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(headless): refresh commerce recommendations server-side #4617

Open
wants to merge 33 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
187fc0b
recs server-side working
y-lakhdar Oct 31, 2024
d571439
recognize search action promises
y-lakhdar Nov 1, 2024
4dc2717
add typeguard
y-lakhdar Nov 1, 2024
2e5f794
prevent multiple recommendations with the same slot
y-lakhdar Nov 1, 2024
de43049
clean
y-lakhdar Nov 3, 2024
9254b93
draft
y-lakhdar Nov 14, 2024
f2d1fa1
working draft
y-lakhdar Nov 15, 2024
bc63a92
test
y-lakhdar Nov 15, 2024
8d170d2
Merge branch 'master' of github.com:coveo/ui-kit into ssr-recs
y-lakhdar Nov 15, 2024
e60bfe5
update sample
y-lakhdar Nov 15, 2024
be888c8
recommendation hydration working
y-lakhdar Nov 15, 2024
c4ff2b9
clean build function
y-lakhdar Nov 15, 2024
2783740
clean exports
y-lakhdar Nov 15, 2024
8a6f9d8
filter invalid and duplicate recommendations
y-lakhdar Nov 16, 2024
025b34f
refacto
y-lakhdar Nov 16, 2024
07d1ec8
simplify build factory
y-lakhdar Nov 17, 2024
9b049fd
adjust factory params and throw error on bad engine definition
y-lakhdar Nov 17, 2024
84e5c53
do not ask props for disabled controllers
y-lakhdar Nov 17, 2024
018c0be
remove comments
y-lakhdar Nov 17, 2024
a7200d8
update warning message
y-lakhdar Nov 17, 2024
785951f
fix export
y-lakhdar Nov 17, 2024
f342da5
clean PR
y-lakhdar Nov 18, 2024
677fd45
clean controller build condition
y-lakhdar Nov 18, 2024
87d0792
create commerce build result type
y-lakhdar Nov 18, 2024
16c2b5e
remove unnecessary error
y-lakhdar Nov 18, 2024
dbffb51
revert EngineDefinitionControllersPropsOption
y-lakhdar Nov 18, 2024
961c28e
update UT
y-lakhdar Nov 18, 2024
c58b3ae
Add recs to sample
alexprudhomme Nov 18, 2024
81ba114
Merge branch 'master' into ssr-recs
alexprudhomme Nov 18, 2024
b8e6e16
no core engine
alexprudhomme Nov 18, 2024
3f93d51
add todo
alexprudhomme Nov 18, 2024
93b3967
Merge branch 'master' into ssr-recs
alexprudhomme Nov 22, 2024
a73f175
fix build
alexprudhomme Nov 22, 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
51 changes: 41 additions & 10 deletions packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import {buildProductListing} from '../../controllers/commerce/product-listing/he
import {buildSearch} from '../../controllers/commerce/search/headless-search.js';
import type {Controller} from '../../controllers/controller/headless-controller.js';
import {createWaitForActionMiddleware} from '../../utils/utils.js';
import {buildControllerDefinitions} from '../commerce-ssr-engine/common.js';
import {
buildControllerDefinitions,
buildRecommendationFilter,
} from '../commerce-ssr-engine/common.js';
import {
ControllerDefinitionsMap,
InferControllerStaticStateMapFromDefinitionsWithSolutionType,
Expand Down Expand Up @@ -37,7 +40,7 @@ export interface SSRCommerceEngine extends CommerceEngine {
/**
* Waits for the search to be completed and returns a promise that resolves to a `SearchCompletedAction`.
*/
waitForRequestCompletedAction(): Promise<Action>;
waitForRequestCompletedAction(): Promise<Action>[];
}

export type CommerceEngineDefinitionOptions<
Expand All @@ -56,13 +59,20 @@ function isSearchCompletedAction(action: unknown): action is Action {
);
}

function isRecommendationCompletedAction(action: unknown): action is Action {
return /^commerce\/recommendations\/fetch\/(fulfilled|rejected)$/.test(
(action as UnknownAction).type
);
}

function noSearchActionRequired(_action: unknown): _action is Action {
return true;
}

function buildSSRCommerceEngine(
solutionType: SolutionType,
options: CommerceEngineOptions
options: CommerceEngineOptions,
recommendationCount: number
): SSRCommerceEngine {
let actionCompletionMiddleware: ReturnType<
typeof createWaitForActionMiddleware
Expand All @@ -85,11 +95,17 @@ function buildSSRCommerceEngine(
);
}

const recommendationActionMiddlewares = Array.from(
{length: recommendationCount},
() => createWaitForActionMiddleware(isRecommendationCompletedAction)
);

const commerceEngine = buildCommerceEngine({
...options,
middlewares: [
...(options.middlewares ?? []),
actionCompletionMiddleware.middleware,
...recommendationActionMiddlewares.map(({middleware}) => middleware),
],
});

