Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(commerce-ssr): add parameter manager #4626

Open
wants to merge 23 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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.
13 changes: 9 additions & 4 deletions 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 All @@ -39,8 +40,6 @@
"@coveo/release": "1.0.0",
"@testing-library/react": "14.3.1",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@typescript-eslint/eslint-plugin": "7.17.0",
"eslint-plugin-jest-dom": "5.4.0",
"eslint-plugin-react": "7.35.0",
Expand All @@ -54,7 +53,13 @@
},
"peerDependencies": {
"react": "^18",
"react-dom": "^18"
"react-dom": "^18",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0"
},
"optionalDependencies": {
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0"
},
"engines": {
"node": "^20.9.0"
Expand Down
96 changes: 96 additions & 0 deletions packages/headless-react/src/ssr-commerce/client-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
'use client';

import {
DependencyList,
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
} from 'react';

/**
* Subscriber is a function that takes a single argument, which is another function `listener` that returns `void`. The Subscriber function itself returns another function that can be used to unsubscribe the `listener`.
*/
export type Subscriber = (listener: () => void) => () => void;

export type SnapshotGetter<T> = () => T;

/**
* Determine if the given list of dependencies has changed.
*/
function useHasDepsChanged(deps: DependencyList) {
const ref = useRef<DependencyList | null>(null);
if (ref.current === null) {
ref.current = deps;
return false;
}
if (
ref.current.length === deps.length &&
!deps.some((dep, i) => !Object.is(ref.current![i], dep))
) {
return false;
}
ref.current = deps;
return true;
}

/**
* Alternate for `useSyncExternalStore` which runs into infinite loops when hooks are used in `getSnapshot`
* https://github.com/facebook/react/issues/24529
*/
export function useSyncMemoizedStore<T>(
subscribe: Subscriber,
getSnapshot: SnapshotGetter<T>
): T {
const snapshot = useRef<T | null>(null);
const [, forceRender] = useReducer((s) => s + 1, 0);

useEffect(() => {
let isMounted = true;
const unsubscribe = subscribe(() => {
if (isMounted) {
snapshot.current = getSnapshot();
forceRender();
}
});
return () => {
isMounted = false;
unsubscribe();
};
}, [subscribe, getSnapshot]);

// Since useRef does not take a dependencies array changes to dependencies need to be processed explicitly
if (
useHasDepsChanged([subscribe, getSnapshot]) ||
snapshot.current === null
) {
snapshot.current = getSnapshot();
}

return snapshot.current;
}

function getUrl() {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In https://docs.coveo.com/en/headless/latest/usage/headless-server-side-rendering/implement-search-parameter-support/#implement-a-history-router-hook, we explain how to implement this.

I thought we might as well create and export a hook for that to reduce boilerplating.

if (typeof window === 'undefined') {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need this despite the use client?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

window is undefined in 'use client' components when they are first rendered on the server. I am not sure how this works in the context of a hook 🤔 .

Copy link
Contributor Author

@fbeaudoincoveo fbeaudoincoveo Nov 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I remove the check, the server responds with 500 (ReferenceError: document is not defined) upon the initial GET request.

return null;
}
return new URL(document.location.href);
}

export function useAppHistoryRouter() {
const [url, updateUrl] = useReducer(() => getUrl(), getUrl());
useEffect(() => {
window.addEventListener('popstate', updateUrl);
return () => window.removeEventListener('popstate', updateUrl);
}, []);
const replace = useCallback(
(href: string) => window.history.replaceState(null, document.title, href),
[]
);
const push = useCallback(
(href: string) => window.history.pushState(null, document.title, href),
[]
);
return useMemo(() => ({url, replace, push}), [url, replace, push]);
}
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
),
},
};
}
Loading
Loading