diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.ts b/packages/react-instantsearch-nextjs/src/InitializePromise.ts index 3cb7a3523b..558a0c1727 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.ts +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.ts @@ -1,4 +1,7 @@ -import { getInitialResults } from 'instantsearch.js/es/lib/server'; +import { + getInitialResults, + waitForResults, +} from 'instantsearch.js/es/lib/server'; import { resetWidgetId, walkIndex } from 'instantsearch.js/es/lib/utils'; import { ServerInsertedHTMLContext } from 'next/navigation'; import { useContext } from 'react'; @@ -10,11 +13,7 @@ import { import { createInsertHTML } from './createInsertHTML'; -import type { - SearchOptions, - CompositionClient, - SearchClient, -} from 'instantsearch.js'; +import type { SearchOptions } from 'instantsearch.js'; type InitializePromiseProps = { /** @@ -34,49 +33,9 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { throw new Error('Missing ServerInsertedHTMLContext'); }); - // Extract search parameters from the search client to use them - // later during hydration. - let requestParamsList: SearchOptions[]; - - if (search.compositionID) { - search.mainHelper!.setClient({ - ...search.mainHelper!.getClient(), - search(query) { - requestParamsList = [query.requestBody.params]; - return (search.client as CompositionClient).search(query); - }, - } as CompositionClient); - } else { - search.mainHelper!.setClient({ - ...search.mainHelper!.getClient(), - search(queries) { - requestParamsList = queries.map(({ params }) => params); - return (search.client as SearchClient).search(queries); - }, - } as SearchClient); - } - resetWidgetId(); - const waitForResults = () => - new Promise((resolve) => { - let searchReceived = false; - let recommendReceived = false; - search.mainHelper!.derivedHelpers[0].once('result', () => { - searchReceived = true; - if (!search._hasRecommendWidget || recommendReceived) { - resolve(); - } - }); - search.mainHelper!.derivedHelpers[0].once('recommend:result', () => { - recommendReceived = true; - if (!search._hasSearchWidget || searchReceived) { - resolve(); - } - }); - }); - - const injectInitialResults = () => { + const injectInitialResults = (requestParamsList: SearchOptions[]) => { const options = { inserted: false }; const results = getInitialResults(search.mainIndex, requestParamsList); insertHTML(createInsertHTML({ options, results, nonce })); @@ -84,29 +43,25 @@ export function InitializePromise({ nonce }: InitializePromiseProps) { if (waitForResultsRef?.current === null) { waitForResultsRef.current = wrapPromiseWithState( - waitForResults() - .then(() => { - let shouldRefetch = false; - walkIndex(search.mainIndex, (index) => { - shouldRefetch = index + waitForResults(search).then((requestParamsList) => { + let shouldRefetch = false; + walkIndex(search.mainIndex, (index) => { + shouldRefetch = + shouldRefetch || + index .getWidgets() .some((widget) => widget.$$type === 'ais.dynamicWidgets'); - }); + }); - if (shouldRefetch) { - waitForResultsRef.current = wrapPromiseWithState( - waitForResults().then(injectInitialResults) - ); - } + if (shouldRefetch) { + waitForResultsRef.current = wrapPromiseWithState( + waitForResults(search).then(injectInitialResults) + ); + return; + } - return shouldRefetch; - }) - .then((shouldRefetch) => { - if (shouldRefetch) { - return; - } - injectInitialResults(); - }) + injectInitialResults(requestParamsList); + }) ); } diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx index f53292cc6a..20dff5114c 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise-composition.test.tsx @@ -30,14 +30,14 @@ const renderComponent = ({ ref = { current: null }, nonce, insertedHTML, + client = createCompositionClient(), }: { children?: React.ReactNode; ref?: { current: PromiseWithState | null }; nonce?: string; insertedHTML?: jest.Mock; + client?: ReturnType; } = {}) => { - const client = createCompositionClient(); - render( @@ -81,6 +81,34 @@ test('it waits for composition-based search', async () => { ); }); +test('it errors when search errors', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createCompositionClient({ + search: jest.fn().mockRejectedValue(new Error('composition failed')), + }); + + renderComponent({ + ref, + children: ( + <> + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + await expect(ref.current).rejects.toEqual(new Error('composition failed')); +}); + afterAll(() => { jest.resetAllMocks(); }); diff --git a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx index 8841b26782..e7fd273a0d 100644 --- a/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx +++ b/packages/react-instantsearch-nextjs/src/__tests__/InitializePromise.test.tsx @@ -32,18 +32,18 @@ const renderComponent = ({ ref = { current: null }, nonce, insertedHTML, + client = createSearchClient({ + getRecommendations: jest.fn().mockResolvedValue({ + results: [createSingleSearchResponse()], + }), + }), }: { children?: React.ReactNode; ref?: { current: PromiseWithState | null }; nonce?: string; insertedHTML?: jest.Mock; + client?: ReturnType; } = {}) => { - const client = createSearchClient({ - getRecommendations: jest.fn().mockResolvedValue({ - results: [createSingleSearchResponse()], - }), - }); - render( @@ -159,6 +159,163 @@ test('it waits for recommend only if there are only recommend widgets', async () expect(client.getRecommendations).toHaveBeenCalledTimes(1); }); +test('it errors if search fails', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createSearchClient({ + search: jest.fn().mockRejectedValue(new Error('search failed')), + }); + + renderComponent({ + ref, + children: ( + <> + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + await expect(ref.current).rejects.toEqual(new Error('search failed')); +}); + +test('it errors if recommend fails', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createSearchClient({ + getRecommendations: jest + .fn() + .mockRejectedValue(new Error('recommend failed')), + }); + + renderComponent({ + ref, + children: ( + <> + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + await expect(ref.current).rejects.toEqual(new Error('recommend failed')); +}); + +test('it errors if both search and recommend fail', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createSearchClient({ + search: jest.fn().mockRejectedValue(new Error('search failed')), + getRecommendations: jest + .fn() + .mockRejectedValue(new Error('recommend failed')), + }); + + renderComponent({ + ref, + children: ( + <> + + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + // There's only one rejection, search comes first + await expect(ref.current).rejects.toEqual(new Error('search failed')); +}); + +test('it does not error if only search fails, but recommendations passes', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createSearchClient({ + search: jest.fn().mockRejectedValue(new Error('search failed')), + getRecommendations: jest.fn().mockResolvedValue({ + results: [createSingleSearchResponse()], + }), + }); + + renderComponent({ + ref, + children: ( + <> + + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + expect(ref.current!.status).toBe('fulfilled'); +}); + +test('it does not error if only recommendations fails, but search passes', async () => { + const ref: { current: PromiseWithState | null } = { current: null }; + + const client = createSearchClient({ + search: jest.fn().mockResolvedValue({ + results: [createSingleSearchResponse()], + }), + getRecommendations: jest + .fn() + .mockRejectedValue(new Error('recommend failed')), + }); + + renderComponent({ + ref, + children: ( + <> + + + + ), + client, + }); + + await act(async () => { + try { + await ref.current; + } catch { + // prevent jest from failing the test + } + }); + + expect(ref.current!.status).toBe('fulfilled'); +}); + afterAll(() => { jest.resetAllMocks(); });