Expand All @@ -101,7 +117,10 @@ function buildSSRCommerceEngine(
},

waitForRequestCompletedAction() {
return actionCompletionMiddleware.promise;
return [
actionCompletionMiddleware.promise,
...recommendationActionMiddlewares.map(({promise}) => promise),
];
},
};
}
Expand Down Expand Up @@ -163,6 +182,10 @@ export function defineCommerceEngine<
type HydrateStaticStateFromBuildResultParameters =
Parameters<HydrateStaticStateFromBuildResultFunction>;

const recommendationHelper = buildRecommendationFilter(
controllerDefinitions ?? {}
);

const getOptions = () => {
return engineOptions;
};
Expand All @@ -180,7 +203,8 @@ export function defineCommerceEngine<
solutionType,
buildOptions?.extend
? await buildOptions.extend(getOptions())
: getOptions()
: getOptions(),
recommendationHelper.count
);
const controllers = buildControllerDefinitions({
definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions,
Expand Down Expand Up @@ -232,10 +256,14 @@ export function defineCommerceEngine<
buildSearch(engine).executeFirstSearch();
}

const searchAction = await engine.waitForRequestCompletedAction();
recommendationHelper.refresh(controllers);

const searchActions = await Promise.all(
engine.waitForRequestCompletedAction()
);

return createStaticState({
searchAction,
searchActions,
controllers,
}) as EngineStaticState<
UnknownAction,
Expand Down Expand Up @@ -266,7 +294,7 @@ export function defineCommerceEngine<
solutionType
).fromBuildResult({
buildResult,
searchAction: params[0]!.searchAction,
searchActions: params[0]!.searchActions,
});
return staticState;
},
Expand All @@ -277,10 +305,13 @@ export function defineCommerceEngine<
const [
{
buildResult: {engine, controllers},
searchAction,
searchActions,
},
] = params;
engine.dispatch(searchAction);

