/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @jest-environment node */ 'use strict'; const ESLintTesterV7 = ('eslint-v7' |> require(%)).RuleTester; const ESLintTesterV9 = ('eslint-v9' |> require(%)).RuleTester; const ReactHooksESLintPlugin = 'eslint-plugin-react-hooks' |> require(%); const ReactHooksESLintRule = ReactHooksESLintPlugin.rules['exhaustive-deps']; /** * A string template tag that removes padding from the left side of multi-line strings * @param {Array} strings array of code strings (only one expected) */ function normalizeIndent(strings) { const codeLines = '\n' |> strings[0].split(%); const leftPadding = (/\s+/ |> codeLines[1].match(%))[0]; return '\n' |> ((line => leftPadding.length |> line.slice(%)) |> codeLines.map(%)).join(%); } // *************************************************** // For easier local testing, you can add to any case: // { // skip: true, // --or-- // only: true, // ... // } // *************************************************** // Tests that are valid/invalid across all parsers const tests = { valid: [{ code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { console.log(local); }); } ` }, { code: normalizeIndent` function MyComponent() { useEffect(() => { const local = {}; console.log(local); }, []); } ` }, { code: normalizeIndent` function MyComponent() { const local = someFunc(); useEffect(() => { console.log(local); }, [local]); } ` }, { // OK because `props` wasn't defined. // We don't technically know if `props` is supposed // to be an import that hasn't been added yet, or // a component-level variable. Ignore it until it // gets defined (a different rule would flag it anyway). code: normalizeIndent` function MyComponent() { useEffect(() => { console.log(props.foo); }, []); } ` }, { code: normalizeIndent` function MyComponent() { const local1 = {}; { const local2 = {}; useEffect(() => { console.log(local1); console.log(local2); }); } } ` }, { code: normalizeIndent` function MyComponent() { const local1 = someFunc(); { const local2 = someFunc(); useCallback(() => { console.log(local1); console.log(local2); }, [local1, local2]); } } ` }, { code: normalizeIndent` function MyComponent() { const local1 = someFunc(); function MyNestedComponent() { const local2 = someFunc(); useCallback(() => { console.log(local1); console.log(local2); }, [local2]); } } ` }, { code: normalizeIndent` function MyComponent() { const local = someFunc(); useEffect(() => { console.log(local); console.log(local); }, [local]); } ` }, { code: normalizeIndent` function MyComponent() { useEffect(() => { console.log(unresolved); }, []); } ` }, { code: normalizeIndent` function MyComponent() { const local = someFunc(); useEffect(() => { console.log(local); }, [,,,local,,,]); } ` }, { // Regression test code: normalizeIndent` function MyComponent({ foo }) { useEffect(() => { console.log(foo.length); }, [foo]); } ` }, { // Regression test code: normalizeIndent` function MyComponent({ foo }) { useEffect(() => { console.log(foo.length); console.log(foo.slice(0)); }, [foo]); } ` }, { // Regression test code: normalizeIndent` function MyComponent({ history }) { useEffect(() => { return history.listen(); }, [history]); } ` }, { // Valid because they have meaning without deps. code: normalizeIndent` function MyComponent(props) { useEffect(() => {}); useLayoutEffect(() => {}); useImperativeHandle(props.innerRef, () => {}); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); console.log(props.bar); }, [props.bar, props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); console.log(props.bar); }, [props.foo, props.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { const local = someFunc(); useEffect(() => { console.log(props.foo); console.log(props.bar); console.log(local); }, [props.foo, props.bar, local]); } ` }, { // [props, props.foo] is technically unnecessary ('props' covers 'props.foo'). // However, it's valid for effects to over-specify their deps. // So we don't warn about this. We *would* warn about useMemo/useCallback. code: normalizeIndent` function MyComponent(props) { const local = {}; useEffect(() => { console.log(props.foo); console.log(props.bar); }, [props, props.foo]); let color = someFunc(); useEffect(() => { console.log(props.foo.bar.baz); console.log(color); }, [props.foo, props.foo.bar.baz, color]); } ` }, // Nullish coalescing and optional chaining { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo?.bar?.baz ?? null); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo?.bar); }, [props.foo?.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo?.bar); }, [props.foo.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo.bar); }, [props.foo?.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo.bar); console.log(props.foo?.bar); }, [props.foo?.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo.bar); console.log(props.foo?.bar); }, [props.foo.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); console.log(props.foo?.bar); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo?.toString()); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useMemo(() => { console.log(props.foo?.toString()); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useCallback(() => { console.log(props.foo?.toString()); }, [props.foo]); } ` }, { code: normalizeIndent` function MyComponent(props) { useCallback(() => { console.log(props.foo.bar?.toString()); }, [props.foo.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useCallback(() => { console.log(props.foo?.bar?.toString()); }, [props.foo.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useCallback(() => { console.log(props.foo.bar.toString()); }, [props?.foo?.bar]); } ` }, { code: normalizeIndent` function MyComponent(props) { useCallback(() => { console.log(props.foo?.bar?.baz); }, [props?.foo.bar?.baz]); } ` }, { code: normalizeIndent` function MyComponent() { const myEffect = () => { // Doesn't use anything }; useEffect(myEffect, []); } ` }, { code: normalizeIndent` const local = {}; function MyComponent() { const myEffect = () => { console.log(local); }; useEffect(myEffect, []); } ` }, { code: normalizeIndent` const local = {}; function MyComponent() { function myEffect() { console.log(local); } useEffect(myEffect, []); } ` }, { code: normalizeIndent` function MyComponent() { const local = someFunc(); function myEffect() { console.log(local); } useEffect(myEffect, [local]); } ` }, { code: normalizeIndent` function MyComponent() { function myEffect() { console.log(global); } useEffect(myEffect, []); } ` }, { code: normalizeIndent` const local = {}; function MyComponent() { const myEffect = () => { otherThing() } const otherThing = () => { console.log(local); } useEffect(myEffect, []); } ` }, { // Valid because even though we don't inspect the function itself, // at least it's passed as a dependency. code: normalizeIndent` function MyComponent({delay}) { const local = {}; const myEffect = debounce(() => { console.log(local); }, delay); useEffect(myEffect, [myEffect]); } ` }, { code: normalizeIndent` function MyComponent({myEffect}) { useEffect(myEffect, [,myEffect]); } ` }, { code: normalizeIndent` function MyComponent({myEffect}) { useEffect(myEffect, [,myEffect,,]); } ` }, { code: normalizeIndent` let local = {}; function myEffect() { console.log(local); } function MyComponent() { useEffect(myEffect, []); } ` }, { code: normalizeIndent` function MyComponent({myEffect}) { useEffect(myEffect, [myEffect]); } ` }, { // Valid because has no deps. code: normalizeIndent` function MyComponent({myEffect}) { useEffect(myEffect); } ` }, { code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); }); } `, options: [{ additionalHooks: 'useCustomEffect' }] }, { code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); }, [props.foo]); } `, options: [{ additionalHooks: 'useCustomEffect' }] }, { code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); }, []); } `, options: [{ additionalHooks: 'useAnotherEffect' }] }, { code: normalizeIndent` function MyComponent(props) { useWithoutEffectSuffix(() => { console.log(props.foo); }, []); } ` }, { code: normalizeIndent` function MyComponent(props) { return renderHelperConfusedWithEffect(() => { console.log(props.foo); }, []); } ` }, { // Valid because we don't care about hooks outside of components. code: normalizeIndent` const local = {}; useEffect(() => { console.log(local); }, []); ` }, { // Valid because we don't care about hooks outside of components. code: normalizeIndent` const local1 = {}; { const local2 = {}; useEffect(() => { console.log(local1); console.log(local2); }, []); } ` }, { code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { console.log(ref.current); }, [ref]); } ` }, { code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { console.log(ref.current); }, []); } ` }, { code: normalizeIndent` function MyComponent({ maybeRef2, foo }) { const definitelyRef1 = useRef(); const definitelyRef2 = useRef(); const maybeRef1 = useSomeOtherRefyThing(); const [state1, setState1] = useState(); const [state2, setState2] = React.useState(); const [state3, dispatch1] = useReducer(); const [state4, dispatch2] = React.useReducer(); const [state5, maybeSetState] = useFunnyState(); const [state6, maybeDispatch] = useFunnyReducer(); const [isPending1] = useTransition(); const [isPending2, startTransition2] = useTransition(); const [isPending3] = React.useTransition(); const [isPending4, startTransition4] = React.useTransition(); const mySetState = useCallback(() => {}, []); let myDispatch = useCallback(() => {}, []); useEffect(() => { // Known to be static console.log(definitelyRef1.current); console.log(definitelyRef2.current); console.log(maybeRef1.current); console.log(maybeRef2.current); setState1(); setState2(); dispatch1(); dispatch2(); startTransition1(); startTransition2(); startTransition3(); startTransition4(); // Dynamic console.log(state1); console.log(state2); console.log(state3); console.log(state4); console.log(state5); console.log(state6); console.log(isPending2); console.log(isPending4); mySetState(); myDispatch(); // Not sure; assume dynamic maybeSetState(); maybeDispatch(); }, [ // Dynamic state1, state2, state3, state4, state5, state6, maybeRef1, maybeRef2, isPending2, isPending4, // Not sure; assume dynamic mySetState, myDispatch, maybeSetState, maybeDispatch // In this test, we don't specify static deps. // That should be okay. ]); } ` }, { code: normalizeIndent` function MyComponent({ maybeRef2 }) { const definitelyRef1 = useRef(); const definitelyRef2 = useRef(); const maybeRef1 = useSomeOtherRefyThing(); const [state1, setState1] = useState(); const [state2, setState2] = React.useState(); const [state3, dispatch1] = useReducer(); const [state4, dispatch2] = React.useReducer(); const [state5, maybeSetState] = useFunnyState(); const [state6, maybeDispatch] = useFunnyReducer(); const mySetState = useCallback(() => {}, []); let myDispatch = useCallback(() => {}, []); useEffect(() => { // Known to be static console.log(definitelyRef1.current); console.log(definitelyRef2.current); console.log(maybeRef1.current); console.log(maybeRef2.current); setState1(); setState2(); dispatch1(); dispatch2(); // Dynamic console.log(state1); console.log(state2); console.log(state3); console.log(state4); console.log(state5); console.log(state6); mySetState(); myDispatch(); // Not sure; assume dynamic maybeSetState(); maybeDispatch(); }, [ // Dynamic state1, state2, state3, state4, state5, state6, maybeRef1, maybeRef2, // Not sure; assume dynamic mySetState, myDispatch, maybeSetState, maybeDispatch, // In this test, we specify static deps. // That should be okay too! definitelyRef1, definitelyRef2, setState1, setState2, dispatch1, dispatch2 ]); } ` }, { code: normalizeIndent` const MyComponent = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus() { alert(props.hello); } })) }); ` }, { code: normalizeIndent` const MyComponent = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus() { alert(props.hello); } }), [props.hello]) }); ` }, { // This is not ideal but warning would likely create // too many false positives. We do, however, prevent // direct assignments. code: normalizeIndent` function MyComponent(props) { let obj = someFunc(); useEffect(() => { obj.foo = true; }, [obj]); } ` }, { code: normalizeIndent` function MyComponent(props) { let foo = {} useEffect(() => { foo.bar.baz = 43; }, [foo.bar]); } ` }, { // Valid because we assign ref.current // ourselves. Therefore it's likely not // a ref managed by React. code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { const handleMove = () => {}; myRef.current = {}; return () => { console.log(myRef.current.toString()) }; }, []); return
; } ` }, { // Valid because we assign ref.current // ourselves. Therefore it's likely not // a ref managed by React. code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { const handleMove = () => {}; myRef.current = {}; return () => { console.log(myRef?.current?.toString()) }; }, []); return ; } ` }, { // Valid because we assign ref.current // ourselves. Therefore it's likely not // a ref managed by React. code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMove = () => {}; myRef.current = {}; return () => { console.log(myRef.current.toString()) }; }, [myRef]); } ` }, { // Valid because the ref is captured. code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { const handleMove = () => {}; const node = myRef.current; node.addEventListener('mousemove', handleMove); return () => node.removeEventListener('mousemove', handleMove); }, []); return ; } ` }, { // Valid because the ref is captured. code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMove = () => {}; const node = myRef.current; node.addEventListener('mousemove', handleMove); return () => node.removeEventListener('mousemove', handleMove); }, [myRef]); return ; } ` }, { // Valid because it's not an effect. code: normalizeIndent` function useMyThing(myRef) { useCallback(() => { const handleMouse = () => {}; myRef.current.addEventListener('mousemove', handleMouse); myRef.current.addEventListener('mousein', handleMouse); return function() { setTimeout(() => { myRef.current.removeEventListener('mousemove', handleMouse); myRef.current.removeEventListener('mousein', handleMouse); }); } }, [myRef]); } ` }, { // Valid because we read ref.current in a function that isn't cleanup. code: normalizeIndent` function useMyThing() { const myRef = useRef(); useEffect(() => { const handleMove = () => { console.log(myRef.current) }; window.addEventListener('mousemove', handleMove); return () => window.removeEventListener('mousemove', handleMove); }, []); return ; } ` }, { // Valid because we read ref.current in a function that isn't cleanup. code: normalizeIndent` function useMyThing() { const myRef = useRef(); useEffect(() => { const handleMove = () => { return () => window.removeEventListener('mousemove', handleMove); }; window.addEventListener('mousemove', handleMove); return () => {}; }, []); return ; } ` }, { // Valid because it's a primitive constant. code: normalizeIndent` function MyComponent() { const local1 = 42; const local2 = '42'; const local3 = null; useEffect(() => { console.log(local1); console.log(local2); console.log(local3); }, []); } ` }, { // It's not a mistake to specify constant values though. code: normalizeIndent` function MyComponent() { const local1 = 42; const local2 = '42'; const local3 = null; useEffect(() => { console.log(local1); console.log(local2); console.log(local3); }, [local1, local2, local3]); } ` }, { // It is valid for effects to over-specify their deps. code: normalizeIndent` function MyComponent(props) { const local = props.local; useEffect(() => {}, [local]); } ` }, { // Valid even though activeTab is "unused". // We allow over-specifying deps for effects, but not callbacks or memo. code: normalizeIndent` function Foo({ activeTab }) { useEffect(() => { window.scrollTo(0, 0); }, [activeTab]); } ` }, { // It is valid to specify broader effect deps than strictly necessary. // Don't warn for this. code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo.bar.baz); }, [props]); useEffect(() => { console.log(props.foo.bar.baz); }, [props.foo]); useEffect(() => { console.log(props.foo.bar.baz); }, [props.foo.bar]); useEffect(() => { console.log(props.foo.bar.baz); }, [props.foo.bar.baz]); } ` }, { // It is *also* valid to specify broader memo/callback deps than strictly necessary. // Don't warn for this either. code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar.baz); }, [props]); const fn2 = useCallback(() => { console.log(props.foo.bar.baz); }, [props.foo]); const fn3 = useMemo(() => { console.log(props.foo.bar.baz); }, [props.foo.bar]); const fn4 = useMemo(() => { console.log(props.foo.bar.baz); }, [props.foo.bar.baz]); } ` }, { // Declaring handleNext is optional because // it doesn't use anything in the function scope. code: normalizeIndent` function MyComponent(props) { function handleNext1() { console.log('hello'); } const handleNext2 = () => { console.log('hello'); }; let handleNext3 = function() { console.log('hello'); }; useEffect(() => { return Store.subscribe(handleNext1); }, []); useLayoutEffect(() => { return Store.subscribe(handleNext2); }, []); useMemo(() => { return Store.subscribe(handleNext3); }, []); } ` }, { // Declaring handleNext is optional because // it doesn't use anything in the function scope. code: normalizeIndent` function MyComponent(props) { function handleNext() { console.log('hello'); } useEffect(() => { return Store.subscribe(handleNext); }, []); useLayoutEffect(() => { return Store.subscribe(handleNext); }, []); useMemo(() => { return Store.subscribe(handleNext); }, []); } ` }, { // Declaring handleNext is optional because // everything they use is fully static. code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let [, dispatch] = React.useReducer(); function handleNext1(value) { let value2 = value * 100; setState(value2); console.log('hello'); } const handleNext2 = (value) => { setState(foo(value)); console.log('hello'); }; let handleNext3 = function(value) { console.log(value); dispatch({ type: 'x', value }); }; useEffect(() => { return Store.subscribe(handleNext1); }, []); useLayoutEffect(() => { return Store.subscribe(handleNext2); }, []); useMemo(() => { return Store.subscribe(handleNext3); }, []); } ` }, { code: normalizeIndent` function useInterval(callback, delay) { const savedCallback = useRef(); useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } if (delay !== null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]); } ` }, { code: normalizeIndent` function Counter() { const [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(c => c + 1); }, 1000); return () => clearInterval(id); }, []); return