Skip to content
This repository has been archived by the owner on Dec 30, 2022. It is now read-only.

feat(hooks): migrate to useSyncExternalStore() #3489

Merged
merged 4 commits into from
Jun 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ module.exports = (api) => {
'instantsearch.js',
// React-DOM also fails if the paths are incomplete
'react-dom',
// `use-sync-external-store` also fails if the paths are incomplete
'use-sync-external-store',
],
},
],
Expand Down
8 changes: 7 additions & 1 deletion packages/react-instantsearch-hooks-web/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ const plugins = [
preferBuiltins: false,
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
}),
commonjs(),
commonjs({
namedExports: {
'../../node_modules/use-sync-external-store/shim/index.js': [
'useSyncExternalStore',
],
},
}),
globals(),
replace({
'process.env.NODE_ENV': JSON.stringify('production'),
Expand Down
3 changes: 2 additions & 1 deletion packages/react-instantsearch-hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@
"dependencies": {
"@babel/runtime": "^7.1.2",
"algoliasearch-helper": "^3.7.4",
"instantsearch.js": "^4.40.1"
"instantsearch.js": "^4.40.1",
"use-sync-external-store": "^1.0.0"
},
"peerDependencies": {
"algoliasearch": ">= 3.1 < 5",
Expand Down
8 changes: 7 additions & 1 deletion packages/react-instantsearch-hooks/rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,13 @@ const plugins = [
preferBuiltins: false,
extensions: ['.mjs', '.js', '.ts', '.jsx', '.tsx', '.json'],
}),
commonjs(),
commonjs({
namedExports: {
'../../node_modules/use-sync-external-store/shim/index.js': [
'useSyncExternalStore',
],
},
}),
globals(),
replace({
'process.env.NODE_ENV': JSON.stringify('production'),
Expand Down
28 changes: 9 additions & 19 deletions packages/react-instantsearch-hooks/src/hooks/useConnector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,25 +83,11 @@ export function useConnector<
}
);

const instance = {
return {
...createWidget(stableProps),
...stableAdditionalWidgetProperties,
};

// On the server, we add the widget early in the memo to retrieve its search
// parameters in the render pass.
if (serverContext) {
parentIndex.addWidgets([instance]);
}

return instance;
}, [
connector,
parentIndex,
serverContext,
stableProps,
stableAdditionalWidgetProperties,
]);
}, [connector, stableProps, stableAdditionalWidgetProperties]);

const [state, setState] = useState<TDescription['renderState']>(() => {
if (widget.getWidgetRenderState) {
Expand Down Expand Up @@ -159,15 +145,19 @@ export function useConnector<
return {};
});

// Using a layout effect adds the widget at the same time as rendering, which
// triggers a single network request, instead of two with a regular effect.
useIsomorphicLayoutEffect(() => {
parentIndex.addWidgets([widget]);

return () => {
parentIndex.removeWidgets([widget]);
};
}, [widget, parentIndex]);
}, [parentIndex, widget]);

// On the server, we add the widget early to retrieve its search parameters
// in the render pass.
if (serverContext && !parentIndex.getWidgets().includes(widget)) {
parentIndex.addWidgets([widget]);
}

return state;
}
104 changes: 45 additions & 59 deletions packages/react-instantsearch-hooks/src/lib/useInstantSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import InstantSearch from 'instantsearch.js/es/lib/InstantSearch';
import { useEffect, useMemo, version as ReactVersion } from 'react';
import { useCallback, useMemo, version as ReactVersion } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';

import { useInstantSearchServerContext } from '../lib/useInstantSearchServerContext';
import { useInstantSearchSSRContext } from '../lib/useInstantSearchSSRContext';
Expand All @@ -8,8 +9,6 @@ import version from '../version';
import { useForceUpdate } from './useForceUpdate';
import { useStableValue } from './useStableValue';

