diff --git a/debug/src/debug.js b/debug/src/debug.js index 749288053d..58a6e40ef0 100644 --- a/debug/src/debug.js +++ b/debug/src/debug.js @@ -357,6 +357,27 @@ export function initDebug() { keys.push(key); } } + + if (vnode._component != null && vnode._component.__hooks != null) { + // Validate that none of the hooks in this component contain arguments that are NaN. + // This is a common mistake that can be hard to debug, so we want to catch it early. + const hooks = vnode._component.__hooks._list; + if (hooks) { + for (let i = 0; i < hooks.length; i += 1) { + const hook = hooks[i]; + if (hook._args) { + for (const arg of hook._args) { + if (Number.isNaN(arg)) { + const componentName = getDisplayName(vnode); + throw new Error( + `Invalid argument passed to hook. Hooks should not be called with NaN in the dependency array. Hook index ${i} in component ${componentName} was called with NaN.` + ); + } + } + } + } + } + } }; } diff --git a/debug/test/browser/validateHookArgs.test.js b/debug/test/browser/validateHookArgs.test.js new file mode 100644 index 0000000000..19f7cb7fd3 --- /dev/null +++ b/debug/test/browser/validateHookArgs.test.js @@ -0,0 +1,74 @@ +import { createElement, render, createRef } from 'preact'; +import { + useState, + useEffect, + useLayoutEffect, + useCallback, + useMemo, + useImperativeHandle +} from 'preact/hooks'; +import { setupRerender } from 'preact/test-utils'; +import { setupScratch, teardown } from '../../../test/_util/helpers'; +import 'preact/debug'; + +/** @jsx createElement */ + +describe('Hook argument validation', () => { + /** + * @param {string} name + * @param {(arg: number) => void} hook + */ + function validateHook(name, hook) { + const TestComponent = ({ initialValue }) => { + const [value, setValue] = useState(initialValue); + hook(value); + + return ( + + ); + }; + + it(`should error if ${name} is mounted with NaN as an argument`, async () => { + expect(() => + render(, scratch) + ).to.throw(/Hooks should not be called with NaN in the dependency array/); + }); + + it(`should error if ${name} is updated with NaN as an argument`, async () => { + render(, scratch); + + expect(() => { + scratch.querySelector('button').click(); + rerender(); + }).to.throw( + /Hooks should not be called with NaN in the dependency array/ + ); + }); + } + + /** @type {HTMLElement} */ + let scratch; + /** @type {() => void} */ + let rerender; + + beforeEach(() => { + scratch = setupScratch(); + rerender = setupRerender(); + }); + + afterEach(() => { + teardown(scratch); + }); + + validateHook('useEffect', arg => useEffect(() => {}, [arg])); + validateHook('useLayoutEffect', arg => useLayoutEffect(() => {}, [arg])); + validateHook('useCallback', arg => useCallback(() => {}, [arg])); + validateHook('useMemo', arg => useMemo(() => {}, [arg])); + + const ref = createRef(); + validateHook('useImperativeHandle', arg => { + useImperativeHandle(ref, () => undefined, [arg]); + }); +});