diff --git a/packages/headless/src/controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager.ts b/packages/headless/src/controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager.ts new file mode 100644 index 00000000000..ecde2fc4212 --- /dev/null +++ b/packages/headless/src/controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager.ts @@ -0,0 +1,32 @@ +import {SearchEngine} from '../../../app/search-engine/search-engine'; +import {ControllerDefinitionWithProps} from '../../../app/ssr-engine/types/common'; +import { + SearchParameterManager, + SearchParameterManagerInitialState, + buildSearchParameterManager, +} from '../../search-parameter-manager/headless-search-parameter-manager'; + +export type { + SearchParameterManagerInitialState, + SearchParameterManagerState, + SearchParameterManager, +} from '../../search-parameter-manager/headless-search-parameter-manager'; + +/** + * @internal + */ +export interface SearchParameterManagerBuildProps { + initialState: SearchParameterManagerInitialState; +} + +/** + * @internal + */ +export const defineSearchParameterManager = (): ControllerDefinitionWithProps< + SearchEngine, + SearchParameterManager, + SearchParameterManagerBuildProps +> => ({ + buildWithProps: (engine, props) => + buildSearchParameterManager(engine, {initialState: props.initialState}), +}); diff --git a/packages/headless/src/ssr.index.ts b/packages/headless/src/ssr.index.ts index ec751fe6208..9dc394a9cad 100644 --- a/packages/headless/src/ssr.index.ts +++ b/packages/headless/src/ssr.index.ts @@ -31,3 +31,11 @@ export type { ResultListState, } from './controllers/ssr/result-list/headless-ssr-result-list'; export {defineResultList} from './controllers/ssr/result-list/headless-ssr-result-list'; + +export type { + SearchParameterManager, + SearchParameterManagerInitialState, + SearchParameterManagerBuildProps, + SearchParameterManagerState, +} from './controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager'; +export {defineSearchParameterManager} from './controllers/ssr/search-parameter-manager/headless-ssr-search-parameter-manager'; diff --git a/packages/samples/headless-ssr/cypress/e2e/ssr-e2e-utils.ts b/packages/samples/headless-ssr/cypress/e2e/ssr-e2e-utils.ts index f96c2472917..f86939a4f98 100644 --- a/packages/samples/headless-ssr/cypress/e2e/ssr-e2e-utils.ts +++ b/packages/samples/headless-ssr/cypress/e2e/ssr-e2e-utils.ts @@ -15,3 +15,10 @@ export function spyOnConsole() { export function waitForHydration() { cy.get('#hydrated-indicator').should('be.checked'); } + +export const getResultTitles = () => + ( + cy.get('.result-list li').invoke('map', function (this: HTMLElement) { + return this.innerText; + }) as Cypress.Chainable> + ).invoke('toArray'); diff --git a/packages/samples/headless-ssr/cypress/e2e/ssr-search-parameter-manager.cy.ts b/packages/samples/headless-ssr/cypress/e2e/ssr-search-parameter-manager.cy.ts new file mode 100644 index 00000000000..5e8447e0e01 --- /dev/null +++ b/packages/samples/headless-ssr/cypress/e2e/ssr-search-parameter-manager.cy.ts @@ -0,0 +1,173 @@ +import { + ConsoleAliases, + getResultTitles, + spyOnConsole, + waitForHydration, +} from './ssr-e2e-utils'; + +const searchStateKey = 'search-state'; + +describe('headless ssr with search parameter manager example', () => { + const route = '/generic'; + describe('when loading a page without search parameters, after hydration', () => { + beforeEach(() => { + spyOnConsole(); + cy.visit(route); + waitForHydration(); + getResultTitles() + .should('have.length.greaterThan', 0) + .as('initial-results'); + }); + + it('should not update the search parameters', () => { + cy.url().should((href) => + expect(new URL(href).searchParams.size).to.equal(0) + ); + }); + + describe('after submitting a search', () => { + const query = 'abc'; + beforeEach(() => + cy.get('.search-box input').focus().type(`${query}{enter}`) + ); + + describe('after the url was updated', () => { + beforeEach(() => { + cy.url().should((href) => + expect(new URL(href).searchParams.has(searchStateKey)).to.equal( + true + ) + ); + cy.get('@initial-results').then((initialResults) => { + getResultTitles().should('not.deep.equal', initialResults); + }); + }); + + it('should have the correct search parameters', () => { + cy.url().should((href) => { + const searchState = new URL(href).searchParams.get(searchStateKey); + expect(searchState && JSON.parse(searchState)).to.deep.equal({ + q: query, + }); + }); + }); + + it('has only two history states', () => { + cy.go('back'); + cy.go('back'); + cy.url().should('eq', 'about:blank'); + }); + + describe('after pressing the back button', () => { + beforeEach(() => cy.go('back')); + + it('should remove the search parameters', () => { + cy.url().should((href) => + expect(new URL(href).searchParams.size).to.be.equal(0) + ); + }); + + it('should update the page', () => { + cy.get('.search-box input').should('have.value', ''); + cy.get('@initial-results').then((initialResults) => { + getResultTitles().should('deep.equal', initialResults); + }); + }); + + it('should not log an error nor warning', () => { + cy.get(ConsoleAliases.error).should('not.be.called'); + cy.get(ConsoleAliases.warn).should('not.be.called'); + }); + }); + }); + }); + }); + + describe('when loading a page with search parameters', () => { + const query = 'def'; + function getInitialUrl() { + const searchParams = new URLSearchParams({ + [searchStateKey]: JSON.stringify({q: query}), + }); + return `${route}?${searchParams.toString()}`; + } + + it('renders page in SSR as expected', () => { + cy.intercept('/**', (req) => { + req.continue((resp) => { + const dom = new DOMParser().parseFromString(resp.body, 'text/html'); + expect(dom.querySelector('.search-box input')).to.have.value(query); + }); + }); + cy.visit(getInitialUrl()); + waitForHydration(); + }); + + describe('after hydration', () => { + beforeEach(() => { + cy.visit(getInitialUrl()); + waitForHydration(); + }); + + it("doesn't update the page", () => { + cy.wait(1000); + cy.get('.search-box input').should('have.value', query); + }); + + it('should not update the parameters', () => { + cy.wait(1000); + cy.url().should((href) => { + expect(href.endsWith(getInitialUrl())).to.equal(true); + }); + }); + + it('has only one history state', () => { + cy.go('back'); + cy.url().should('eq', 'about:blank'); + }); + }); + }); + + describe('when loading a page with invalid search parameters', () => { + function getInitialUrl() { + const searchParams = new URLSearchParams({ + [searchStateKey]: JSON.stringify({q: ''}), + }); + return `${route}?${searchParams.toString()}`; + } + + it('renders page in SSR as expected', () => { + cy.intercept('/**', (req) => { + req.continue((resp) => { + const dom = new DOMParser().parseFromString(resp.body, 'text/html'); + expect(dom.querySelector('.search-box input')).to.have.value(''); + }); + }); + cy.visit(getInitialUrl()); + waitForHydration(); + }); + + describe('after hydration', () => { + beforeEach(() => { + cy.visit(getInitialUrl()); + waitForHydration(); + }); + + it("doesn't update the page", () => { + cy.wait(1000); + cy.get('.search-box input').should('have.value', ''); + }); + + it('should correct the parameters', () => { + cy.url().should((href) => { + expect(new URL(href).searchParams.size).to.equal(0); + }); + }); + + it('has only one history state', () => { + cy.go('back'); + cy.url().should('eq', 'about:blank'); + }); + }); + }); +}); diff --git a/packages/samples/headless-ssr/cypress/e2e/ssr.cy.ts b/packages/samples/headless-ssr/cypress/e2e/ssr.cy.ts index b14ca205b8b..79e57d491ca 100644 --- a/packages/samples/headless-ssr/cypress/e2e/ssr.cy.ts +++ b/packages/samples/headless-ssr/cypress/e2e/ssr.cy.ts @@ -1,12 +1,10 @@ import 'cypress-web-vitals'; -import {ConsoleAliases, spyOnConsole, waitForHydration} from './ssr-e2e-utils'; - -const getResultTitles = () => - ( - cy.get('.result-list li').invoke('map', function (this: HTMLElement) { - return this.innerText; - }) as Cypress.Chainable> - ).invoke('toArray'); +import { + ConsoleAliases, + getResultTitles, + spyOnConsole, + waitForHydration, +} from './ssr-e2e-utils'; describe('headless ssr example', () => { const route = '/generic'; diff --git a/packages/samples/headless-ssr/src/app/generic/common/engine.ts b/packages/samples/headless-ssr/src/app/generic/common/engine.ts index f16bd25a318..fd5b0b971b3 100644 --- a/packages/samples/headless-ssr/src/app/generic/common/engine.ts +++ b/packages/samples/headless-ssr/src/app/generic/common/engine.ts @@ -5,6 +5,7 @@ import { InferSSRState, InferCSRState, defineSearchBox, + defineSearchParameterManager, } from '@coveo/headless/ssr'; const engineDefinition = defineSearchEngine({ @@ -15,6 +16,7 @@ const engineDefinition = defineSearchEngine({ controllers: { searchBox: defineSearchBox(), resultList: defineResultList(), + searchParameters: defineSearchParameterManager(), }, }); diff --git a/packages/samples/headless-ssr/src/app/generic/common/search-parameters-serializer.ts b/packages/samples/headless-ssr/src/app/generic/common/search-parameters-serializer.ts new file mode 100644 index 00000000000..9e5ebbf184e --- /dev/null +++ b/packages/samples/headless-ssr/src/app/generic/common/search-parameters-serializer.ts @@ -0,0 +1,62 @@ +import {SearchParameters} from '@coveo/headless'; +import {ReadonlyURLSearchParams} from 'next/navigation'; + +export type NextJSServerSideSearchParams = Record< + string, + string | string[] | undefined +>; + +const searchStateKey = 'search-state'; + +export class CoveoNextJsSearchParametersSerializer { + public static fromServerSideUrlSearchParams( + serverSideUrlSearchParams: NextJSServerSideSearchParams + ): CoveoNextJsSearchParametersSerializer { + if (!(searchStateKey in serverSideUrlSearchParams)) { + return new CoveoNextJsSearchParametersSerializer({}); + } + const stringifiedSearchParameters = + serverSideUrlSearchParams[searchStateKey]; + if ( + !stringifiedSearchParameters || + Array.isArray(stringifiedSearchParameters) + ) { + return new CoveoNextJsSearchParametersSerializer({}); + } + return new CoveoNextJsSearchParametersSerializer( + JSON.parse(stringifiedSearchParameters) + ); + } + + public static fromClientSideUrlSearchParams( + clientSideUrlSearchParams: URLSearchParams | ReadonlyURLSearchParams + ) { + if (clientSideUrlSearchParams.getAll(searchStateKey).length !== 1) { + return new CoveoNextJsSearchParametersSerializer({}); + } + return new CoveoNextJsSearchParametersSerializer( + JSON.parse(clientSideUrlSearchParams.get(searchStateKey)!) + ); + } + + public static fromCoveoSearchParameters( + coveoSearchParameters: SearchParameters + ) { + return new CoveoNextJsSearchParametersSerializer(coveoSearchParameters); + } + + private constructor( + public readonly coveoSearchParameters: SearchParameters + ) {} + + public applyToUrlSearchParams(urlSearchParams: URLSearchParams) { + if (!Object.keys(this.coveoSearchParameters).length) { + urlSearchParams.delete(searchStateKey); + return; + } + urlSearchParams.set( + searchStateKey, + JSON.stringify(this.coveoSearchParameters) + ); + } +} diff --git a/packages/samples/headless-ssr/src/app/generic/components/search-page.tsx b/packages/samples/headless-ssr/src/app/generic/components/search-page.tsx index 904c4c20298..13983ed8e00 100644 --- a/packages/samples/headless-ssr/src/app/generic/components/search-page.tsx +++ b/packages/samples/headless-ssr/src/app/generic/components/search-page.tsx @@ -6,6 +6,7 @@ import { hydrateInitialState, } from '@/src/app/generic/common/engine'; import {useEffect, useState} from 'react'; +import {useSyncSearchParameters} from '../hooks/search-parameters'; import {HydrationMetadata} from './hydration-metadata'; import {ResultList} from './result-list'; import {SearchBox} from './search-box'; @@ -16,10 +17,23 @@ export default function SearchPage({ssrState}: {ssrState: SearchSSRState}) { ); useEffect(() => { - hydrateInitialState(ssrState).then(({engine, controllers}) => { + hydrateInitialState({ + searchFulfilledAction: ssrState.searchFulfilledAction, + controllers: { + searchParameters: { + initialState: ssrState.controllers.searchParameters.state, + }, + }, + }).then(({engine, controllers}) => { setCSRResult({engine, controllers}); }); }, [ssrState]); + + useSyncSearchParameters({ + ssrState: ssrState.controllers.searchParameters.state, + controller: csrResult?.controllers.searchParameters, + }); + return ( <> diff --git a/packages/samples/headless-ssr/src/app/generic/hooks/search-parameters.ts b/packages/samples/headless-ssr/src/app/generic/hooks/search-parameters.ts new file mode 100644 index 00000000000..c2aee29eab7 --- /dev/null +++ b/packages/samples/headless-ssr/src/app/generic/hooks/search-parameters.ts @@ -0,0 +1,111 @@ +'use client'; + +import {SearchParameters} from '@coveo/headless'; +import { + SearchParameterManager, + SearchParameterManagerState, +} from '@coveo/headless/ssr'; +import {useCallback, useEffect, useMemo, useReducer, useState} from 'react'; +import {CoveoNextJsSearchParametersSerializer} from '../common/search-parameters-serializer'; + +interface UseSyncSearchParametersProps { + ssrState: SearchParameterManagerState; + controller?: SearchParameterManager; +} + +function getUrl() { + if (typeof window === 'undefined') { + return null; + } + return new URL(document.location.href); +} + +function useUrlSearchParams() { + const [urlSearchParams, onUrlSearchParamsChanged] = useReducer( + () => getUrl()?.searchParams, + undefined as never + ); + useEffect(() => { + window.addEventListener('popstate', onUrlSearchParamsChanged); + return () => + window.removeEventListener('popstate', onUrlSearchParamsChanged); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return urlSearchParams; +} + +function useCoveoSearchParameters( + initialParameters: SearchParameters, + controller?: SearchParameterManager +) { + const [searchParameters, setSearchParameters] = useState(initialParameters); + useEffect(() => { + if (!controller) { + return; + } + return controller.subscribe(() => + setSearchParameters(controller.state.parameters) + ); + }, [controller]); + return searchParameters; +} + +export function useSyncSearchParameters({ + ssrState, + controller, +}: UseSyncSearchParametersProps) { + const urlSearchParams = useUrlSearchParams(); + const coveoSearchParameters = useCoveoSearchParameters( + ssrState.parameters, + controller + ); + const urlFromCoveoSearchParameters = useMemo(() => { + const newUrl = getUrl(); + if (!newUrl) { + return null; + } + CoveoNextJsSearchParametersSerializer.fromCoveoSearchParameters( + coveoSearchParameters + ).applyToUrlSearchParams(newUrl.searchParams); + return newUrl; + }, [coveoSearchParameters]); + + const updateCoveoSearchParameters = useCallback( + (coveoSearchParameters: SearchParameters) => + controller?.synchronize(coveoSearchParameters), + [controller] + ); + const updateUrlSearchParams = useCallback( + (options: {href: string; isInitialState: boolean}) => + (options.isInitialState ? history.replaceState : history.pushState).call( + history, + null, + document.title, + options.href + ), + [] + ); + + useEffect( + () => + urlSearchParams && + updateCoveoSearchParameters( + CoveoNextJsSearchParametersSerializer.fromClientSideUrlSearchParams( + urlSearchParams + ).coveoSearchParameters + ), + [updateCoveoSearchParameters, urlSearchParams] + ); + useEffect(() => { + if (!urlFromCoveoSearchParameters) { + return; + } + if (urlFromCoveoSearchParameters.href === document.location.href) { + return; + } + updateUrlSearchParams({ + isInitialState: !controller, + href: urlFromCoveoSearchParameters.href, + }); + }, [updateUrlSearchParams, controller, urlFromCoveoSearchParameters]); +} diff --git a/packages/samples/headless-ssr/src/app/generic/page.tsx b/packages/samples/headless-ssr/src/app/generic/page.tsx index 4d926bcf25e..da11613c323 100644 --- a/packages/samples/headless-ssr/src/app/generic/page.tsx +++ b/packages/samples/headless-ssr/src/app/generic/page.tsx @@ -1,8 +1,29 @@ import {fetchInitialState} from '@/src/app/generic/common/engine'; +import { + CoveoNextJsSearchParametersSerializer, + NextJSServerSideSearchParams, +} from '@/src/app/generic/common/search-parameters-serializer'; import SearchPage from '@/src/app/generic/components/search-page'; // Entry point SSR function -export default async function Search() { - const ssrState = await fetchInitialState(); +export default async function Search(url: { + searchParams: NextJSServerSideSearchParams; +}) { + const {coveoSearchParameters} = + CoveoNextJsSearchParametersSerializer.fromServerSideUrlSearchParams( + url.searchParams + ); + const ssrState = await fetchInitialState({ + controllers: { + searchParameters: { + initialState: { + parameters: coveoSearchParameters, + }, + }, + }, + }); return ; } + +// A page with search parameters cannot be statically rendered, since its rendered state should look different based on the current search parameters. +export const dynamic = 'force-dynamic';