Skip to content

Commit

Permalink
fix(headless commerce SSR): fix react provider hydration logic (#4792)
Browse files Browse the repository at this point in the history
# Hydration Logic
Pass the appropriate object to the `hydrateStaticState` method instead
of all the controllers from the static state.

# Typing changes
## Clearer engine definition type
### Before 😵‍💫

![image](https://github.com/user-attachments/assets/55b1091a-8d1d-4125-92b8-a7c5a9a47993)

### After (a bit better)

![image](https://github.com/user-attachments/assets/fcaab4a7-6ac1-469e-92b6-f375fa299440)

## Support type completion in provider arguments
### Before 😔

![image](https://github.com/user-attachments/assets/b57fff20-5f47-4786-921d-155f229d6850)

### After 😀

![image](https://github.com/user-attachments/assets/ea8668f6-fe7c-4d04-a5f8-e6cfa134afc3)


https://coveord.atlassian.net/browse/KIT-3807
  • Loading branch information
y-lakhdar authored and fpbrault committed Dec 17, 2024
1 parent ca4e401 commit 125e5e0
Show file tree
Hide file tree
Showing 3 changed files with 95 additions and 60 deletions.
30 changes: 28 additions & 2 deletions packages/headless-react/src/ssr-commerce/commerce-engine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
defineCommerceEngine as defineBaseCommerceEngine,
CommerceEngineOptions,
SolutionType,
CommerceEngine,
} from '@coveo/headless/ssr-commerce';
// Workaround to prevent Next.js erroring about importing CSR only hooks
import React from 'react';
Expand All @@ -15,7 +16,11 @@ import {
buildHydratedStateProvider,
buildStaticStateProvider,
} from './common.js';
import {ContextState, ReactEngineDefinition} from './types.js';
import {
ContextState,
InferControllerHooksMapFromDefinition,
ReactEngineDefinition,
} from './types.js';

export type ReactCommerceEngineDefinition<
TControllers extends ControllerDefinitionsMap<Controller>,
Expand All @@ -38,7 +43,28 @@ export function createSingletonContext<
*/
export function defineCommerceEngine<
TControllers extends ControllerDefinitionsMap<Controller>,
>(options: CommerceEngineDefinitionOptions<TControllers>) {
>(
options: CommerceEngineDefinitionOptions<TControllers>
): {
useEngine: () => CommerceEngine | undefined;
controllers: InferControllerHooksMapFromDefinition<TControllers>;
listingEngineDefinition: ReactCommerceEngineDefinition<
TControllers,
SolutionType.listing
>;
searchEngineDefinition: ReactCommerceEngineDefinition<
TControllers,
SolutionType.search
>;
standaloneEngineDefinition: ReactCommerceEngineDefinition<
TControllers,
SolutionType.standalone
>;
recommendationEngineDefinition: ReactCommerceEngineDefinition<
TControllers,
SolutionType.recommendation
>;
} {
const singletonContext = createSingletonContext<TControllers>();

type ContextStateType<TSolutionType extends SolutionType> = SingletonGetter<
Expand Down
123 changes: 67 additions & 56 deletions packages/headless-react/src/ssr-commerce/providers.tsx
Original file line number Diff line number Diff line change
@@ -1,86 +1,96 @@
'use client';

import {
HydrateStaticStateOptions,
Cart,
Controller,
InferControllersMapFromDefinition,
ControllerDefinitionsMap,
EngineStaticState,
InferControllerStaticStateMapFromDefinitionsWithSolutionType,
InferHydratedState,
InferStaticState,
NavigatorContext,
SolutionType,
Context,
HydrateStaticStateOptions,
ParameterManager,
Parameters,
} from '@coveo/headless/ssr-commerce';
import {PropsWithChildren, useEffect, useState} from 'react';
import {defineCommerceEngine} from './commerce-engine.js';

interface WithDefinitionProps {
staticState: InferStaticState<RealDefinition>;
navigatorContext: NavigatorContext;
}

type LooseDefinition = {
setNavigatorContextProvider: unknown;
build: unknown;
hydrateStaticState: unknown;
fetchStaticState: unknown;
HydratedStateProvider: unknown;
StaticStateProvider: unknown;
};
import {ReactCommerceEngineDefinition} from './commerce-engine.js';

type RealDefinition =
| ReturnType<typeof defineCommerceEngine>['recommendationEngineDefinition']
| ReturnType<typeof defineCommerceEngine>['listingEngineDefinition']
| ReturnType<typeof defineCommerceEngine>['searchEngineDefinition']
| ReturnType<typeof defineCommerceEngine>['standaloneEngineDefinition'];
type ControllerPropsMap = {[customName: string]: unknown};
type UnknownAction = {type: string};

export function buildProviderWithDefinition(looseDefinition: LooseDefinition) {
export function buildProviderWithDefinition<
TControllers extends ControllerDefinitionsMap<Controller>,
TSolutionType extends SolutionType,
>(definition: ReactCommerceEngineDefinition<TControllers, TSolutionType>) {
return function WrappedProvider({
staticState,
navigatorContext,
children,
}: PropsWithChildren<WithDefinitionProps>) {
const definition = looseDefinition as RealDefinition;
type RecommendationHydratedState = InferHydratedState<typeof definition>;
}: PropsWithChildren<{
staticState: EngineStaticState<
UnknownAction,
InferControllerStaticStateMapFromDefinitionsWithSolutionType<
TControllers,
TSolutionType
>
>;
navigatorContext: NavigatorContext;
}>) {
const [hydratedState, setHydratedState] = useState<
RecommendationHydratedState | undefined
InferHydratedState<typeof definition> | undefined
>(undefined);

definition.setNavigatorContextProvider(() => navigatorContext);

useEffect(() => {
const {searchActions, controllers} = staticState;

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const hydrateControllers: Record<string, any> = {};
const hydrateArguments: ControllerPropsMap = {};

if ('parameterManager' in controllers) {
hydrateControllers.parameterManager = {
hydrateArguments.parameterManager = {
initialState: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
parameters: (controllers as any).parameterManager.state.parameters,
parameters: (
controllers.parameterManager as ParameterManager<Parameters>
).state.parameters,
},
};
}

if ('cart' in controllers) {
hydrateControllers.cart = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
initialState: {items: (controllers as any).cart.state.items},
hydrateArguments.cart = {
initialState: {items: (controllers.cart as Cart).state.items},
};
}

if ('context' in controllers) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hydrateControllers.context = (controllers as any).context.state;
hydrateArguments.context = (controllers.context as Context).state;
}

definition
.hydrateStaticState({
searchActions,
controllers: {
...controllers,
...hydrateControllers,
},
} as HydrateStaticStateOptions<{type: string}>)
.then(({engine, controllers}) => {
setHydratedState({engine, controllers});
const args: HydrateStaticStateOptions<UnknownAction> &
ControllerPropsMap = {
searchActions,
};

if (hydrateArguments) {
args.controllers = hydrateArguments;
}

// @ts-expect-error Casting to loose definition since we don't need the inferred controllers here
const looseDefinition = definition as ReactCommerceEngineDefinition<
ControllerDefinitionsMap<Controller>,
SolutionType
>;
looseDefinition.hydrateStaticState(args).then(({engine, controllers}) => {
setHydratedState({
engine,
controllers: controllers as InferControllersMapFromDefinition<
TControllers,
TSolutionType
>,
});
});
}, [staticState]);

if (hydratedState) {
Expand All @@ -94,13 +104,14 @@ export function buildProviderWithDefinition(looseDefinition: LooseDefinition) {
);
}

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const StaticStateProviderWithAnyControllers = (looseDefinition as any)
.StaticStateProvider as React.ComponentType<{
// eslint-disable-next-line @typescript-eslint/no-explicit-any
controllers: any;
children: React.ReactNode;
}>;
const StaticStateProviderWithAnyControllers =
definition.StaticStateProvider as React.ComponentType<{
controllers: InferControllerStaticStateMapFromDefinitionsWithSolutionType<
ControllerDefinitionsMap<Controller>,
SolutionType
>;
children: React.ReactNode;
}>;

return (
<StaticStateProviderWithAnyControllers
Expand Down
2 changes: 0 additions & 2 deletions packages/headless-react/src/ssr-commerce/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ export type ReactEngineDefinition<
TEngineOptions,
TSolutionType extends SolutionType,
> = EngineDefinition<TControllers, TEngineOptions, TSolutionType> & {
controllers: InferControllerHooksMapFromDefinition<TControllers>;
useEngine(): SSRCommerceEngine | undefined;
StaticStateProvider: FunctionComponent<
PropsWithChildren<{
controllers: InferControllerStaticStateMapFromDefinitionsWithSolutionType<
Expand Down

0 comments on commit 125e5e0

Please sign in to comment.