import type { InstantSearchServerContextApi } from '../components/InstantSearchServerContext';
import type { InstantSearchServerState } from '../components/InstantSearchSSRProvider';
import type {
InstantSearchOptions,
SearchClient,
Expand All @@ -30,84 +29,71 @@ export type UseInstantSearchProps<
export function useInstantSearch<TUiState extends UiState, TRouteState>(
props: UseInstantSearchProps<TUiState, TRouteState>
) {
const forceUpdate = useForceUpdate();
const serverContext = useInstantSearchServerContext();
const serverState = useInstantSearchSSRContext();
const stableProps = useStableValue(props);
const search = useMemo(
() =>
serverAdapter(
new InstantSearch(stableProps),
stableProps,
serverContext,
serverState
),
[stableProps, serverContext, serverState]
);
const forceUpdate = useForceUpdate();
const search = useMemo(() => {
const instance = new InstantSearch(stableProps);
const initialResults = serverState?.initialResults;

if (serverContext || initialResults) {
// InstantSearch.js has a private Initial Results API that lets us inject
// results on the search instance.
// On the server, we default the initial results to an empty object so that
// InstantSearch.js doesn't schedule a search that isn't used, leading to
// an additional network request. (This is equivalent to monkey-patching
// `scheduleSearch` to a noop.)
instance._initialResults = initialResults || {};
}

useEffect(() => {
addAlgoliaAgents(stableProps.searchClient, defaultUserAgents);
}, [stableProps.searchClient]);
addAlgoliaAgents(props.searchClient, [
...defaultUserAgents,
serverContext && `react-instantsearch-server (${version})`,
]);

useEffect(() => {
// On SSR, the instance is already started so we don't start it again here.
if (!search.started) {
return instance;
}, [
props.searchClient,
serverContext,
serverState?.initialResults,
stableProps,
]);

const store = useSyncExternalStore<InstantSearch<TUiState, TRouteState>>(
useCallback(() => {
search.start();
forceUpdate();
}

return () => {
search.dispose();
};
}, [search, serverState, forceUpdate]);

return search;
}
return () => {
search.dispose();
};
}, [forceUpdate, search]),
() => search,
() => search
);

function serverAdapter<TUiState extends UiState, TRouteState>(
search: InstantSearch,
props: UseInstantSearchProps<TUiState, TRouteState>,
serverContext: InstantSearchServerContextApi | null,
serverState: Partial<InstantSearchServerState> | null
): InstantSearch<TUiState, TRouteState> {
const initialResults = serverState?.initialResults;

if (serverContext || initialResults) {
// InstantSearch.js has a private Initial Results API that lets us inject
// results on the search instance.
// On the server, we default the initial results to an empty object so that
// InstantSearch.js doesn't schedule a search that isn't used, leading to
// an additional network request. (This is equivalent to monkey-patching
// `scheduleSearch` to a noop.)
search._initialResults = initialResults || {};
if (serverContext && !search.started) {
// On the server, we start the search early to compute the search parameters.
// On SSR, we start the search early to directly catch up with the lifecycle
// and render.
search.start();
}

if (serverContext) {
// On the browser, we add user agents in an effect. Since effects are not
// run on the server, we need to add user agents directly here.
addAlgoliaAgents(props.searchClient, [
...defaultUserAgents,
`react-instantsearch-server (${version})`,
]);

// We notify `getServerState()` of the InstantSearch internals to retrieve
// the server state and pass it to the render on SSR.
serverContext.notifyServer({ search });
}

return search;
return store;
}

function addAlgoliaAgents(searchClient: SearchClient, userAgents: string[]) {
function addAlgoliaAgents(
searchClient: SearchClient,
userAgents: Array<string | null>
) {
if (typeof searchClient.addAlgoliaAgent !== 'function') {
return;
}

userAgents.forEach((userAgent) => {
searchClient.addAlgoliaAgent!(userAgent);
userAgents.filter(Boolean).forEach((userAgent) => {
searchClient.addAlgoliaAgent!(userAgent!);
});
}
Loading