Skip to content

Commit 968cf43

Browse files
authored
fix(hydration): generate cache with search parameters from server-side request (#5991)
1 parent d1e415e commit 968cf43

File tree

11 files changed

+323
-21
lines changed

11 files changed

+323
-21
lines changed

bundlesize.config.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
},
1111
{
1212
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
13-
"maxSize": "75.75 kB"
13+
"maxSize": "76 kB"
1414
},
1515
{
1616
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",

packages/instantsearch.js/src/lib/__tests__/server.test.ts

Lines changed: 109 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {
33
createSearchClient,
44
} from '@instantsearch/mocks';
55

6-
import { connectSearchBox } from '../../connectors';
6+
import { connectConfigure, connectSearchBox } from '../../connectors';
77
import instantsearch from '../../index.es';
88
import { index } from '../../widgets';
99
import { getInitialResults, waitForResults } from '../server';
@@ -14,8 +14,15 @@ describe('waitForResults', () => {
1414
const search = instantsearch({
1515
indexName: 'indexName',
1616
searchClient,
17+
initialUiState: {
18+
indexName: {
19+
query: 'apple',
20+
},
21+
},
1722
}).addWidgets([
18-
index({ indexName: 'indexName2' }),
23+
index({ indexName: 'indexName2' }).addWidgets([
24+
connectConfigure(() => {})({ searchParameters: { hitsPerPage: 2 } }),
25+
]),
1926
connectSearchBox(() => {})({}),
2027
]);
2128

@@ -25,7 +32,10 @@ describe('waitForResults', () => {
2532

2633
searches[0].resolver();
2734

28-
await expect(output).resolves.toBeUndefined();
35+
await expect(output).resolves.toEqual([
36+
expect.objectContaining({ query: 'apple' }),
37+
expect.objectContaining({ query: 'apple', hitsPerPage: 2 }),
38+
]);
2939
});
3040

3141
test('throws on a search client error', async () => {
@@ -239,4 +249,100 @@ describe('getInitialResults', () => {
239249
},
240250
});
241251
});
252+
253+
test('returns the current results with request params if specified', async () => {
254+
const search = instantsearch({
255+
indexName: 'indexName',
256+
searchClient: createSearchClient(),
257+
initialUiState: {
258+
indexName: {
259+
query: 'apple',
260+
},
261+
indexName2: {
262+
query: 'samsung',
263+
},
264+
},
265+
});
266+
267+
search.addWidgets([
268+
connectSearchBox(() => {})({}),
269+
index({ indexName: 'indexName2' }).addWidgets([
270+
connectSearchBox(() => {})({}),
271+
]),
272+
index({ indexName: 'indexName2', indexId: 'indexId' }).addWidgets([
273+
connectConfigure(() => {})({ searchParameters: { hitsPerPage: 2 } }),
274+
]),
275+
index({ indexName: 'indexName2', indexId: 'indexId' }).addWidgets([
276+
connectConfigure(() => {})({ searchParameters: { hitsPerPage: 3 } }),
277+
]),
278+
]);
279+
280+
search.start();
281+
282+
const requestParams = await waitForResults(search);
283+
284+
// Request params for the same index name + index id are not deduplicated,
285+
// so we should have data for 4 indices (main index + 3 index widgets)
286+
expect(requestParams).toHaveLength(4);
287+
expect(requestParams).toMatchInlineSnapshot(`
288+
[
289+
{
290+
"facets": [],
291+
"query": "apple",
292+
"tagFilters": "",
293+
},
294+
{
295+
"facets": [],
296+
"query": "samsung",
297+
"tagFilters": "",
298+
},
299+
{
300+
"facets": [],
301+
"hitsPerPage": 2,
302+
"query": "apple",
303+
"tagFilters": "",
304+
},
305+
{
306+
"facets": [],
307+
"hitsPerPage": 3,
308+
"query": "apple",
309+
"tagFilters": "",
310+
},
311+
]
312+
`);
313+
314+
// `getInitialResults()` generates a dictionary of initial results
315+
// keyed by index id, so indexName2/indexId should be deduplicated...
316+
expect(Object.entries(getInitialResults(search.mainIndex))).toHaveLength(3);
317+
318+
// ...and only the latest duplicate params are in the returned results
319+
const expectedInitialResults = {
320+
indexName: expect.objectContaining({
321+
requestParams: expect.objectContaining({
322+
query: 'apple',
323+
}),
324+
}),
325+
indexName2: expect.objectContaining({
326+
requestParams: expect.objectContaining({
327+
query: 'samsung',
328+
}),
329+
}),
330+
indexId: expect.objectContaining({
331+
requestParams: expect.objectContaining({
332+
query: 'apple',
333+
hitsPerPage: 3,
334+
}),
335+
}),
336+
};
337+
338+
expect(getInitialResults(search.mainIndex, requestParams)).toEqual(
339+
expectedInitialResults
340+
);
341+
342+
// Multiple calls to `getInitialResults()` with the same requestParams
343+
// return the same results
344+
expect(getInitialResults(search.mainIndex, requestParams)).toEqual(
345+
expectedInitialResults
346+
);
347+
});
242348
});

