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

Commit d94899d

Browse files
authored
fix(hooks): prevent widget cleanup on <InstantSearch> unmount (#3590)
1 parent affcbb5 commit d94899d

File tree

5 files changed

+77
-11
lines changed

5 files changed

+77
-11
lines changed

.eslintrc.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ const config = {
8989
paths: ['instantsearch.js/es'],
9090
},
9191
],
92+
'react-hooks/exhaustive-deps': [
93+
'warn',
94+
{
95+
additionalHooks: '(useIsomorphicLayoutEffect)',
96+
},
97+
],
9298
},
9399
overrides: [
94100
{
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,8 @@
11
declare const __DEV__: boolean;
2+
3+
import 'instantsearch.js';
4+
declare module 'instantsearch.js' {
5+
declare class InstantSearch {
6+
_preventWidgetCleanup?: boolean;
7+
}
8+
}

packages/react-instantsearch-hooks/src/components/__tests__/InstantSearch.test.tsx

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import React, { StrictMode, Suspense, version as ReactVersion } from 'react';
66
import { SearchBox } from 'react-instantsearch-hooks-web';
77

88
import { createSearchClient } from '../../../../../test/mock';
9-
import { createInstantSearchSpy } from '../../../../../test/utils';
9+
import { createInstantSearchSpy, wait } from '../../../../../test/utils';
1010
import { useRefinementList } from '../../connectors/useRefinementList';
1111
import version from '../../version';
1212
import { Index } from '../Index';
@@ -749,4 +749,55 @@ describe('InstantSearch', () => {
749749
expect(searchFunction2).toHaveBeenCalledTimes(5);
750750
});
751751
});
752+
753+
test('triggers no search on unmount', async () => {
754+
const searchClient = createSearchClient({});
755+
756+
function App() {
757+
return (
758+
<StrictMode>
759+
<InstantSearch searchClient={searchClient} indexName="indexName">
760+
<SearchBox />
761+
<RefinementList attribute="brand" />
762+
<RefinementList attribute="price" />
763+
</InstantSearch>
764+
</StrictMode>
765+
);
766+
}
767+
768+
const { unmount } = render(<App />);
769+
770+
await waitFor(() => expect(searchClient.search).toHaveBeenCalledTimes(1));
771+
772+
unmount();
773+
774+
// We need to wait for the `cleanup` functions of the instance and
775+
// the widgets to get called since they are schedule with a `setTimeout`.
776+
await wait(100);
777+
778+
expect(searchClient.search).toHaveBeenCalledTimes(1);
779+
});
780+
781+
test('triggers a search on widget unmount', async () => {
782+
const searchClient = createSearchClient({});
783+
784+
function App({ isMounted }) {
785+
return (
786+
<StrictMode>
787+
<InstantSearch searchClient={searchClient} indexName="indexName">
788+
<SearchBox />
789+
{isMounted && <RefinementList attribute="brand" />}
790+
</InstantSearch>
791+
</StrictMode>
792+
);
793+
}
794+
795+
const { rerender } = render(<App isMounted={true} />);
796+
797+
await waitFor(() => expect(searchClient.search).toHaveBeenCalledTimes(1));
798+
799+
rerender(<App isMounted={false} />);
800+
801+
await waitFor(() => expect(searchClient.search).toHaveBeenCalledTimes(2));
802+
});
752803
});

packages/react-instantsearch-hooks/src/lib/useInstantSearchApi.ts

Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -138,19 +138,12 @@ export function useInstantSearchApi<TUiState extends UiState, TRouteState>(
138138
// We cancel the previous cleanup function because we don't want to
139139
// dispose the search during an update.
140140
clearTimeout(cleanupTimerRef.current);
141+
search._preventWidgetCleanup = false;
141142
}
142143

143144
return () => {
144145
function cleanup() {
145-
// When `dispose()` is called, InstantSearch.js also calls `removeWigets()`.
146-
// This is not desired because React starts by unmounting each widget
147-
// before unmounting <InstantSearch>, so their `dispose()` methods are
148-
// already called.
149-
// Calling `removeWidgets()` would remove each widget multiple times.
150-
const originalRemoveWidgets = search.removeWidgets;
151-
search.removeWidgets = () => search;
152146
search.dispose();
153-
search.removeWidgets = originalRemoveWidgets;
154147
}
155148

156149
// We clean up only when the component that uses this subscription unmounts,
@@ -159,7 +152,12 @@ export function useInstantSearchApi<TUiState extends UiState, TRouteState>(
159152
// Executing the cleanup function in a `setTimeout()` lets us cancel it
160153
// in the next effect.
161154
// (There might be better ways to do this.)
162-
cleanupTimerRef.current = setTimeout(cleanup, 0);
155+
cleanupTimerRef.current = setTimeout(cleanup);
156+
157+
// We need to prevent the `useWidget` cleanup function so that widgets
158+
// are not removed before the instance is disposed, triggering
159+
// an unwanted search request.
160+
search._preventWidgetCleanup = true;
163161
};
164162
}, [forceUpdate]),
165163
() => searchRef.current,

packages/react-instantsearch-hooks/src/lib/useWidget.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useRef } from 'react';
22

33
import { dequal } from './dequal';
4+
import { useInstantSearchContext } from './useInstantSearchContext';
45
import { useIsomorphicLayoutEffect } from './useIsomorphicLayoutEffect';
56

67
import type { Widget } from 'instantsearch.js';
@@ -31,13 +32,16 @@ export function useWidget<TWidget extends Widget | IndexWidget, TProps>({
3132
const shouldAddWidgetEarly =
3233
shouldSsr && !parentIndex.getWidgets().includes(widget);
3334

35+
const search = useInstantSearchContext();
36+
3437
// This effect is responsible for adding, removing, and updating the widget.
3538
// We need to support scenarios where the widget is remounted quickly, like in
3639
// Strict Mode, so that we don't lose its state, and therefore that we don't
3740
// break routing.
3841
useIsomorphicLayoutEffect(() => {
3942
const previousWidget = prevWidgetRef.current;
4043
function cleanup() {
44+
if (search._preventWidgetCleanup) return;
4145
parentIndex.removeWidgets([previousWidget]);
4246
}
4347

@@ -76,7 +80,7 @@ export function useWidget<TWidget extends Widget | IndexWidget, TProps>({
7680
// we're able to cancel it in the next effect.
7781
cleanupTimerRef.current = setTimeout(cleanup);
7882
};
79-
}, [parentIndex, widget, shouldAddWidgetEarly]);
83+
}, [parentIndex, widget, shouldAddWidgetEarly, search, props]);
8084

8185
if (shouldAddWidgetEarly) {
8286
parentIndex.addWidgets([widget]);

0 commit comments

Comments
 (0)