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

Commit 1799fc9

Browse files
fix(hooks): prevent infinite loops from render state (#3455)
1 parent 2d99b88 commit 1799fc9

File tree

7 files changed

+205
-10
lines changed

7 files changed

+205
-10
lines changed

packages/react-instantsearch-hooks/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"dependencies": {
4949
"@babel/runtime": "^7.1.2",
5050
"algoliasearch-helper": "^3.7.4",
51-
"dequal": "^2.0.0",
5251
"instantsearch.js": "^4.40.1"
5352
},
5453
"peerDependencies": {

packages/react-instantsearch-hooks/src/hooks/__tests__/useConnector.test.tsx

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,13 @@ import {
88
createSearchClient,
99
createSingleSearchResponse,
1010
} from '../../../../../test/mock';
11-
import { createInstantSearchTestWrapper } from '../../../../../test/utils';
11+
import {
12+
createInstantSearchTestWrapper,
13+
wait,
14+
} from '../../../../../test/utils';
1215
import { Index } from '../../components/Index';
1316
import { InstantSearch } from '../../components/InstantSearch';
17+
import { useHits } from '../../connectors/useHits';
1418
import { IndexContext } from '../../lib/IndexContext';
1519
import { InstantSearchContext } from '../../lib/InstantSearchContext';
1620
import { noop } from '../../lib/noop';
@@ -133,6 +137,56 @@ const connectCustomSearchBoxWithoutRenderState: Connector<
133137
};
134138
};
135139

140+
const connectUnstableSearchBox: Connector<
141+
CustomSearchBoxWidgetDescription,
142+
Record<string, never>
143+
> =
144+
(renderFn, unmountFn = noop) =>
145+
(widgetParams) => {
146+
return {
147+
$$type: 'test.searchBox',
148+
init(params) {
149+
renderFn(
150+
{
151+
...this.getWidgetRenderState!(params),
152+
instantSearchInstance: params.instantSearchInstance,
153+
},
154+
true
155+
);
156+
},
157+
render(params) {
158+
renderFn(
159+
{
160+
...this.getWidgetRenderState!(params),
161+
query: 'query',
162+
instantSearchInstance: params.instantSearchInstance,
163+
},
164+
false
165+
);
166+
},
167+
dispose() {
168+
unmountFn();
169+
},
170+
getWidgetRenderState({ helper, state }) {
171+
return {
172+
query: state.query || '',
173+
// This creates a new reference for `refine()` at every render.
174+
refine: (value) => helper.setQuery(value).search(),
175+
widgetParams,
176+
};
177+
},
178+
getWidgetUiState(uiState, { searchParameters }) {
179+
return {
180+
...uiState,
181+
query: searchParameters.query,
182+
};
183+
},
184+
getWidgetSearchParameters(searchParameters, { uiState }) {
185+
return searchParameters.setQueryParameter('query', uiState.query || '');
186+
},
187+
};
188+
};
189+
136190
describe('useConnector', () => {
137191
test('returns the connector render state', async () => {
138192
const wrapper = createInstantSearchTestWrapper();
@@ -448,4 +502,43 @@ describe('useConnector', () => {
448502
])
449503
);
450504
});
505+
506+
test('limits the number of renders with unstable function references from render state', async () => {
507+
const searchClient = createSearchClient({});
508+
509+
function Hits(props) {
510+
useHits(props);
511+
return null;
512+
}
513+
514+
function Search() {
515+
// Use a connector with unstable function references in render state
516+
const { query } = useConnector(connectUnstableSearchBox);
517+
518+
return (
519+
<>
520+
<input value={query} />
521+
{/* Use unstable function as prop */}
522+
<Hits transformItems={(items) => items} />
523+
</>
524+
);
525+
}
526+
527+
function App() {
528+
return (
529+
<InstantSearch searchClient={searchClient} indexName="indexName">
530+
<Search />
531+
</InstantSearch>
532+
);
533+
}
534+
535+
render(<App />);
536+
537+
await wait(0);
538+
539+
// This checks that InstantSearch doesn't re-render endlessly. We should
540+
// still be able to optimize this render count to `1`, but `2` is acceptable
541+
// for now compared to an infinite loop.
542+
expect(searchClient.search).toHaveBeenCalledTimes(2);
543+
});
451544
});

packages/react-instantsearch-hooks/src/hooks/useConnector.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useMemo, useRef, useState } from 'react';
22

33
import { createSearchResults } from '../lib/createSearchResults';
4+
import { dequal } from '../lib/dequal';
45
import { useIndexContext } from '../lib/useIndexContext';
56
import { useInstantSearchContext } from '../lib/useInstantSearchContext';
67
import { useInstantSearchServerContext } from '../lib/useInstantSearchServerContext';
@@ -27,6 +28,9 @@ export function useConnector<
2728
additionalWidgetProperties
2829
);
2930
const shouldSetStateRef = useRef(true);
31+
const previousRenderStateRef = useRef<TDescription['renderState'] | null>(
32+
null
33+
);
3034

