Skip to content

Commit

Permalink
feat(headless SSR): support both search and listing solution types (#…
Browse files Browse the repository at this point in the history
…4249)

## TL;DR

Enhance the commerce engine to support both search and listing solution
types, implementing type inference and conditional types to ensure the
correct controllers are instantiated based on the solution type.

## Key Changes
### **Solution Type Support**:
The commerce engine now supports both search and listing solution types.
This is particularly important when building sub-controllers (e.g.,
summary, facets, sort, etc.). Since, in SSR, users do not manually build
their controllers because it is done under the hood (in the SSR utils),
the SSR utils need to know which function to call (e.g.,
`buildProductListing()` or `buildSearch()`).

### **Conditional Controller Instantiation**: 
Based on the specified solution type, the engine now conditionally
instantiates controllers. This mechanism ensures that only relevant
controllers are active for a given solution type, optimizing the
engine’s performance and resource usage by avoiding the creation of
unnecessary controllers.

#### Example
For instance, if you create an engine definition containing a Query
Summary but want the QuerySummary controller to not be created on
listing pages, you can opt out for this solution type. Otherwise, it
will be built like other controllers.

```ts
const engineDefinition = defineCommerceEngine({
  configuration: configuration,
  controllers: {
    searchbox: defineStandaloneSearchBox({
      options: {redirectionUrl: '/search'},
    }),
    summary: defineQuerySummary({listing: false}), // opting out for listing context
    // ... other controllers
  },
});
```

### **Type Inference Enhancements**:
Since Conditional Controller Instantiation is a runtime logic, I had to
update the typing to reflect that change so that the same logic could
also be available statically with TypeScript. This means that if you opt
out from a solution type during the engine definition (like in the
previous example), you should not have the controller available when
using code completion.

These enhancements facilitate the accurate determination of controller
types based on the solution type, streamlining the setup process for
developers.

#### Example
Using the same engine definition from the previous example, and since we
have opted out from query summary in the listing context, you will
notice that you get an error if you try to get that controller in the
listing context.

![image](https://github.com/user-attachments/assets/6724fd40-4a37-4a0f-9b20-15a9d93e1a3b)

### **Duplicate Types and Methods**: 
Due to compatibility issues with existing types used by the non-commerce
SSR package, some types and methods have been duplicated and adapted to
fit the solution type strategy.

https://coveord.atlassian.net/browse/KIT-3394

---------

Co-authored-by: Alex Prudhomme <[email protected]>
Co-authored-by: Frederic Beaudoin <[email protected]>
  • Loading branch information
3 people authored Aug 19, 2024
1 parent 49b33a6 commit dcd35d8
Show file tree
Hide file tree
Showing 21 changed files with 719 additions and 185 deletions.
267 changes: 158 additions & 109 deletions packages/headless/src/app/commerce-engine/commerce-engine.ssr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,26 @@
import {Action, UnknownAction} from '@reduxjs/toolkit';
import {stateKey} from '../../app/state-key';
import {buildProductListing} from '../../controllers/commerce/product-listing/headless-product-listing';
import {buildSearch} from '../../controllers/commerce/search/headless-search';
import type {Controller} from '../../controllers/controller/headless-controller';
import {createWaitForActionMiddleware} from '../../utils/utils';
import {NavigatorContextProvider} from '../navigatorContextProvider';
import {
buildControllerDefinitions,
composeFunction,
createStaticState,
} from '../ssr-engine/common';
import {buildControllerDefinitions} from '../commerce-ssr-engine/common';
import {
ControllerDefinitionsMap,
InferControllerPropsMapFromDefinitions,
} from '../ssr-engine/types/common';
InferControllerStaticStateMapFromDefinitionsWithSolutionType,
SolutionType,
} from '../commerce-ssr-engine/types/common';
import {
EngineDefinition,
EngineDefinitionOptions,
} from '../ssr-engine/types/core-engine';
} from '../commerce-ssr-engine/types/core-engine';
import {NavigatorContextProvider} from '../navigatorContextProvider';
import {composeFunction} from '../ssr-engine/common';
import {createStaticState} from '../ssr-engine/common';
import {
EngineStaticState,
InferControllerPropsMapFromDefinitions,
} from '../ssr-engine/types/common';
import {
CommerceEngine,
CommerceEngineOptions,
Expand All @@ -33,7 +37,7 @@ export interface SSRCommerceEngine extends CommerceEngine {
/**
* Waits for the search to be completed and returns a promise that resolves to a `SearchCompletedAction`.
*/
waitForSearchCompletedAction(): Promise<Action>;
waitForRequestCompletedAction(): Promise<Action>;
}

export type CommerceEngineDefinitionOptions<
Expand All @@ -46,20 +50,20 @@ function isListingFetchCompletedAction(action: unknown): action is Action {
);
}