packages/instantsearch.js/src/lib/server.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,39 @@
11
import { walkIndex } from './utils';
22

3-
import type { IndexWidget, InitialResults, InstantSearch } from '../types';
3+
import type {
4+
IndexWidget,
5+
InitialResults,
6+
InstantSearch,
7+
SearchOptions,
8+
} from '../types';
49

510
/**
611
* Waits for the results from the search instance to coordinate the next steps
712
* in `getServerState()`.
813
*/
9-
export function waitForResults(search: InstantSearch): Promise<void> {
14+
export function waitForResults(
15+
search: InstantSearch
16+
): Promise<SearchOptions[]> {
1017
const helper = search.mainHelper!;
1118

19+
// Extract search parameters from the search client to use them
20+
// later during hydration.
21+
let requestParamsList: SearchOptions[];
22+
const client = helper.getClient();
23+
helper.setClient({
24+
search(queries) {
25+
requestParamsList = queries.map(({ params }) => params!);
26+
return client.search(queries);
27+
},
28+
});
29+
1230
helper.searchOnlyWithDerivedHelpers();
1331

1432
return new Promise((resolve, reject) => {
1533
// All derived helpers resolve in the same tick so we're safe only relying
1634
// on the first one.
1735
helper.derivedHelpers[0].on('result', () => {
18-
resolve();
36+
resolve(requestParamsList);
1937
});
2038

2139
// However, we listen to errors that can happen on any derived helper because
@@ -37,17 +55,27 @@ export function waitForResults(search: InstantSearch): Promise<void> {
3755
/**
3856
* Walks the InstantSearch root index to construct the initial results.
3957
*/
40-
export function getInitialResults(rootIndex: IndexWidget): InitialResults {
58+
export function getInitialResults(
59+
rootIndex: IndexWidget,
60+
/**
61+
* Search parameters sent to the search client,
62+
* returned by `waitForResults()`.
63+
*/
64+
requestParamsList?: SearchOptions[]
65+
): InitialResults {
4166
const initialResults: InitialResults = {};
4267

68+
let requestParamsIndex = 0;
4369
walkIndex(rootIndex, (widget) => {
4470
const searchResults = widget.getResults();
4571
if (searchResults) {
72+
const requestParams = requestParamsList?.[requestParamsIndex++];
4673
initialResults[widget.getIndexId()] = {
4774
// We convert the Helper state to a plain object to pass parsable data
4875
// structures from server to client.
4976
state: { ...searchResults._state },
5077
results: searchResults._rawResults,
78+
...(requestParams && { requestParams }),
5179
};
5280
}
5381
});

packages/instantsearch.js/src/lib/utils/__tests__/hydrateSearchClient-test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,4 +71,80 @@ describe('hydrateSearchClient', () => {
7171

7272
expect(client.cache).toBeDefined();
7373
});
74+
75+
it('should use request params by default', () => {
76+
const setCache = jest.fn();
77+
client = {
78+
transporter: { responsesCache: { set: setCache } },
79+
addAlgoliaAgent: jest.fn(),
80+
} as unknown as SearchClient;
81+
82+
hydrateSearchClient(client, {
83+
instant_search: {
84+
results: [
85+
{ index: 'instant_search', params: 'source=results', nbHits: 1000 },
86+
],
87+
state: {},
88+
rawResults: [
89+
{ index: 'instant_search', params: 'source=results', nbHits: 1000 },
90+
],
91+
requestParams: {
92+
source: 'request',
93+
},
94+
},
95+
} as unknown as InitialResults);
96+
97+
expect(setCache).toHaveBeenCalledWith(
98+
expect.objectContaining({
99+
args: [[{ indexName: 'instant_search', params: 'source=request' }]],
100+
method: 'search',
101+
}),
102+
expect.anything()
103+
);
104+
});
105+
106+
it('should use results params as a fallback', () => {
107+
const setCache = jest.fn();
108+
client = {
109+
transporter: { responsesCache: { set: setCache } },
110+
addAlgoliaAgent: jest.fn(),
111+
} as unknown as SearchClient;
112+
113+
hydrateSearchClient(client, {
114+
instant_search: {
115+
results: [
116+
{ index: 'instant_search', params: 'source=results', nbHits: 1000 },
117+
],
118+
state: {},
119+
rawResults: [
120+
{ index: 'instant_search', params: 'source=results', nbHits: 1000 },
121+
],
122+
},
123+
} as unknown as InitialResults);
124+
125+
expect(setCache).toHaveBeenCalledWith(
126+
expect.objectContaining({
127+
args: [[{ indexName: 'instant_search', params: 'source=results' }]],
128+
method: 'search',
129+
}),
130+
expect.anything()
131+
);
132+
});
133+
134+
it('should not throw if there are no params from request or results to generate the cache with', () => {
135+
expect(() => {
136+
client = {
137+
transporter: { responsesCache: { set: jest.fn() } },
138+
addAlgoliaAgent: jest.fn(),
139+
} as unknown as SearchClient;
140+
141+
hydrateSearchClient(client, {
142+
instant_search: {
143+
results: [{ index: 'instant_search', nbHits: 1000 }],
144+
state: {},
145+
rawResults: [{ index: 'instant_search', nbHits: 1000 }],
146+
},
147+
} as unknown as InitialResults);
148+
}).not.toThrow();
149+
});
74150
});

packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -34,16 +34,24 @@ export function hydrateSearchClient(
3434
return;
3535
}
3636

37-
const cachedRequest = Object.keys(results).map((key) =>
38-
results[key].results.map((result) => ({
39-
indexName: result.index,
37+
const cachedRequest = Object.keys(results).map((key) => {
38+
const { state, requestParams, results: serverResults } = results[key];
39+
return serverResults.map((result) => ({
40+
indexName: state.index || result.index,
4041
// We normalize the params received from the server as they can
4142
// be serialized differently depending on the engine.
42-
params: serializeQueryParameters(
43-
deserializeQueryParameters(result.params)
44-
),
45-
}))
46-
);
43+
// We use search parameters from the server request to craft the cache
44+
// if possible, and fallback to those from results if not.
45+
...(requestParams || result.params
46+
? {
47+
params: serializeQueryParameters(
48+
requestParams || deserializeQueryParameters(result.params)
49+
),
50+
}
51+
: {}),
52+
}));
53+
});
54+
4755
const cachedResults = Object.keys(results).reduce<Array<SearchResponse<any>>>(
4856
(acc, key) => acc.concat(results[key].results),
4957
[]

packages/instantsearch.js/src/types/results.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { SearchOptions } from './algoliasearch';
12
import type {
23
PlainSearchParameters,
34
SearchForFacetValues,
@@ -94,6 +95,7 @@ export type Refinement = FacetRefinement | NumericRefinement;
9495
type InitialResult = {
9596
state: PlainSearchParameters;
9697
results: SearchResults['_rawResults'];
98+
requestParams?: SearchOptions;
9799
};
98100

99101
export type InitialResults = Record<string, InitialResult>;

0 commit comments

Comments
 (0)