Skip to content

Commit

Permalink
feat(headless SSR): improved error messaging for hook misuse (#4771)
Browse files Browse the repository at this point in the history
Replace the previous cryptic error with a more meaningful and
user-friendly message when a hook is used with an incorrect state
provider.

### Before

![image](https://github.com/user-attachments/assets/811c2b95-f57e-4360-8b07-43bfc196c589)

### Now

![image](https://github.com/user-attachments/assets/b0bcf935-d924-4324-90e7-f828e043119b)

This change aims to improve developer experience by making debugging
easier and faster.

https://coveord.atlassian.net/browse/KIT-3783

---------

Co-authored-by: Alex Prudhomme <[email protected]>
  • Loading branch information
2 people authored and fpbrault committed Dec 17, 2024
1 parent 141cce3 commit c79eb88
Show file tree
Hide file tree
Showing 6 changed files with 88 additions and 19 deletions.
26 changes: 25 additions & 1 deletion packages/headless-react/src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,31 @@
import type {SolutionType} from '@coveo/headless/ssr-commerce';
import {capitalize} from './utils.js';

export class MissingEngineProviderError extends Error {
static message =
'Unable to find Context. Please make sure you are wrapping your component with either `StaticStateProvider` or `HydratedStateProvider` component that can provide the required context.';
'Unable to find Context. Make sure your component using a controller is wrapped in one of the following providers: `RecommendationProvider`, `ListingProvider`, `SearchProvider`, or `StandaloneProvider`';
constructor() {
super(MissingEngineProviderError.message);
}
}

export class UndefinedControllerError extends Error {
static createEngineSupportMessage(solutionTypes: SolutionType[]) {
const supportedEngineDefinitionList = solutionTypes.map(
(solutionType) => `${solutionType}EngineDefinition`
);
return `This controller is only available in these engine definitions:\n${supportedEngineDefinitionList.map((def) => ` • ${def}`).join('\n')}`;
}

constructor(controllerName: string, solutionTypes: SolutionType[]) {
super(
[
`You're importing a controller (use${capitalize(controllerName)}) that is not defined in the current engine definition`,
UndefinedControllerError.createEngineSupportMessage(solutionTypes),
'',
'Ensure that the component using the controller is wrapped in the appropriate State Provider.',
// 'Learn more: TODO: Add link to documentation on how to use hooks with providers',
].join('\n')
);
}
}
24 changes: 16 additions & 8 deletions packages/headless-react/src/ssr-commerce/commerce-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,38 +61,46 @@ export function defineCommerceEngine<
listingEngineDefinition: {
...listingEngineDefinition,
StaticStateProvider: buildStaticStateProvider(
singletonContext as ListingContext
singletonContext as ListingContext,
SolutionType.listing
),

HydratedStateProvider: buildHydratedStateProvider(
singletonContext as ListingContext
singletonContext as ListingContext,
SolutionType.listing
),
},
searchEngineDefinition: {
...searchEngineDefinition,
StaticStateProvider: buildStaticStateProvider(
singletonContext as SearchContext
singletonContext as SearchContext,
SolutionType.search
),
HydratedStateProvider: buildHydratedStateProvider(
singletonContext as SearchContext
singletonContext as SearchContext,
SolutionType.search
),
},
standaloneEngineDefinition: {
...standaloneEngineDefinition,
StaticStateProvider: buildStaticStateProvider(
singletonContext as StandaloneContext
singletonContext as StandaloneContext,
SolutionType.standalone
),
HydratedStateProvider: buildHydratedStateProvider(
singletonContext as StandaloneContext
singletonContext as StandaloneContext,
SolutionType.standalone
),
},
recommendationEngineDefinition: {
...recommendationEngineDefinition,
StaticStateProvider: buildStaticStateProvider(
singletonContext as RecommendationContext
singletonContext as RecommendationContext,
SolutionType.recommendation
),
HydratedStateProvider: buildHydratedStateProvider(
singletonContext as RecommendationContext
singletonContext as RecommendationContext,
SolutionType.recommendation
),
},
};
Expand Down
40 changes: 32 additions & 8 deletions packages/headless-react/src/ssr-commerce/common.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
Controller,
ControllerDefinitionsMap,
ControllerDefinition,
InferControllerFromDefinition,
InferControllerStaticStateMapFromDefinitionsWithSolutionType,
InferControllersMapFromDefinition,
Expand All @@ -15,7 +16,10 @@ import {
PropsWithChildren,
} from 'react';
import {useSyncMemoizedStore} from '../client-utils.js';
import {MissingEngineProviderError} from '../errors.js';
import {
MissingEngineProviderError,
UndefinedControllerError,
} from '../errors.js';
import {SingletonGetter, capitalize, mapObject} from '../utils.js';
import {
ContextHydratedState,
Expand All @@ -41,16 +45,30 @@ function buildControllerHook<
singletonContext: SingletonGetter<
Context<ContextState<TControllers, TSolutionType> | null>
>,
key: TKey
key: TKey,
controllerDefinition: ControllerDefinition<Controller>
): ControllerHook<InferControllerFromDefinition<TControllers[TKey]>> {
return () => {
const ctx = useContext(singletonContext.get());
if (ctx === null) {
throw new MissingEngineProviderError();
}

const allSolutionTypes = Object.values(SolutionType);

const supportedSolutionTypes = allSolutionTypes.filter(
(solutionType) => controllerDefinition[solutionType] === true
);

// TODO: KIT-3715 - Workaround to ensure that 'key' can be used as an index for 'ctx.controllers'. A more robust solution is needed.
type ControllerKey = Exclude<keyof typeof ctx.controllers, symbol>;
if (ctx.controllers[key as ControllerKey] === undefined) {
throw new UndefinedControllerError(
key.toString(),
supportedSolutionTypes
);
}

const subscribe = useCallback(
(listener: () => void) =>
isHydratedStateContext(ctx)
Expand Down Expand Up @@ -89,9 +107,9 @@ export function buildControllerHooks<
return (
controllersMap
? Object.fromEntries(
Object.keys(controllersMap).map((key) => [
Object.entries(controllersMap).map(([key, controllerDefinition]) => [
`use${capitalize(key)}`,
buildControllerHook(singletonContext, key),
buildControllerHook(singletonContext, key, controllerDefinition),
])
)
: {}
Expand Down Expand Up @@ -121,7 +139,8 @@ export function buildStaticStateProvider<
>(
singletonContext: SingletonGetter<
Context<ContextState<TControllers, TSolutionType> | null>
>
>,
solutionType: TSolutionType
) {
return ({
controllers,
Expand All @@ -133,7 +152,7 @@ export function buildStaticStateProvider<
>;
}>) => {
const {Provider} = singletonContext.get();
return <Provider value={{controllers}}>{children}</Provider>;
return <Provider value={{controllers, solutionType}}>{children}</Provider>;
};
}

Expand All @@ -143,7 +162,8 @@ export function buildHydratedStateProvider<
>(
singletonContext: SingletonGetter<
Context<ContextState<TControllers, TSolutionType> | null>
>
>,
solutionType: TSolutionType
) {
return ({
engine,
Expand All @@ -154,6 +174,10 @@ export function buildHydratedStateProvider<
controllers: InferControllersMapFromDefinition<TControllers, TSolutionType>;
}>) => {
const {Provider} = singletonContext.get();
return <Provider value={{engine, controllers}}>{children}</Provider>;
return (
<Provider value={{engine, controllers, solutionType}}>
{children}
</Provider>
);
};
}
2 changes: 2 additions & 0 deletions packages/headless-react/src/ssr-commerce/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type ContextStaticState<
TControllers,
TSolutionType
>;
solutionType: TSolutionType;
};

export type ContextHydratedState<
Expand All @@ -26,6 +27,7 @@ export type ContextHydratedState<
> = {
engine: SSRCommerceEngine;
controllers: InferControllersMapFromDefinition<TControllers, TSolutionType>;
solutionType: TSolutionType;
};

export type ContextState<
Expand Down
14 changes: 12 additions & 2 deletions packages/headless/src/app/commerce-ssr-engine/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,19 @@ export interface EngineStaticState<
controllers: TControllers;
}

export interface SolutionTypeAvailability {
[SolutionType.search]?: boolean;
[SolutionType.listing]?: boolean;
[SolutionType.standalone]?: boolean;
[SolutionType.recommendation]?: boolean;
}

export type ControllerDefinition<TController extends Controller> =
| ControllerDefinitionWithoutProps<TController>
| ControllerDefinitionWithProps<TController, unknown>;
SolutionTypeAvailability &
(
| ControllerDefinitionWithoutProps<TController>
| ControllerDefinitionWithProps<TController, unknown>
);

export interface ControllerDefinitionsMap<TController extends Controller> {
[customName: string]: ControllerDefinition<TController>;
Expand Down
1 change: 1 addition & 0 deletions packages/headless/src/ssr-commerce.index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ export type {
} from './app/engine-configuration.js';
export {SolutionType} from './app/commerce-ssr-engine/types/common.js';
export type {
ControllerDefinition,
ControllerDefinitionsMap,
InferControllerFromDefinition,
InferControllersMapFromDefinition,
Expand Down

0 comments on commit c79eb88

Please sign in to comment.