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 25 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d9151c9
chore(headless SSR) add missing exports
y-lakhdar Oct 31, 2024
1192473
add ssr-commerce folder
y-lakhdar Oct 31, 2024
34a8767
update readme
y-lakhdar Oct 31, 2024
7277180
use react hooks in sample
y-lakhdar Oct 31, 2024
868593c
add todo
y-lakhdar Oct 31, 2024
3a376d7
add TODOs:
y-lakhdar Oct 31, 2024
0a668dd
chore: general improvements and refactor of headless-ssr-commerce
alexprudhomme Nov 4, 2024
3f5d38e
better imports
alexprudhomme Nov 4, 2024
8886515
Work on SSR parameters serializer
fbeaudoincoveo Oct 30, 2024
bd64354
Add support for sortCriteria in ssr parameter serializer
fbeaudoincoveo Nov 1, 2024
8992c30
Add tests for commerce SSR parameters serializer
fbeaudoincoveo Nov 1, 2024
a1d7ce4
Add / export useAppHistoryRouter hook
fbeaudoincoveo Nov 4, 2024
9f8a1d2
Support parameter manager in commerce SSR sample
fbeaudoincoveo Nov 4, 2024
e656536
Fix amounts in cart
fbeaudoincoveo Nov 4, 2024
61ffded
Fix pdp
fbeaudoincoveo Nov 4, 2024
81d4e86
Add favicon
fbeaudoincoveo Nov 5, 2024
c0d38fe
Support mnf
fbeaudoincoveo Nov 5, 2024
6d1028c
Refactoring
fbeaudoincoveo Nov 5, 2024
64e3cba
Fix warnings
fbeaudoincoveo Nov 5, 2024
f823d86
Add util to remove commerce params from URL
fbeaudoincoveo Nov 11, 2024
47ab808
Set x-coveo-href header
fbeaudoincoveo Nov 11, 2024
8ea8256
Implement working-ish param manager for nextjs commerce SSR
fbeaudoincoveo Nov 11, 2024
49bf3d7
Use better approach with history API
fbeaudoincoveo Nov 12, 2024
7d1db1a
Something that works
fbeaudoincoveo Nov 14, 2024
5b94736
Improve solution
fbeaudoincoveo Nov 14, 2024
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