Skip to content

Commit d3e99d2

Browse files
authored
Prevent stale closures of memoized callbacks (#137)
* improve callback memoization * add useWarnOnce hook
1 parent 87c12b6 commit d3e99d2

File tree

6 files changed

+85
-38
lines changed

6 files changed

+85
-38
lines changed

src/useCache.js

-12
This file was deleted.

src/useFormState.js

+10-21
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useRef } from 'react';
22
import { parseInputArgs } from './parseInputArgs';
33
import { useInputId } from './useInputId';
4-
import { useCache } from './useCache';
4+
import { useMap, useReferencedCallback, useWarnOnce } from './utils-hooks';
55
import { useState } from './useState';
66
import {
77
noop,
@@ -36,16 +36,9 @@ export default function useFormState(initialState, options) {
3636

3737
const formState = useState({ initialState });
3838
const { getIdProp } = useInputId(formOptions.withIds);
39-
const { set: setDirty, get: isDirty } = useCache();
40-
const callbacks = useCache();
41-
const devWarnings = useCache();
42-
43-
function warn(key, type, message) {
44-
if (!devWarnings.has(`${type}:${key}`)) {
45-
devWarnings.set(`${type}:${key}`, true);
46-
console.warn('[useFormState]', message);
47-
}
48-
}
39+
const { set: setDirty, get: isDirty } = useMap();
40+
const referencedCallback = useReferencedCallback();
41+
const warn = useWarnOnce();
4942

5043
const createPropsGetter = type => (...args) => {
5144
const inputOptions = parseInputArgs(args);
@@ -69,8 +62,7 @@ export default function useFormState(initialState, options) {
6962
if (__DEV__) {
7063
if (isRaw) {
7164
warn(
72-
key,
73-
'missingInitialValue',
65+
`missingInitialValue.${key}`,
7466
`The initial value for input "${name}" is missing. Custom inputs ` +
7567
'controlled with raw() are expected to have an initial value ' +
7668
'provided to useFormState(). To prevent React from treating ' +
@@ -124,8 +116,7 @@ export default function useFormState(initialState, options) {
124116
if (__DEV__) {
125117
if (isRaw && ![value, other].every(testIsEqualCompatibility)) {
126118
warn(
127-
key,
128-
'missingCompare',
119+
`missingCompare.${key}`,
129120
`You used a raw input type for "${name}" without providing a ` +
130121
'custom compare method. As a result, the pristine value of ' +
131122
'this input will be calculated using strict equality check ' +
@@ -164,8 +155,7 @@ export default function useFormState(initialState, options) {
164155
error = e.target.validationMessage;
165156
} else if (__DEV__) {
166157
warn(
167-
key,
168-
'missingValidate',
158+
`missingValidate.${key}`,
169159
`You used a raw input type for "${name}" without providing a ` +
170160
'custom validate method. As a result, validation of this input ' +
171161
'will be set to "true" automatically. If you need to validate ' +
@@ -243,7 +233,7 @@ export default function useFormState(initialState, options) {
243233

244234
return hasValueInState ? formState.current.values[name] : '';
245235
},
246-
onChange: callbacks.getOrSet(`onChange.${key}`, e => {
236+
onChange: referencedCallback(`onChange.${key}`, e => {
247237
setDirty(name, true);
248238
let value;
249239
if (isRaw) {
@@ -256,8 +246,7 @@ export default function useFormState(initialState, options) {
256246
/* istanbul ignore else */
257247
if (__DEV__) {
258248
warn(
259-
key,
260-
'onChangeUndefined',
249+
`onChangeUndefined.${key}`,
261250
`You used a raw input type for "${name}" with an onChange() ` +
262251
'option without returning a value. The onChange callback ' +
263252
'of raw inputs, when provided, is used to determine the ' +
@@ -296,7 +285,7 @@ export default function useFormState(initialState, options) {
296285

297286
formState.setValues(partialNewState);
298287
}),
299-
onBlur: callbacks.getOrSet(`onBlur.${key}`, e => {
288+
onBlur: referencedCallback(`onBlur.${key}`, e => {
300289
touch(e);
301290

302291
inputOptions.onBlur(e);

src/useState.js

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { useReducer, useRef } from 'react';
22
import { isFunction, isEqual } from './utils';
3-
import { useCache } from './useCache';
3+
import { useMap } from './utils-hooks';
44

55
function stateReducer(state, newState) {
66
return isFunction(newState) ? newState(state) : { ...state, ...newState };
77
}
88

99
export function useState({ initialState }) {
1010
const state = useRef();
11-
const initialValues = useCache();
12-
const comparators = useCache();
11+
const initialValues = useMap();
12+
const comparators = useMap();
1313
const [values, setValues] = useReducer(stateReducer, initialState || {});
1414
const [touched, setTouched] = useReducer(stateReducer, {});
1515
const [validity, setValidity] = useReducer(stateReducer, {});

src/utils-hooks.js

+32
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { useRef } from 'react';
2+
3+
export function useMap() {
4+
const map = useRef(new Map());
5+
return {
6+
set: (key, value) => map.current.set(key, value),
7+
has: key => map.current.has(key),
8+
get: key => map.current.get(key),
9+
};
10+
}
11+
12+
export function useReferencedCallback() {
13+
const callbacks = useMap();
14+
return (key, current) => {
15+
if (!callbacks.has(key)) {
16+
const callback = (...args) => callback.current(...args);
17+
callbacks.set(key, callback);
18+
}
19+
callbacks.get(key).current = current;
20+
return callbacks.get(key);
21+
};
22+
}
23+
24+
export function useWarnOnce() {
25+
const didWarnRef = useRef(new Set());
26+
return (key, message) => {
27+
if (!didWarnRef.current.has(key)) {
28+
didWarnRef.current.add(key);
29+
console.warn('[useFormState]', message);
30+
}
31+
};
32+
}

test/test-utils.js

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { useFormState } from '../src';
44

55
export { renderHook } from 'react-hooks-testing-library';
66

7+
export { render as renderElement, fireEvent };
8+
79
export const InputTypes = {
810
textLike: ['text', 'email', 'password', 'search', 'tel', 'url'],
911
time: ['date', 'month', 'time', 'week'],

test/useFormState-input.test.js

+38-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import React from 'react';
1+
import React, { useState } from 'react';
22
import { useFormState } from '../src';
3-
import { renderWithFormState, renderHook, InputTypes } from './test-utils';
3+
import {
4+
InputTypes,
5+
fireEvent,
6+
renderHook,
7+
renderElement,
8+
renderWithFormState,
9+
} from './test-utils';
410

511
describe('input type methods return correct props object', () => {
612
/**
@@ -553,4 +559,34 @@ describe('Input props are memoized', () => {
553559
change({ value: 'c' }, root.childNodes[1]);
554560
expect(renderCheck).toHaveBeenCalledTimes(2);
555561
});
562+
563+
it('prevents callbacks from using stale closures', () => {
564+
const onInputChange = jest.fn();
565+
566+
function ComponentWithInternalState() {
567+
const [state, setState] = useState(1);
568+
const [, { text }] = useFormState(null, {
569+
onBlur: () => {
570+
onInputChange(state);
571+
setState(state + 1);
572+
},
573+
onChange: () => {
574+
onInputChange(state);
575+
setState(state + 1);
576+
},
577+
});
578+
return <input {...text('name')} />;
579+
}
580+
581+
const { container } = renderElement(<ComponentWithInternalState />);
582+
583+
fireEvent.change(container.firstChild, { target: { value: 'foo' } });
584+
expect(onInputChange).toHaveBeenLastCalledWith(1);
585+
586+
fireEvent.change(container.firstChild, { target: { value: 'bar' } });
587+
expect(onInputChange).toHaveBeenLastCalledWith(2);
588+
589+
fireEvent.blur(container.firstChild);
590+
expect(onInputChange).toHaveBeenLastCalledWith(3);
591+
});
556592
});

0 commit comments

Comments
 (0)