diff --git a/bundlesize.config.json b/bundlesize.config.json index b5110c5261..732bde78a9 100644 --- a/bundlesize.config.json +++ b/bundlesize.config.json @@ -10,7 +10,7 @@ }, { "path": "./packages/instantsearch.js/dist/instantsearch.production.min.js", - "maxSize": "75.75 kB" + "maxSize": "76 kB" }, { "path": "./packages/instantsearch.js/dist/instantsearch.development.js", diff --git a/packages/instantsearch.js/src/lib/__tests__/server.test.ts b/packages/instantsearch.js/src/lib/__tests__/server.test.ts index 9e60eac006..2e26c44029 100644 --- a/packages/instantsearch.js/src/lib/__tests__/server.test.ts +++ b/packages/instantsearch.js/src/lib/__tests__/server.test.ts @@ -3,7 +3,7 @@ import { createSearchClient, } from '@instantsearch/mocks'; -import { connectSearchBox } from '../../connectors'; +import { connectConfigure, connectSearchBox } from '../../connectors'; import instantsearch from '../../index.es'; import { index } from '../../widgets'; import { getInitialResults, waitForResults } from '../server'; @@ -14,8 +14,15 @@ describe('waitForResults', () => { const search = instantsearch({ indexName: 'indexName', searchClient, + initialUiState: { + indexName: { + query: 'apple', + }, + }, }).addWidgets([ - index({ indexName: 'indexName2' }), + index({ indexName: 'indexName2' }).addWidgets([ + connectConfigure(() => {})({ searchParameters: { hitsPerPage: 2 } }), + ]), connectSearchBox(() => {})({}), ]); @@ -25,7 +32,10 @@ describe('waitForResults', () => { searches[0].resolver(); - await expect(output).resolves.toBeUndefined(); + await expect(output).resolves.toEqual([ + expect.objectContaining({ query: 'apple' }), + expect.objectContaining({ query: 'apple', hitsPerPage: 2 }), + ]); }); test('throws on a search client error', async () => { @@ -239,4 +249,100 @@ describe('getInitialResults', () => { }, }); }); + + test('returns the current results with request params if specified', async () => { + const search = instantsearch({ + indexName: 'indexName', + searchClient: createSearchClient(), + initialUiState: { + indexName: { + query: 'apple', + }, + indexName2: { + query: 'samsung', + }, + }, + }); + + search.addWidgets([ + connectSearchBox(() => {})({}), + index({ indexName: 'indexName2' }).addWidgets([ + connectSearchBox(() => {})({}), + ]), + index({ indexName: 'indexName2', indexId: 'indexId' }).addWidgets([ + connectConfigure(() => {})({ searchParameters: { hitsPerPage: 2 } }), + ]), + index({ indexName: 'indexName2', indexId: 'indexId' }).addWidgets([ + connectConfigure(() => {})({ searchParameters: { hitsPerPage: 3 } }), + ]), + ]); + + search.start(); + + const requestParams = await waitForResults(search); + + // Request params for the same index name + index id are not deduplicated, + // so we should have data for 4 indices (main index + 3 index widgets) + expect(requestParams).toHaveLength(4); + expect(requestParams).toMatchInlineSnapshot(` + [ + { + "facets": [], + "query": "apple", + "tagFilters": "", + }, + { + "facets": [], + "query": "samsung", + "tagFilters": "", + }, + { + "facets": [], + "hitsPerPage": 2, + "query": "apple", + "tagFilters": "", + }, + { + "facets": [], + "hitsPerPage": 3, + "query": "apple", + "tagFilters": "", + }, + ] + `); + + // `getInitialResults()` generates a dictionary of initial results + // keyed by index id, so indexName2/indexId should be deduplicated... + expect(Object.entries(getInitialResults(search.mainIndex))).toHaveLength(3); + + // ...and only the latest duplicate params are in the returned results + const expectedInitialResults = { + indexName: expect.objectContaining({ + requestParams: expect.objectContaining({ + query: 'apple', + }), + }), + indexName2: expect.objectContaining({ + requestParams: expect.objectContaining({ + query: 'samsung', + }), + }), + indexId: expect.objectContaining({ + requestParams: expect.objectContaining({ + query: 'apple', + hitsPerPage: 3, + }), + }), + }; + + expect(getInitialResults(search.mainIndex, requestParams)).toEqual( + expectedInitialResults + ); + + // Multiple calls to `getInitialResults()` with the same requestParams + // return the same results + expect(getInitialResults(search.mainIndex, requestParams)).toEqual( + expectedInitialResults + ); + }); }); diff --git a/packages/instantsearch.js/src/lib/server.ts b/packages/instantsearch.js/src/lib/server.ts index f962fae0e6..604f869af1 100644 --- a/packages/instantsearch.js/src/lib/server.ts +++ b/packages/instantsearch.js/src/lib/server.ts @@ -1,21 +1,39 @@ import { walkIndex } from './utils'; -import type { IndexWidget, InitialResults, InstantSearch } from '../types'; +import type { + IndexWidget, + InitialResults, + InstantSearch, + SearchOptions, +} from '../types'; /** * Waits for the results from the search instance to coordinate the next steps * in `getServerState()`. */ -export function waitForResults(search: InstantSearch): Promise { +export function waitForResults( + search: InstantSearch +): Promise { const helper = search.mainHelper!; + // Extract search parameters from the search client to use them + // later during hydration. + let requestParamsList: SearchOptions[]; + const client = helper.getClient(); + helper.setClient({ + search(queries) { + requestParamsList = queries.map(({ params }) => params!); + return client.search(queries); + }, + }); + helper.searchOnlyWithDerivedHelpers(); return new Promise((resolve, reject) => { // All derived helpers resolve in the same tick so we're safe only relying // on the first one. helper.derivedHelpers[0].on('result', () => { - resolve(); + resolve(requestParamsList); }); // However, we listen to errors that can happen on any derived helper because @@ -37,17 +55,27 @@ export function waitForResults(search: InstantSearch): Promise { /** * Walks the InstantSearch root index to construct the initial results. */ -export function getInitialResults(rootIndex: IndexWidget): InitialResults { +export function getInitialResults( + rootIndex: IndexWidget, + /** + * Search parameters sent to the search client, + * returned by `waitForResults()`. + */ + requestParamsList?: SearchOptions[] +): InitialResults { const initialResults: InitialResults = {}; + let requestParamsIndex = 0; walkIndex(rootIndex, (widget) => { const searchResults = widget.getResults(); if (searchResults) { + const requestParams = requestParamsList?.[requestParamsIndex++]; initialResults[widget.getIndexId()] = { // We convert the Helper state to a plain object to pass parsable data // structures from server to client. state: { ...searchResults._state }, results: searchResults._rawResults, + ...(requestParams && { requestParams }), }; } }); diff --git a/packages/instantsearch.js/src/lib/utils/__tests__/hydrateSearchClient-test.ts b/packages/instantsearch.js/src/lib/utils/__tests__/hydrateSearchClient-test.ts index 6c5f1d7fb0..6222d0969f 100644 --- a/packages/instantsearch.js/src/lib/utils/__tests__/hydrateSearchClient-test.ts +++ b/packages/instantsearch.js/src/lib/utils/__tests__/hydrateSearchClient-test.ts @@ -71,4 +71,80 @@ describe('hydrateSearchClient', () => { expect(client.cache).toBeDefined(); }); + + it('should use request params by default', () => { + const setCache = jest.fn(); + client = { + transporter: { responsesCache: { set: setCache } }, + addAlgoliaAgent: jest.fn(), + } as unknown as SearchClient; + + hydrateSearchClient(client, { + instant_search: { + results: [ + { index: 'instant_search', params: 'source=results', nbHits: 1000 }, + ], + state: {}, + rawResults: [ + { index: 'instant_search', params: 'source=results', nbHits: 1000 }, + ], + requestParams: { + source: 'request', + }, + }, + } as unknown as InitialResults); + + expect(setCache).toHaveBeenCalledWith( + expect.objectContaining({ + args: [[{ indexName: 'instant_search', params: 'source=request' }]], + method: 'search', + }), + expect.anything() + ); + }); + + it('should use results params as a fallback', () => { + const setCache = jest.fn(); + client = { + transporter: { responsesCache: { set: setCache } }, + addAlgoliaAgent: jest.fn(), + } as unknown as SearchClient; + + hydrateSearchClient(client, { + instant_search: { + results: [ + { index: 'instant_search', params: 'source=results', nbHits: 1000 }, + ], + state: {}, + rawResults: [ + { index: 'instant_search', params: 'source=results', nbHits: 1000 }, + ], + }, + } as unknown as InitialResults); + + expect(setCache).toHaveBeenCalledWith( + expect.objectContaining({ + args: [[{ indexName: 'instant_search', params: 'source=results' }]], + method: 'search', + }), + expect.anything() + ); + }); + + it('should not throw if there are no params from request or results to generate the cache with', () => { + expect(() => { + client = { + transporter: { responsesCache: { set: jest.fn() } }, + addAlgoliaAgent: jest.fn(), + } as unknown as SearchClient; + + hydrateSearchClient(client, { + instant_search: { + results: [{ index: 'instant_search', nbHits: 1000 }], + state: {}, + rawResults: [{ index: 'instant_search', nbHits: 1000 }], + }, + } as unknown as InitialResults); + }).not.toThrow(); + }); }); diff --git a/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts b/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts index 6d44d26a88..02c4671a93 100644 --- a/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts +++ b/packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts @@ -34,16 +34,24 @@ export function hydrateSearchClient( return; } - const cachedRequest = Object.keys(results).map((key) => - results[key].results.map((result) => ({ - indexName: result.index, + const cachedRequest = Object.keys(results).map((key) => { + const { state, requestParams, results: serverResults } = results[key]; + return serverResults.map((result) => ({ + indexName: state.index || result.index, // We normalize the params received from the server as they can // be serialized differently depending on the engine. - params: serializeQueryParameters( - deserializeQueryParameters(result.params) - ), - })) - ); + // We use search parameters from the server request to craft the cache + // if possible, and fallback to those from results if not. + ...(requestParams || result.params + ? { + params: serializeQueryParameters( + requestParams || deserializeQueryParameters(result.params) + ), + } + : {}), + })); + }); + const cachedResults = Object.keys(results).reduce>>( (acc, key) => acc.concat(results[key].results), [] diff --git a/packages/instantsearch.js/src/types/results.ts b/packages/instantsearch.js/src/types/results.ts index 7fc069ce23..a6077b0af7 100644 --- a/packages/instantsearch.js/src/types/results.ts +++ b/packages/instantsearch.js/src/types/results.ts @@ -1,3 +1,4 @@ +import type { SearchOptions } from './algoliasearch'; import type { PlainSearchParameters, SearchForFacetValues, @@ -94,6 +95,7 @@ export type Refinement = FacetRefinement | NumericRefinement; type InitialResult = { state: PlainSearchParameters; results: SearchResults['_rawResults']; + requestParams?: SearchOptions; }; export type InitialResults = Record; diff --git a/packages/react-instantsearch-core/src/server/__tests__/__snapshots__/getServerState.test.tsx.snap b/packages/react-instantsearch-core/src/server/__tests__/__snapshots__/getServerState.test.tsx.snap index 51c0b106a3..e8276463bb 100644 --- a/packages/react-instantsearch-core/src/server/__tests__/__snapshots__/getServerState.test.tsx.snap +++ b/packages/react-instantsearch-core/src/server/__tests__/__snapshots__/getServerState.test.tsx.snap @@ -3,6 +3,21 @@ exports[`getServerState returns initialResults 1`] = ` { "instant_search": { + "requestParams": { + "facetFilters": [ + [ + "brand:Apple", + ], + ], + "facets": [ + "brand", + ], + "highlightPostTag": "__/ais-highlight__", + "highlightPreTag": "__ais-highlight__", + "maxValuesPerFacet": 10, + "query": "iphone", + "tagFilters": "", + }, "results": [ { "exhaustiveFacetsCount": true, @@ -55,6 +70,17 @@ exports[`getServerState returns initialResults 1`] = ` }, }, "instant_search_price_asc": { + "requestParams": { + "analytics": false, + "clickAnalytics": false, + "facets": "brand", + "highlightPostTag": "__/ais-highlight__", + "highlightPreTag": "__ais-highlight__", + "hitsPerPage": 0, + "maxValuesPerFacet": 10, + "page": 0, + "query": "iphone", + }, "results": [ { "exhaustiveFacetsCount": true, @@ -99,6 +125,17 @@ exports[`getServerState returns initialResults 1`] = ` }, }, "instant_search_price_desc": { + "requestParams": { + "analytics": false, + "clickAnalytics": false, + "facets": "brand", + "highlightPostTag": "__/ais-highlight__", + "highlightPreTag": "__ais-highlight__", + "hitsPerPage": 0, + "maxValuesPerFacet": 10, + "page": 0, + "query": "iphone", + }, "results": [ { "exhaustiveFacetsCount": true, @@ -143,6 +180,21 @@ exports[`getServerState returns initialResults 1`] = ` }, }, "instant_search_rating_desc": { + "requestParams": { + "facetFilters": [ + [ + "brand:Apple", + ], + ], + "facets": [ + "brand", + ], + "highlightPostTag": "__/ais-highlight__", + "highlightPreTag": "__ais-highlight__", + "maxValuesPerFacet": 10, + "query": "iphone", + "tagFilters": "", + }, "results": [ { "exhaustiveFacetsCount": true, diff --git a/packages/react-instantsearch-core/src/server/getServerState.tsx b/packages/react-instantsearch-core/src/server/getServerState.tsx index d2c18defa2..a4e1b48992 100644 --- a/packages/react-instantsearch-core/src/server/getServerState.tsx +++ b/packages/react-instantsearch-core/src/server/getServerState.tsx @@ -129,9 +129,12 @@ function execute({ return waitForResults(searchRef.current); }) - .then(() => { + .then((requestParamsList) => { return { - initialResults: getInitialResults(searchRef.current!.mainIndex), + initialResults: getInitialResults( + searchRef.current!.mainIndex, + requestParamsList + ), }; }); } diff --git a/packages/react-instantsearch-nextjs/src/InitializePromise.tsx b/packages/react-instantsearch-nextjs/src/InitializePromise.tsx index 5d4021c489..0b70ab15e8 100644 --- a/packages/react-instantsearch-nextjs/src/InitializePromise.tsx +++ b/packages/react-instantsearch-nextjs/src/InitializePromise.tsx @@ -8,6 +8,8 @@ import { wrapPromiseWithState, } from 'react-instantsearch-core'; +import type { SearchOptions } from 'instantsearch.js'; + export function InitializePromise() { const search = useInstantSearchContext(); const waitForResultsRef = useRSCContext(); @@ -17,6 +19,16 @@ export function InitializePromise() { throw new Error('Missing ServerInsertedHTMLContext'); }); + // Extract search parameters from the search client to use them + // later during hydration. + let requestParamsList: SearchOptions[]; + search.mainHelper!.setClient({ + search(queries) { + requestParamsList = queries.map(({ params }) => params!); + return search.client.search(queries); + }, + }); + const waitForResults = () => new Promise((resolve) => { search.mainHelper!.derivedHelpers[0].on('result', () => { @@ -26,7 +38,7 @@ export function InitializePromise() { const injectInitialResults = () => { let inserted = false; - const results = getInitialResults(search.mainIndex); + const results = getInitialResults(search.mainIndex, requestParamsList); insertHTML(() => { if (inserted) { return <>; diff --git a/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js b/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js index 4521d41b5b..9ace2a58e1 100644 --- a/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js +++ b/packages/vue-instantsearch/src/util/__tests__/createServerRootMixin.test.js @@ -317,6 +317,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear }); expect(state.hello).toEqual({ + requestParams: { + facets: [], + hitsPerPage: 100, + query: '', + tagFilters: '', + }, results: [ { query: '', @@ -340,6 +346,12 @@ See documentation: https://www.algolia.com/doc/api-reference/widgets/instantsear // Parent's widgets state should not be merged into nested index state expect(state.nestedIndex).toEqual({ + requestParams: { + facets: [], + hitsPerPage: 100, + query: '', + tagFilters: '', + }, results: [ { query: '', diff --git a/packages/vue-instantsearch/src/util/createServerRootMixin.js b/packages/vue-instantsearch/src/util/createServerRootMixin.js index 70ec5cd50a..7ba64be2bc 100644 --- a/packages/vue-instantsearch/src/util/createServerRootMixin.js +++ b/packages/vue-instantsearch/src/util/createServerRootMixin.js @@ -116,8 +116,11 @@ function augmentInstantSearch(instantSearchOptions, cloneComponent) { }) .then(() => renderToString(app)) .then(() => waitForResults(instance)) - .then(() => { - initialResults = getInitialResults(instance.mainIndex); + .then((requestParamsList) => { + initialResults = getInitialResults( + instance.mainIndex, + requestParamsList + ); search.hydrate(initialResults); return search.getState(); });