diff --git a/src/useCache.js b/src/useCache.js deleted file mode 100644 index 324a0bb..0000000 --- a/src/useCache.js +++ /dev/null @@ -1,12 +0,0 @@ -import { useRef } from 'react'; - -export function useCache() { - const cache = useRef(new Map()); - const has = key => cache.current.has(key); - const get = key => cache.current.get(key); - const set = (key, value) => cache.current.set(key, value); - const getOrSet = (key, value) => - has(key) ? get(key) : set(key, value) && get(key); - - return { getOrSet, set, has, get }; -} diff --git a/src/useFormState.js b/src/useFormState.js index 0089242..665b3dc 100644 --- a/src/useFormState.js +++ b/src/useFormState.js @@ -1,7 +1,7 @@ import { useRef } from 'react'; import { parseInputArgs } from './parseInputArgs'; import { useInputId } from './useInputId'; -import { useCache } from './useCache'; +import { useMap, useReferencedCallback, useWarnOnce } from './utils-hooks'; import { useState } from './useState'; import { noop, @@ -36,16 +36,9 @@ export default function useFormState(initialState, options) { const formState = useState({ initialState }); const { getIdProp } = useInputId(formOptions.withIds); - const { set: setDirty, get: isDirty } = useCache(); - const callbacks = useCache(); - const devWarnings = useCache(); - - function warn(key, type, message) { - if (!devWarnings.has(`${type}:${key}`)) { - devWarnings.set(`${type}:${key}`, true); - console.warn('[useFormState]', message); - } - } + const { set: setDirty, get: isDirty } = useMap(); + const referencedCallback = useReferencedCallback(); + const warn = useWarnOnce(); const createPropsGetter = type => (...args) => { const inputOptions = parseInputArgs(args); @@ -69,8 +62,7 @@ export default function useFormState(initialState, options) { if (__DEV__) { if (isRaw) { warn( - key, - 'missingInitialValue', + `missingInitialValue.${key}`, `The initial value for input "${name}" is missing. Custom inputs ` + 'controlled with raw() are expected to have an initial value ' + 'provided to useFormState(). To prevent React from treating ' + @@ -124,8 +116,7 @@ export default function useFormState(initialState, options) { if (__DEV__) { if (isRaw && ![value, other].every(testIsEqualCompatibility)) { warn( - key, - 'missingCompare', + `missingCompare.${key}`, `You used a raw input type for "${name}" without providing a ` + 'custom compare method. As a result, the pristine value of ' + 'this input will be calculated using strict equality check ' + @@ -164,8 +155,7 @@ export default function useFormState(initialState, options) { error = e.target.validationMessage; } else if (__DEV__) { warn( - key, - 'missingValidate', + `missingValidate.${key}`, `You used a raw input type for "${name}" without providing a ` + 'custom validate method. As a result, validation of this input ' + 'will be set to "true" automatically. If you need to validate ' + @@ -243,7 +233,7 @@ export default function useFormState(initialState, options) { return hasValueInState ? formState.current.values[name] : ''; }, - onChange: callbacks.getOrSet(`onChange.${key}`, e => { + onChange: referencedCallback(`onChange.${key}`, e => { setDirty(name, true); let value; if (isRaw) { @@ -256,8 +246,7 @@ export default function useFormState(initialState, options) { /* istanbul ignore else */ if (__DEV__) { warn( - key, - 'onChangeUndefined', + `onChangeUndefined.${key}`, `You used a raw input type for "${name}" with an onChange() ` + 'option without returning a value. The onChange callback ' + 'of raw inputs, when provided, is used to determine the ' + @@ -296,7 +285,7 @@ export default function useFormState(initialState, options) { formState.setValues(partialNewState); }), - onBlur: callbacks.getOrSet(`onBlur.${key}`, e => { + onBlur: referencedCallback(`onBlur.${key}`, e => { touch(e); inputOptions.onBlur(e); diff --git a/src/useState.js b/src/useState.js index 5d06b5b..027b7b2 100644 --- a/src/useState.js +++ b/src/useState.js @@ -1,6 +1,6 @@ import { useReducer, useRef } from 'react'; import { isFunction, isEqual } from './utils'; -import { useCache } from './useCache'; +import { useMap } from './utils-hooks'; function stateReducer(state, newState) { return isFunction(newState) ? newState(state) : { ...state, ...newState }; @@ -8,8 +8,8 @@ function stateReducer(state, newState) { export function useState({ initialState }) { const state = useRef(); - const initialValues = useCache(); - const comparators = useCache(); + const initialValues = useMap(); + const comparators = useMap(); const [values, setValues] = useReducer(stateReducer, initialState || {}); const [touched, setTouched] = useReducer(stateReducer, {}); const [validity, setValidity] = useReducer(stateReducer, {}); diff --git a/src/utils-hooks.js b/src/utils-hooks.js new file mode 100644 index 0000000..9c39963 --- /dev/null +++ b/src/utils-hooks.js @@ -0,0 +1,32 @@ +import { useRef } from 'react'; + +export function useMap() { + const map = useRef(new Map()); + return { + set: (key, value) => map.current.set(key, value), + has: key => map.current.has(key), + get: key => map.current.get(key), + }; +} + +export function useReferencedCallback() { + const callbacks = useMap(); + return (key, current) => { + if (!callbacks.has(key)) { + const callback = (...args) => callback.current(...args); + callbacks.set(key, callback); + } + callbacks.get(key).current = current; + return callbacks.get(key); + }; +} + +export function useWarnOnce() { + const didWarnRef = useRef(new Set()); + return (key, message) => { + if (!didWarnRef.current.has(key)) { + didWarnRef.current.add(key); + console.warn('[useFormState]', message); + } + }; +} diff --git a/test/test-utils.js b/test/test-utils.js index 2d9eec2..486a745 100644 --- a/test/test-utils.js +++ b/test/test-utils.js @@ -4,6 +4,8 @@ import { useFormState } from '../src'; export { renderHook } from 'react-hooks-testing-library'; +export { render as renderElement, fireEvent }; + export const InputTypes = { textLike: ['text', 'email', 'password', 'search', 'tel', 'url'], time: ['date', 'month', 'time', 'week'], diff --git a/test/useFormState-input.test.js b/test/useFormState-input.test.js index 1d715ff..1abb588 100644 --- a/test/useFormState-input.test.js +++ b/test/useFormState-input.test.js @@ -1,6 +1,12 @@ -import React from 'react'; +import React, { useState } from 'react'; import { useFormState } from '../src'; -import { renderWithFormState, renderHook, InputTypes } from './test-utils'; +import { + InputTypes, + fireEvent, + renderHook, + renderElement, + renderWithFormState, +} from './test-utils'; describe('input type methods return correct props object', () => { /** @@ -553,4 +559,34 @@ describe('Input props are memoized', () => { change({ value: 'c' }, root.childNodes[1]); expect(renderCheck).toHaveBeenCalledTimes(2); }); + + it('prevents callbacks from using stale closures', () => { + const onInputChange = jest.fn(); + + function ComponentWithInternalState() { + const [state, setState] = useState(1); + const [, { text }] = useFormState(null, { + onBlur: () => { + onInputChange(state); + setState(state + 1); + }, + onChange: () => { + onInputChange(state); + setState(state + 1); + }, + }); + return ; + } + + const { container } = renderElement(); + + fireEvent.change(container.firstChild, { target: { value: 'foo' } }); + expect(onInputChange).toHaveBeenLastCalledWith(1); + + fireEvent.change(container.firstChild, { target: { value: 'bar' } }); + expect(onInputChange).toHaveBeenLastCalledWith(2); + + fireEvent.blur(container.firstChild); + expect(onInputChange).toHaveBeenLastCalledWith(3); + }); });