Skip to content

Commit

Permalink
fix(hydration): generate cache with search parameters from server-sid…
Browse files Browse the repository at this point in the history
…e request (#5991)
  • Loading branch information
dhayab committed Jan 17, 2024
1 parent d1e415e commit 968cf43
Show file tree
Hide file tree
Showing 11 changed files with 323 additions and 21 deletions.
2 changes: 1 addition & 1 deletion bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
112 changes: 109 additions & 3 deletions packages/instantsearch.js/src/lib/__tests__/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(() => {})({}),
]);

Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
);
});
});
36 changes: 32 additions & 4 deletions packages/instantsearch.js/src/lib/server.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
export function waitForResults(
search: InstantSearch
): Promise<SearchOptions[]> {
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
Expand All @@ -37,17 +55,27 @@ export function waitForResults(search: InstantSearch): Promise<void> {
/**
* 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 }),
};
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
24 changes: 16 additions & 8 deletions packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Array<SearchResponse<any>>>(
(acc, key) => acc.concat(results[key].results),
[]
Expand Down
2 changes: 2 additions & 0 deletions packages/instantsearch.js/src/types/results.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { SearchOptions } from './algoliasearch';
import type {
PlainSearchParameters,
SearchForFacetValues,
Expand Down Expand Up @@ -94,6 +95,7 @@ export type Refinement = FacetRefinement | NumericRefinement;
type InitialResult = {
state: PlainSearchParameters;
results: SearchResults['_rawResults'];
requestParams?: SearchOptions;
};

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

0 comments on commit 968cf43

Please sign in to comment.