3135
const widget = useMemo(() => {
3236
const createWidget = connector(
@@ -54,8 +58,22 @@ export function useConnector<
5458
const { instantSearchInstance, widgetParams, ...renderState } =
5559
connectorState;
5660

57-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
58-
setState(renderState);
61+
// We only update the state when a widget render state param changes,
62+
// except for functions. We ignore function reference changes to avoid
63+
// infinite loops. It's safe to omit them because they get updated
64+
// every time another render param changes.
65+
if (
66+
!dequal(
67+
renderState,
68+
previousRenderStateRef.current,
69+
(a, b) =>
70+
a?.constructor === Function && b?.constructor === Function
71+
)
72+
) {
73+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
74+
setState(renderState);
75+
previousRenderStateRef.current = renderState;
76+
}
5977
}
6078
},
6179
() => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { dequal } from '../dequal';
2+
3+
describe('dequal', () => {
4+
const areFunctions = (a: any, b: any): boolean =>
5+
a?.constructor === Function && b?.constructor === Function;
6+
7+
test('without compare returns false for functions', () => {
8+
expect(
9+
dequal(
10+
() => {},
11+
() => {}
12+
)
13+
).toEqual(false);
14+
});
15+
16+
test('with a compare returns true for functions', () => {
17+
expect(
18+
dequal(
19+
() => {},
20+
() => {},
21+
areFunctions
22+
)
23+
).toEqual(true);
24+
});
25+
26+
test('without compare returns false for nested functions', () => {
27+
expect(dequal({ fn: () => {} }, { fn: () => {} })).toEqual(false);
28+
});
29+
30+
test('with a compare returns true for nested functions', () => {
31+
expect(dequal({ fn: () => {} }, { fn: () => {} }, areFunctions)).toEqual(
32+
true
33+
);
34+
});
35+
});
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/* eslint-disable complexity */
2+
3+
/*
4+
* Code taken from dequal/lite v2.0.0
5+
* https://github.com/lukeed/dequal/blob/9aa73181ac7e081cd330cac67d313632ac04bb02/src/lite.js
6+
*
7+
* It adds a 3rd argument `compare(a, b)` that lets execute custom logic to
8+
* compare values.
9+
* We use it to skip comparing function references.
10+
*/
11+
12+
const has = Object.prototype.hasOwnProperty;
13+
14+
export function dequal(
15+
foo: any,
16+
bar: any,
17+
compare?: (a: any, b: any) => boolean
18+
) {
19+
// start of custom implementation
20+
if (compare?.(foo, bar)) {
21+
return true;
22+
}
23+
// end of custom implementation
24+
25+
let ctor;
26+
let len;
27+
if (foo === bar) return true;
28+
29+
if (foo && bar && (ctor = foo.constructor) === bar.constructor) {
30+
if (ctor === Date) return foo.getTime() === bar.getTime();
31+
if (ctor === RegExp) return foo.toString() === bar.toString();
32+
33+
if (ctor === Array) {
34+
if ((len = foo.length) === bar.length) {
35+
while (len-- && dequal(foo[len], bar[len], compare));
36+
}
37+
return len === -1;
38+
}
39+
40+
if (!ctor || typeof foo === 'object') {
41+
len = 0;
42+
// eslint-disable-next-line guard-for-in, no-restricted-syntax
43+
for (ctor in foo) {
44+
if (has.call(foo, ctor) && ++len && !has.call(bar, ctor)) return false;
45+
if (!(ctor in bar) || !dequal(foo[ctor], bar[ctor], compare))
46+
return false;
47+
}
48+
return Object.keys(bar).length === len;
49+
}
50+
}
51+
52+
// eslint-disable-next-line no-self-compare
53+
return foo !== foo && bar !== bar;
54+
}

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { dequal } from 'dequal/lite';
21
import { useEffect, useState } from 'react';
32

3+
import { dequal } from '../lib/dequal';
4+
45
export function useStableValue<TValue>(value: TValue) {
56
const [stableValue, setStableValue] = useState<TValue>(() => value);
67

yarn.lock

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12524,11 +12524,6 @@ deprecation@^2.0.0, deprecation@^2.3.1:
1252412524
resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919"
1252512525
integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ==
1252612526

12527-
dequal@^2.0.0:
12528-
version "2.0.2"
12529-
resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.2.tgz#85ca22025e3a87e65ef75a7a437b35284a7e319d"
12530-
integrity sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug==
12531-
1253212527
des.js@^1.0.0:
1253312528
version "1.0.0"
1253412529
resolved "https://registry.yarnpkg.com/des.js/-/des.js-1.0.0.tgz#c074d2e2aa6a8a9a07dbd61f9a15c2cd83ec8ecc"

0 commit comments

Comments
 (0)