Skip to content

Commit

Permalink
feat(instantsearch): hydrate search client (#5854)
Browse files Browse the repository at this point in the history
* feat(react-instantsearch): hydrate search client

* move function call in instantsearch.js

* test: `hydrateSearchClient` to make sure it caches results (#5913)

* test: add tests for `hydrateSearchClient` function

* add test in InstantSearchSSRProvider

* update bundlesize

---------

Co-authored-by: Aymeric Giraudet <aymeric.giraudet@algolia.com>
  • Loading branch information
dhayab and aymeric-giraudet committed Oct 31, 2023
1 parent 1501fae commit 5b96771
Show file tree
Hide file tree
Showing 8 changed files with 304 additions and 25 deletions.
8 changes: 4 additions & 4 deletions bundlesize.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.production.min.js",
"maxSize": "75 kB"
"maxSize": "75.25 kB"
},
{
"path": "./packages/instantsearch.js/dist/instantsearch.development.js",
"maxSize": "165 kB"
"maxSize": "165.5 kB"
},
{
"path": "packages/react-instantsearch-core/dist/umd/ReactInstantSearchCore.min.js",
Expand All @@ -26,11 +26,11 @@
},
{
"path": "packages/vue-instantsearch/vue2/umd/index.js",
"maxSize": "63.5 kB"
"maxSize": "63.75 kB"
},
{
"path": "packages/vue-instantsearch/vue3/umd/index.js",
"maxSize": "63.75 kB"
"maxSize": "64.25 kB"
},
{
"path": "packages/vue-instantsearch/vue2/cjs/index.js",
Expand Down
3 changes: 3 additions & 0 deletions packages/instantsearch.js/src/lib/InstantSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
createDocumentationMessageGenerator,
createDocumentationLink,
defer,
hydrateSearchClient,
noop,
warning,
setIndexHelperState,
Expand Down Expand Up @@ -634,6 +635,8 @@ See documentation: ${createDocumentationLink({
});

if (this._initialResults) {
hydrateSearchClient(this.client, this._initialResults);

const originalScheduleSearch = this.scheduleSearch;
// We don't schedule a first search when initial results are provided
// because we already have the results to render. This skips the initial
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { hydrateSearchClient } from '../hydrateSearchClient';

import type { SearchClient, InitialResults } from '../../../types';

describe('hydrateSearchClient', () => {
let client: SearchClient & {
_cacheHydrated?: boolean;
_useCache?: boolean;
cache?: Record<string, string>;
};
const initialResults = {
instant_search: {
results: [{ index: 'instant_search', params: 'params', nbHits: 1000 }],
state: {},
rawResults: [{ index: 'instant_search', params: 'params', nbHits: 1000 }],
},
} as unknown as InitialResults;

it('should not hydrate the client if no results are provided', () => {
client = {} as unknown as SearchClient;
hydrateSearchClient(client, undefined);

expect(client._cacheHydrated).not.toBeDefined();
});

it('should not hydrate the client if the cache is disabled', () => {
client = { _useCache: false } as unknown as SearchClient;

hydrateSearchClient(client, initialResults);

expect(client._cacheHydrated).not.toBeDefined();
});

it('should not hydrate the client if `addAlgoliaAgent` is missing', () => {
client = { addAlgoliaAgent: undefined } as unknown as SearchClient;

hydrateSearchClient(client, initialResults);

expect(client._cacheHydrated).not.toBeDefined();
});

it('should hydrate the client for >= v4 if the cache is enabled and the Algolia agent is present', () => {
const setCache = jest.fn();
client = {
transporter: { responsesCache: { set: setCache } },
addAlgoliaAgent: jest.fn(),
} as unknown as SearchClient;

hydrateSearchClient(client, initialResults);

expect(setCache).toHaveBeenCalledWith(
expect.objectContaining({
args: [[{ indexName: 'instant_search', params: 'params' }]],
method: 'search',
}),
expect.objectContaining({
results: [{ index: 'instant_search', params: 'params', nbHits: 1000 }],
})
);
expect(client._cacheHydrated).toBe(true);
expect(client.search).toBeDefined();
});

it('should populate the cache for < v4 if there is no transporter object', () => {
client = {
addAlgoliaAgent: jest.fn(),
_useCache: true,
} as unknown as SearchClient;

hydrateSearchClient(client, initialResults);

expect(client.cache).toBeDefined();
});
});
132 changes: 132 additions & 0 deletions packages/instantsearch.js/src/lib/utils/hydrateSearchClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
// @ts-nocheck (types to be fixed during actual implementation)
import type { InitialResults, SearchClient } from '../../types';

export function hydrateSearchClient(
client: SearchClient,
results?: InitialResults
) {
if (!results) {
return;
}

// Disable cache hydration on:
// - Algoliasearch API Client < v4 with cache disabled
// - Third party clients (detected by the `addAlgoliaAgent` function missing)

if (
(!client.transporter || client._cacheHydrated) &&
(!client._useCache || typeof client.addAlgoliaAgent !== 'function')
) {
return;
}

// Algoliasearch API Client >= v4
// To hydrate the client we need to populate the cache with the data from
// the server (done in `hydrateSearchClientWithMultiIndexRequest` or
// `hydrateSearchClientWithSingleIndexRequest`). But since there is no way
// for us to compute the key the same way as `algoliasearch-client` we need
// to populate it on a custom key and override the `search` method to
// search on it first.
if (client.transporter && !client._cacheHydrated) {
client._cacheHydrated = true;

const baseMethod = client.search;
client.search = (requests, ...methodArgs) => {
const requestsWithSerializedParams = requests.map((request) => ({
...request,
params: serializeQueryParameters(request.params),
}));

return client.transporter.responsesCache.get(
{
method: 'search',
args: [requestsWithSerializedParams, ...methodArgs],
},
() => {
return baseMethod(requests, ...methodArgs);
}
);
};

// Populate the cache with the data from the server
client.transporter.responsesCache.set(
{
method: 'search',
args: [
Object.keys(results).reduce(
(acc, key) =>
acc.concat(
results[key].results.map((request) => ({
indexName: request.index,
params: request.params,
}))
),
[]
),
],
},
{
results: Object.keys(results).reduce(
(acc, key) => acc.concat(results[key].results),
[]
),
}
);
}

// Algoliasearch API Client < v4
// Prior to client v4 we didn't have a proper API to hydrate the client
// cache from the outside. The following code populates the cache with
// a single-index result. You can find more information about the
// computation of the key inside the client (see link below).
// https://github.com/algolia/algoliasearch-client-javascript/blob/c27e89ff92b2a854ae6f40dc524bffe0f0cbc169/src/AlgoliaSearchCore.js#L232-L240
if (!client.transporter) {
const cacheKey = `/1/indexes/*/queries_body_${JSON.stringify({
requests: Object.keys(results).reduce(
(acc, key) =>
acc.concat(
results[key].rawResults.map((request) => ({
indexName: request.index,
params: request.params,
}))
),
[]
),
})}`;

client.cache = {
...client.cache,
[cacheKey]: JSON.stringify({
results: Object.keys(results).reduce(
(acc, key) => acc.concat(results[key].rawResults),
[]
),
}),
};
}
}

// This function is copied from the algoliasearch v4 API Client. If modified,
// consider updating it also in `serializeQueryParameters` from `@algolia/transporter`.
function serializeQueryParameters(parameters) {
const isObjectOrArray = (value) =>
Object.prototype.toString.call(value) === '[object Object]' ||
Object.prototype.toString.call(value) === '[object Array]';

const encode = (format, ...args) => {
let i = 0;
return format.replace(/%s/g, () => encodeURIComponent(args[i++]));
};

return Object.keys(parameters)
.map((key) =>
encode(
'%s=%s',
key,
isObjectOrArray(parameters[key])
? JSON.stringify(parameters[key])
: parameters[key]
)
)
.join('&');
}
1 change: 1 addition & 0 deletions packages/instantsearch.js/src/lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export * from './getRefinements';
export * from './getWidgetAttribute';
export * from './hits-absolute-position';
export * from './hits-query-id';
export * from './hydrateSearchClient';
export * from './isDomElement';
export * from './isEqual';
export * from './isFacetRefined';
Expand Down

0 comments on commit 5b96771

Please sign in to comment.