-
Notifications
You must be signed in to change notification settings - Fork 34
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(headless SSR): add hooks for ssr react commerce (#4614)
## TL;DR Integration for server-side rendering with commerce functionalities. The changes include * Updates to the package exports * New react hooks * Creation of new types and hooks to facilitate SSR in commerce applications. ## Key changes include: ### Enhancements to SSR Support: * Added a new export for `./ssr-commerce` in the `package.json` file. Implementer can now fetch all the goodies from the `@coveo/headless-react/ssr-commerce` ### New Utilities and Hooks: * Created `useEngine` react hook. * Added utility functions for building controller hooks (e.g. `useSearchBox`, `useFacetGenerator`), engine hooks, and state providers to manage SSR and CSR contexts. * Added Static and Hydrated state providers for every solution types (search, listing, standalone). > Note > The controller hooks are programmatically generated based on the engine definition. You can find the logic in `buildControllerHooks` ### Type Definitions: * Defined new types in `types.ts` to support the new SSR commerce functionalities, including `ReactCommerceEngineDefinition`, `ContextState`, and various hooks and state types. https://coveord.atlassian.net/browse/KIT-3695
- Loading branch information
Showing
13 changed files
with
372 additions
and
16 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,9 +1,13 @@ | ||
# Headless React Utils for SSR | ||
|
||
`@coveo/headless-react/ssr` provides React utilities for server-side rendering with headless controllers. | ||
`@coveo/headless-react` provides React utilities for server-side rendering with headless controllers. This package includes two sub-packages: | ||
|
||
- `@coveo/headless-react/ssr`: For general server-side rendering with headless controllers. | ||
- `@coveo/headless-react/ssr-commerce`: For implementing a commerce storefront with server-side rendering. | ||
|
||
## Learn more | ||
|
||
<!-- TODO: KIT-3698: Add link to headless-react/ssr-commerce link in public doc --> | ||
|
||
- Checkout our [Documentation](https://docs.coveo.com/en/headless/latest/usage/headless-server-side-rendering/) | ||
- Refer to [samples/headless-ssr](https://github.com/coveo/ui-kit/tree/master/packages/samples/headless-ssr/) for examples. | ||
- All exports from `@coveo/headless/ssr` are also available from under `@coveo/headless-react/ssr` as convenience. | ||
- Refer to [samples/headless-ssr-commerce](https://github.com/coveo/ui-kit/tree/master/packages/samples/headless-ssr-commerce/) for examples. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
File renamed without changes.
File renamed without changes.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
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.'; | ||
constructor() { | ||
super(MissingEngineProviderError.message); | ||
} | ||
} |
102 changes: 102 additions & 0 deletions
102
packages/headless-react/src/ssr-commerce/commerce-engine.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
import { | ||
Controller, | ||
CommerceEngine, | ||
ControllerDefinitionsMap, | ||
CommerceEngineDefinitionOptions, | ||
defineCommerceEngine as defineBaseCommerceEngine, | ||
CommerceEngineOptions, | ||
SolutionType, | ||
} from '@coveo/headless/ssr-commerce'; | ||
// Workaround to prevent Next.js erroring about importing CSR only hooks | ||
import React from 'react'; | ||
import {singleton, SingletonGetter} from '../utils.js'; | ||
import { | ||
buildControllerHooks, | ||
buildEngineHook, | ||
buildHydratedStateProvider, | ||
buildStaticStateProvider, | ||
} from './common.js'; | ||
import {ContextState, ReactEngineDefinition} from './types.js'; | ||
|
||
export type ReactCommerceEngineDefinition< | ||
TControllers extends ControllerDefinitionsMap<CommerceEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
> = ReactEngineDefinition< | ||
CommerceEngine, | ||
TControllers, | ||
CommerceEngineOptions, | ||
TSolutionType | ||
>; | ||
|
||
// Wrapper to workaround the limitation that `createContext()` cannot be called directly during SSR in next.js | ||
export function createSingletonContext< | ||
TControllers extends ControllerDefinitionsMap<CommerceEngine, Controller>, | ||
TSolutionType extends SolutionType = SolutionType, | ||
>() { | ||
return singleton(() => | ||
React.createContext<ContextState< | ||
CommerceEngine, | ||
TControllers, | ||
TSolutionType | ||
> | null>(null) | ||
); | ||
} | ||
|
||
/** | ||
* Returns controller hooks as well as SSR and CSR context providers that can be used to interact with a Commerce engine | ||
* on the server and client side respectively. | ||
*/ | ||
export function defineCommerceEngine< | ||
TControllers extends ControllerDefinitionsMap<CommerceEngine, Controller>, | ||
>(options: CommerceEngineDefinitionOptions<TControllers>) { | ||
const singletonContext = createSingletonContext<TControllers>(); | ||
|
||
type ContextStateType<TSolutionType extends SolutionType> = SingletonGetter< | ||
React.Context<ContextState< | ||
CommerceEngine, | ||
TControllers, | ||
TSolutionType | ||
> | null> | ||
>; | ||
type ListingContext = ContextStateType<SolutionType.listing>; | ||
type SearchContext = ContextStateType<SolutionType.search>; | ||
type StandaloneContext = ContextStateType<SolutionType.standalone>; | ||
|
||
const { | ||
listingEngineDefinition, | ||
searchEngineDefinition, | ||
standaloneEngineDefinition, | ||
} = defineBaseCommerceEngine({...options}); | ||
return { | ||
useEngine: buildEngineHook(singletonContext), | ||
controllers: buildControllerHooks(singletonContext, options.controllers), | ||
listingEngineDefinition: { | ||
...listingEngineDefinition, | ||
StaticStateProvider: buildStaticStateProvider( | ||
singletonContext as ListingContext | ||
), | ||
|
||
HydratedStateProvider: buildHydratedStateProvider( | ||
singletonContext as ListingContext | ||
), | ||
}, | ||
searchEngineDefinition: { | ||
...searchEngineDefinition, | ||
StaticStateProvider: buildStaticStateProvider( | ||
singletonContext as SearchContext | ||
), | ||
HydratedStateProvider: buildHydratedStateProvider( | ||
singletonContext as SearchContext | ||
), | ||
}, | ||
standaloneEngineDefinition: { | ||
...standaloneEngineDefinition, | ||
StaticStateProvider: buildStaticStateProvider( | ||
singletonContext as StandaloneContext | ||
), | ||
HydratedStateProvider: buildHydratedStateProvider( | ||
singletonContext as StandaloneContext | ||
), | ||
}, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,165 @@ | ||
import { | ||
Controller, | ||
ControllerDefinitionsMap, | ||
CoreEngineNext, | ||
InferControllerFromDefinition, | ||
InferControllerStaticStateMapFromDefinitionsWithSolutionType, | ||
InferControllersMapFromDefinition, | ||
SolutionType, | ||
} from '@coveo/headless/ssr-commerce'; | ||
import { | ||
useContext, | ||
useCallback, | ||
useMemo, | ||
Context, | ||
PropsWithChildren, | ||
} from 'react'; | ||
import {useSyncMemoizedStore} from '../client-utils.js'; | ||
import {MissingEngineProviderError} from '../errors.js'; | ||
import {SingletonGetter, capitalize, mapObject} from '../utils.js'; | ||
import { | ||
ContextHydratedState, | ||
ContextState, | ||
ControllerHook, | ||
InferControllerHooksMapFromDefinition, | ||
} from './types.js'; | ||
|
||
function isHydratedStateContext< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
>( | ||
ctx: ContextState<TEngine, TControllers, TSolutionType> | ||
): ctx is ContextHydratedState<TEngine, TControllers, TSolutionType> { | ||
return 'engine' in ctx; | ||
} | ||
|
||
function buildControllerHook< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TKey extends keyof TControllers, | ||
TSolutionType extends SolutionType, | ||
>( | ||
singletonContext: SingletonGetter< | ||
Context<ContextState<TEngine, TControllers, TSolutionType> | null> | ||
>, | ||
key: TKey | ||
): ControllerHook<InferControllerFromDefinition<TControllers[TKey]>> { | ||
return () => { | ||
const ctx = useContext(singletonContext.get()); | ||
if (ctx === null) { | ||
throw new MissingEngineProviderError(); | ||
} | ||
|
||
// 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>; | ||
const subscribe = useCallback( | ||
(listener: () => void) => | ||
isHydratedStateContext(ctx) | ||
? ctx.controllers[key as ControllerKey].subscribe(listener) | ||
: () => {}, | ||
[ctx] | ||
); | ||
const getStaticState = useCallback(() => ctx.controllers[key].state, [ctx]); | ||
const state = useSyncMemoizedStore(subscribe, getStaticState); | ||
const controller = useMemo(() => { | ||
if (!isHydratedStateContext(ctx)) { | ||
return undefined; | ||
} | ||
const controller = ctx.controllers[key as ControllerKey]; | ||
const {state: _, subscribe: __, ...remainder} = controller; | ||
return mapObject(remainder, (member) => | ||
typeof member === 'function' ? member.bind(controller) : member | ||
) as Omit< | ||
InferControllerFromDefinition<TControllers[TKey]>, | ||
'state' | 'subscribe' | ||
>; | ||
}, [ctx, key]); | ||
return {state, controller}; | ||
}; | ||
} | ||
|
||
export function buildControllerHooks< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
>( | ||
singletonContext: SingletonGetter< | ||
Context<ContextState<TEngine, TControllers, TSolutionType> | null> | ||
>, | ||
controllersMap?: TControllers | ||
) { | ||
return ( | ||
controllersMap | ||
? Object.fromEntries( | ||
Object.keys(controllersMap).map((key) => [ | ||
`use${capitalize(key)}`, | ||
buildControllerHook(singletonContext, key), | ||
]) | ||
) | ||
: {} | ||
) as InferControllerHooksMapFromDefinition<TControllers>; | ||
} | ||
|
||
export function buildEngineHook< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
>( | ||
singletonContext: SingletonGetter< | ||
Context<ContextState<TEngine, TControllers, TSolutionType> | null> | ||
> | ||
) { | ||
return () => { | ||
const ctx = useContext(singletonContext.get()); | ||
if (ctx === null) { | ||
throw new MissingEngineProviderError(); | ||
} | ||
return isHydratedStateContext(ctx) ? ctx.engine : undefined; | ||
}; | ||
} | ||
|
||
export function buildStaticStateProvider< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
>( | ||
singletonContext: SingletonGetter< | ||
Context<ContextState<TEngine, TControllers, TSolutionType> | null> | ||
> | ||
) { | ||
return ({ | ||
controllers, | ||
children, | ||
}: PropsWithChildren<{ | ||
controllers: InferControllerStaticStateMapFromDefinitionsWithSolutionType< | ||
TControllers, | ||
TSolutionType | ||
>; | ||
}>) => { | ||
const {Provider} = singletonContext.get(); | ||
return <Provider value={{controllers}}>{children}</Provider>; | ||
}; | ||
} | ||
|
||
export function buildHydratedStateProvider< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
>( | ||
singletonContext: SingletonGetter< | ||
Context<ContextState<TEngine, TControllers, TSolutionType> | null> | ||
> | ||
) { | ||
return ({ | ||
engine, | ||
controllers, | ||
children, | ||
}: PropsWithChildren<{ | ||
engine: TEngine; | ||
controllers: InferControllersMapFromDefinition<TControllers, TSolutionType>; | ||
}>) => { | ||
const {Provider} = singletonContext.get(); | ||
return <Provider value={{engine, controllers}}>{children}</Provider>; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
export {defineCommerceEngine} from './commerce-engine.js'; | ||
export type {ReactCommerceEngineDefinition} from './commerce-engine.js'; | ||
export {MissingEngineProviderError} from '../errors.js'; | ||
export * from '@coveo/headless/ssr-commerce'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,79 @@ | ||
import { | ||
Controller, | ||
ControllerDefinitionsMap, | ||
InferControllerFromDefinition, | ||
InferControllersMapFromDefinition, | ||
InferControllerStaticStateMapFromDefinitionsWithSolutionType, | ||
EngineDefinition, | ||
SolutionType, | ||
CoreEngineNext, | ||
} from '@coveo/headless/ssr-commerce'; | ||
import {FunctionComponent, PropsWithChildren} from 'react'; | ||
|
||
export type ContextStaticState< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
> = { | ||
controllers: InferControllerStaticStateMapFromDefinitionsWithSolutionType< | ||
TControllers, | ||
TSolutionType | ||
>; | ||
}; | ||
|
||
export type ContextHydratedState< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
> = { | ||
engine: TEngine; | ||
controllers: InferControllersMapFromDefinition<TControllers, TSolutionType>; | ||
}; | ||
|
||
export type ContextState< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TSolutionType extends SolutionType, | ||
> = | ||
| ContextStaticState<TEngine, TControllers, TSolutionType> | ||
| ContextHydratedState<TEngine, TControllers, TSolutionType>; | ||
|
||
export type ControllerHook<TController extends Controller> = () => { | ||
state: TController['state']; | ||
controller?: Omit<TController, 'state' | 'subscribe'>; | ||
}; | ||
|
||
export type InferControllerHooksMapFromDefinition< | ||
TControllers extends ControllerDefinitionsMap<CoreEngineNext, Controller>, | ||
> = { | ||
[K in keyof TControllers as `use${Capitalize< | ||
K extends string ? K : never | ||
>}`]: ControllerHook<InferControllerFromDefinition<TControllers[K]>>; | ||
}; | ||
|
||
export type ReactEngineDefinition< | ||
TEngine extends CoreEngineNext, | ||
TControllers extends ControllerDefinitionsMap<TEngine, Controller>, | ||
TEngineOptions, | ||
TSolutionType extends SolutionType, | ||
> = EngineDefinition<TEngine, TControllers, TEngineOptions, TSolutionType> & { | ||
controllers: InferControllerHooksMapFromDefinition<TControllers>; | ||
useEngine(): TEngine | undefined; | ||
StaticStateProvider: FunctionComponent< | ||
PropsWithChildren<{ | ||
controllers: InferControllerStaticStateMapFromDefinitionsWithSolutionType< | ||
TControllers, | ||
TSolutionType | ||
>; | ||
}> | ||
>; | ||
HydratedStateProvider: FunctionComponent< | ||
PropsWithChildren<{ | ||
engine: TEngine; | ||
controllers: InferControllersMapFromDefinition< | ||
TControllers, | ||
TSolutionType | ||
>; | ||
}> | ||
>; | ||
}; |
Oops, something went wrong.