Skip to content

Commit

Permalink
feat(headless SSR): add hooks for ssr react commerce (#4614)
Browse files Browse the repository at this point in the history
## 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
y-lakhdar authored Nov 6, 2024
1 parent 368aafa commit 7936561
Show file tree
Hide file tree
Showing 13 changed files with 372 additions and 16 deletions.
10 changes: 7 additions & 3 deletions packages/headless-react/README.md
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.
3 changes: 2 additions & 1 deletion packages/headless-react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"license": "Apache-2.0",
"type": "module",
"exports": {
"./ssr": "./dist/ssr/index.js"
"./ssr": "./dist/ssr/index.js",
"./ssr-commerce": "./dist/ssr-commerce/index.js"
},
"files": [
"dist"
Expand Down
File renamed without changes.
7 changes: 7 additions & 0 deletions packages/headless-react/src/errors.ts
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 packages/headless-react/src/ssr-commerce/commerce-engine.tsx
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
),
},
};
}
165 changes: 165 additions & 0 deletions packages/headless-react/src/ssr-commerce/common.tsx
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>;
};
}
4 changes: 4 additions & 0 deletions packages/headless-react/src/ssr-commerce/index.ts
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';
79 changes: 79 additions & 0 deletions packages/headless-react/src/ssr-commerce/types.ts
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
>;
}>
>;
};
Loading

0 comments on commit 7936561

Please sign in to comment.