// TODO: KIT-3394 - Uncomment this function when implementing the search solution type
// function isSearchCompletedAction(action: unknown): action is Action {
// return /^commerce\/search\/executeSearch\/(fulfilled|rejected)$/.test(
// (action as UnknownAction).type
// );
// }
function isSearchCompletedAction(action: unknown): action is Action {
return /^commerce\/search\/executeSearch\/(fulfilled|rejected)$/.test(
(action as UnknownAction).type
);
}

function buildSSRCommerceEngine(
solutionType: SolutionType,
options: CommerceEngineOptions
): SSRCommerceEngine {
const {middleware, promise} = createWaitForActionMiddleware(
isListingFetchCompletedAction
// TODO: KIT-3394 - Implement the logic for the search solution type
// isSearchCompletedAction
solutionType === SolutionType.listing
? isListingFetchCompletedAction
: isSearchCompletedAction
);
const commerceEngine = buildCommerceEngine({
...options,
Expand All @@ -72,37 +76,50 @@ function buildSSRCommerceEngine(
return commerceEngine[stateKey];
},

waitForSearchCompletedAction() {
waitForRequestCompletedAction() {
return promise;
},
};
}

export interface CommerceEngineDefinition<
TControllers extends ControllerDefinitionsMap<SSRCommerceEngine, Controller>,
TSolutionType extends SolutionType,
> extends EngineDefinition<
SSRCommerceEngine,
TControllers,
CommerceEngineOptions
CommerceEngineOptions,
TSolutionType
> {}

/**
* @internal
* Initializes a Commerce engine definition in SSR with given controllers definitions and commerce engine config.
* @param options - The commerce engine definition
* @returns Three utility functions to fetch the initial state of the engine in SSR, hydrate the state in CSR,
* and a build function that can be used for edge cases requiring more control.
*/
export function defineCommerceEngine<
TControllerDefinitions extends ControllerDefinitionsMap<
CommerceEngine,
SSRCommerceEngine,
Controller
>,
>(
options: CommerceEngineDefinitionOptions<TControllerDefinitions>
): CommerceEngineDefinition<TControllerDefinitions> {
): {
listingEngineDefinition: CommerceEngineDefinition<
TControllerDefinitions,
SolutionType.listing
>;
searchEngineDefinition: CommerceEngineDefinition<
TControllerDefinitions,
SolutionType.search
>;
} {
const {controllers: controllerDefinitions, ...engineOptions} = options;
type Definition = CommerceEngineDefinition<TControllerDefinitions>;
type Definition = CommerceEngineDefinition<
TControllerDefinitions,
SolutionType
>;
type BuildFunction = Definition['build'];
type FetchStaticStateFunction = Definition['fetchStaticState'];
type HydrateStaticStateFunction = Definition['hydrateStaticState'];
Expand All @@ -128,97 +145,129 @@ export function defineCommerceEngine<
engineOptions.navigatorContextProvider = navigatorContextProvider;
};

const build: BuildFunction = async (...[buildOptions]: BuildParameters) => {
const engine = buildSSRCommerceEngine(
buildOptions?.extend
? await buildOptions.extend(getOptions())
: getOptions()
);
const controllers = buildControllerDefinitions({
definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions,
engine,
propsMap: (buildOptions && 'controllers' in buildOptions
? buildOptions.controllers
: {}) as InferControllerPropsMapFromDefinitions<TControllerDefinitions>,
});
return {
engine,
controllers,
const buildFactory =
<T extends SolutionType>(solutionType: T) =>
async (...[buildOptions]: BuildParameters) => {
const engine = buildSSRCommerceEngine(
solutionType,
buildOptions?.extend
? await buildOptions.extend(getOptions())
: getOptions()
);
const controllers = buildControllerDefinitions({
definitionsMap: (controllerDefinitions ?? {}) as TControllerDefinitions,
engine,
solutionType,
propsMap: (buildOptions && 'controllers' in buildOptions
? buildOptions.controllers
: {}) as InferControllerPropsMapFromDefinitions<TControllerDefinitions>,
});

return {
engine,
controllers,
};
};
};

const fetchStaticState: FetchStaticStateFunction = composeFunction(
async (...params: FetchStaticStateParameters) => {
if (!getOptions().navigatorContextProvider) {
// TODO: KIT-3409 - implement a logger to log SSR warnings/errors
console.warn(
'[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()'
);
}
const buildResult = await build(...params);
const staticState = await fetchStaticState.fromBuildResult({
buildResult,
});
return staticState;
},
{
fromBuildResult: async (
...params: FetchStaticStateFromBuildResultParameters
) => {
const [
{
buildResult: {engine, controllers},
},
] = params;

buildProductListing(engine).executeFirstRequest();
// TODO: KIT-3394 - Implement the logic for the search solution type
// buildSearch(engine).executeFirstSearch();

return createStaticState({
searchAction: await engine.waitForSearchCompletedAction(),
controllers,
const fetchStaticStateFactory: (
solutionType: SolutionType
) => FetchStaticStateFunction = (solutionType: SolutionType) =>
composeFunction(
async (...params: FetchStaticStateParameters) => {
if (!getOptions().navigatorContextProvider) {
// TODO: KIT-3409 - implement a logger to log SSR warnings/errors
console.warn(
'[WARNING] Missing navigator context in server-side code. Make sure to set it with `setNavigatorContextProvider` before calling fetchStaticState()'
);
}
const buildResult = await buildFactory(solutionType)(...params);
const staticState = await fetchStaticStateFactory(
solutionType
).fromBuildResult({
buildResult,
});
return staticState;
},
}
);
{
fromBuildResult: async (
...params: FetchStaticStateFromBuildResultParameters
) => {
const [
{
buildResult: {engine, controllers},
},
] = params;

const hydrateStaticState: HydrateStaticStateFunction = composeFunction(
async (...params: HydrateStaticStateParameters) => {
if (!getOptions().navigatorContextProvider) {
// TODO: KIT-3409 - implement a logger to log SSR warnings/errors
console.warn(
'[WARNING] Missing navigator context in client-side code. Make sure to set it with `setNavigatorContextProvider` before calling hydrateStaticState()'
);
if (solutionType === SolutionType.listing) {
buildProductListing(engine).executeFirstRequest();
} else {
buildSearch(engine).executeFirstSearch();
}

return createStaticState({
searchAction: await engine.waitForRequestCompletedAction(),
controllers,
}) as EngineStaticState<
UnknownAction,
InferControllerStaticStateMapFromDefinitionsWithSolutionType<
TControllerDefinitions,
SolutionType
>
>;
},
}
const buildResult = await build(...(params as BuildParameters));
const staticState = await hydrateStaticState.fromBuildResult({
buildResult,
searchAction: params[0]!.searchAction,
});
return staticState;
},
{
fromBuildResult: async (
...params: HydrateStaticStateFromBuildResultParameters
) => {
const [
{
buildResult: {engine, controllers},
searchAction,
},
] = params;
engine.dispatch(searchAction);
await engine.waitForSearchCompletedAction();
return {engine, controllers};
},
}
);
);

const hydrateStaticStateFactory: (
solutionType: SolutionType
) => HydrateStaticStateFunction = (solutionType: SolutionType) =>
composeFunction(
async (...params: HydrateStaticStateParameters) => {
if (!getOptions().navigatorContextProvider) {
// TODO: KIT-3409 - implement a logger to log SSR warnings/errors
console.warn(
'[WARNING] Missing navigator context in client-side code. Make sure to set it with `setNavigatorContextProvider` before calling hydrateStaticState()'
);
}
const buildResult = await buildFactory(solutionType)(
...(params as BuildParameters)
);
const staticState = await hydrateStaticStateFactory(
solutionType
).fromBuildResult({
buildResult,
searchAction: params[0]!.searchAction,
});
return staticState;
},
{
fromBuildResult: async (
...params: HydrateStaticStateFromBuildResultParameters
) => {
const [
{
buildResult: {engine, controllers},
searchAction,
},
] = params;
engine.dispatch(searchAction);
await engine.waitForRequestCompletedAction();
return {engine, controllers};
},
}
);
return {
build,
fetchStaticState,
hydrateStaticState,
setNavigatorContextProvider,
listingEngineDefinition: {
build: buildFactory(SolutionType.listing),
fetchStaticState: fetchStaticStateFactory(SolutionType.listing),
hydrateStaticState: hydrateStaticStateFactory(SolutionType.listing),
setNavigatorContextProvider,
} as CommerceEngineDefinition<TControllerDefinitions, SolutionType.listing>,
searchEngineDefinition: {
build: buildFactory(SolutionType.search),
fetchStaticState: fetchStaticStateFactory(SolutionType.search),
hydrateStaticState: hydrateStaticStateFactory(SolutionType.search),
setNavigatorContextProvider,
} as CommerceEngineDefinition<TControllerDefinitions, SolutionType.search>,
};
}
Loading

0 comments on commit dcd35d8

Please sign in to comment.