searchActions.forEach((action) => {
engine.dispatch(action);
});
await engine.waitForRequestCompletedAction();
return {engine, controllers};
},
Expand Down
35 changes: 35 additions & 0 deletions packages/headless/src/app/commerce-ssr-engine/common.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import {Recommendations} from '../../controllers/commerce/recommendations/headless-recommendations.js';
import {Controller} from '../../controllers/controller/headless-controller.js';
import {InvalidControllerDefinition} from '../../utils/errors.js';
import {filterObject, mapObject} from '../../utils/utils.js';
Expand Down Expand Up @@ -103,3 +104,37 @@ export function ensureAtLeastOneSolutionType(
throw new InvalidControllerDefinition();
}
}
export function buildRecommendationFilter<
TEngine extends CoreEngine | CoreEngineNext,
TControllerDefinitions extends ControllerDefinitionsMap<TEngine, Controller>,
>(controllerDefinitions: TControllerDefinitions) {
const keys = Object.entries(controllerDefinitions)
.filter(([_, value]) => 'isRecs' in value && value.isRecs)
.map(([key, _]) => key);

return {
/**
* Gets the number of recommendation controllers from the controller definitions map.
*
* @returns {number} The number of recommendation controllers in the controller definition map
*/
get count() {
return keys.length;
},

/**
* Go through all the controllers passed in argument and only refresh recommendation controllers.
*
* @param controllers - A record of all controllers where the key is the controller name and the value is the controller instance.
*/
refresh(controllers: Record<string, Controller>) {
const isRecommendationController = (key: string) => keys.includes(key);

Object.entries(controllers)
.filter(([key, _]) => isRecommendationController(key))
.forEach(([_, controller]) =>
(controller as Recommendations).refresh?.()
);
},
};
}
57 changes: 48 additions & 9 deletions packages/headless/src/app/search-engine/search-engine.ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {UnknownAction} from '@reduxjs/toolkit';
import type {Controller} from '../../controllers/controller/headless-controller.js';
import {LegacySearchAction} from '../../features/analytics/analytics-utils.js';
import {createWaitForActionMiddleware} from '../../utils/utils.js';
import {buildRecommendationFilter} from '../commerce-ssr-engine/common.js';
import {NavigatorContextProvider} from '../navigatorContextProvider.js';
import {
buildControllerDefinitions,
Expand Down Expand Up @@ -36,7 +37,7 @@ export interface SSRSearchEngine extends SearchEngine {
/**
* Waits for the search to be completed and returns a promise that resolves to a `SearchCompletedAction`.
*/
waitForSearchCompletedAction(): Promise<SearchCompletedAction>;
waitForSearchCompletedAction(): Promise<SearchCompletedAction>[];
}

/**
Expand All @@ -60,21 +61,45 @@ function isSearchCompletedAction(
);
}

function buildSSRSearchEngine(options: SearchEngineOptions): SSRSearchEngine {
function isRecommendationCompletedAction(
action: unknown
): action is SearchCompletedAction {
return /^recommendation\/get\/(fulfilled|rejected)$/.test(
(action as UnknownAction).type
);
}

function buildSSRSearchEngine(
options: SearchEngineOptions,
recommendationCount: number
): SSRSearchEngine {
const {middleware, promise} = createWaitForActionMiddleware(
isSearchCompletedAction
);

const recommendationActionMiddlewares = Array.from(
{length: recommendationCount},
() => createWaitForActionMiddleware(isRecommendationCompletedAction)
);

const searchEngine = buildSearchEngine({
...options,
middlewares: [...(options.middlewares ?? []), middleware],
middlewares: [
...(options.middlewares ?? []),
middleware,
...recommendationActionMiddlewares.map(({middleware}) => middleware),
],
});
return {
...searchEngine,
get state() {
return searchEngine.state;
},
waitForSearchCompletedAction() {
return promise;
return [
promise,
...recommendationActionMiddlewares.map(({promise}) => promise),
];
},
};
}
Expand Down Expand Up @@ -121,6 +146,10 @@ export function defineSearchEngine<
type HydrateStaticStateFromBuildResultParameters =
Parameters<HydrateStaticStateFromBuildResultFunction>;

const recommendationHelper = buildRecommendationFilter(
controllerDefinitions ?? {}
);

const getOptions = () => {
return engineOptions;
};
Expand All @@ -135,7 +164,8 @@ export function defineSearchEngine<
const engine = buildSSRSearchEngine(
buildOptions?.extend
? await buildOptions.extend(getOptions())
: getOptions()
: getOptions(),
recommendationHelper.count
);
const controllers = buildControllerDefinitions({
definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions,
Expand Down Expand Up @@ -175,8 +205,14 @@ export function defineSearchEngine<
] = params;

engine.executeFirstSearch();
recommendationHelper.refresh(controllers);

const searchActions = await Promise.all(
engine.waitForSearchCompletedAction()
);

return createStaticState({
searchAction: await engine.waitForSearchCompletedAction(),
searchActions,
controllers,
}) as EngineStaticState<
UnknownAction,
Expand All @@ -197,7 +233,7 @@ export function defineSearchEngine<
const buildResult = await build(...(params as BuildParameters));
const staticState = await hydrateStaticState.fromBuildResult({
buildResult,
searchAction: params[0]!.searchAction,
searchActions: params[0]!.searchActions,
});
return staticState;
},
Expand All @@ -208,10 +244,13 @@ export function defineSearchEngine<
const [
{
buildResult: {engine, controllers},
searchAction,
searchActions,
},
] = params;
engine.dispatch(searchAction);

searchActions.forEach((action) => {
engine.dispatch(action);
});
await engine.waitForSearchCompletedAction();
return {engine, controllers};
},
Expand Down
6 changes: 3 additions & 3 deletions packages/headless/src/app/ssr-engine/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ export function buildControllerDefinitions<
}

export function createStaticState<TSearchAction extends UnknownAction>({
searchAction,
searchActions,
controllers,
}: {
searchAction: TSearchAction;
searchActions: TSearchAction[];
controllers: ControllersMap;
}): EngineStaticState<
TSearchAction,
Expand All @@ -71,7 +71,7 @@ export function createStaticState<TSearchAction extends UnknownAction>({
controllers: mapObject(controllers, (controller) => ({
state: clone(controller.state),
})) as InferControllerStaticStateMapFromControllers<ControllersMap>,
searchAction: clone(searchAction),
searchActions: searchActions.map((action) => clone(action)),
};
}

Expand Down
2 changes: 1 addition & 1 deletion packages/headless/src/app/ssr-engine/types/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ export interface EngineStaticState<
TSearchAction extends UnknownAction,
TControllers extends ControllerStaticStateMap,
> {
searchAction: TSearchAction;
searchActions: TSearchAction[];
controllers: TControllers;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {
import {FromBuildResult} from './from-build-result.js';

export interface HydrateStaticStateOptions<TSearchAction> {
searchAction: TSearchAction;
searchActions: TSearchAction[];
}

export type HydrateStaticState<
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@ export interface RecommendationsDefinition
* */
export function defineRecommendations(
props: RecommendationsProps
): RecommendationsDefinition {
): RecommendationsDefinition & {isRecs: true} {
return {
search: true,
listing: true,
standalone: true,
isRecs: true,
y-lakhdar marked this conversation as resolved.
Show resolved Hide resolved
build: (engine) => buildRecommendations(engine, props),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,10 @@ export default function ListingPage({
useEffect(() => {
listingEngineDefinition
.hydrateStaticState({
searchAction: staticState.searchAction,
searchActions: staticState.searchActions,
})
.then(({engine, controllers}) => {
setHydratedState({engine, controllers});
// Refreshing recommendations in the browser after hydrating the state in the client-side
// Recommendation refresh in the server is not supported yet.
controllers.popularBoughtRecs.refresh();
});
}, [staticState]);

Expand Down
Loading