From 93a229bab59f94e214256582c55d3b6c1fc2b958 Mon Sep 17 00:00:00 2001 From: Will Douglas Date: Mon, 17 Feb 2020 13:24:27 -0700 Subject: [PATCH 01/24] Update eslint rule exhaustive deps to use new suggestions feature (#17385) This closes #16313 --- .../ESLintRuleExhaustiveDeps-test.js | 5304 ++++++++++------- .../src/ExhaustiveDeps.js | 83 +- 2 files changed, 3335 insertions(+), 2052 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index aa25e13ba5121..d45728aedb0fc 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -19,6 +19,16 @@ ESLintTester.setDefaultConfig({ }, }); +/** + * 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 = strings[0].split('\n'); + const leftPadding = codeLines[1].match(/\s+/)[0]; + return codeLines.map(line => line.substr(leftPadding.length)).join('\n'); +} + // *************************************************** // For easier local testing, you can add to any case: // { @@ -32,7 +42,7 @@ ESLintTester.setDefaultConfig({ const tests = { valid: [ { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -42,7 +52,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { useEffect(() => { const local = {}; @@ -52,7 +62,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -67,7 +77,7 @@ const tests = { // 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: ` + code: normalizeIndent` function MyComponent() { useEffect(() => { console.log(props.foo); @@ -76,7 +86,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; { @@ -90,7 +100,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; { @@ -104,7 +114,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; function MyNestedComponent() { @@ -118,7 +128,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -129,7 +139,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { useEffect(() => { console.log(unresolved); @@ -138,7 +148,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -149,7 +159,7 @@ const tests = { }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent({ foo }) { useEffect(() => { console.log(foo.length); @@ -159,7 +169,7 @@ const tests = { }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent({ foo }) { useEffect(() => { console.log(foo.length); @@ -170,7 +180,7 @@ const tests = { }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent({ history }) { useEffect(() => { return history.listen(); @@ -180,7 +190,7 @@ const tests = { }, { // Valid because they have meaning without deps. - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => {}); useLayoutEffect(() => {}); @@ -189,7 +199,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); @@ -198,7 +208,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); @@ -208,7 +218,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); @@ -218,7 +228,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useEffect(() => { @@ -233,7 +243,7 @@ const tests = { // [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: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useEffect(() => { @@ -250,7 +260,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); @@ -260,7 +270,7 @@ const tests = { options: [{additionalHooks: 'useCustomEffect'}], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); @@ -270,7 +280,7 @@ const tests = { options: [{additionalHooks: 'useCustomEffect'}], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); @@ -281,7 +291,7 @@ const tests = { }, { // Valid because we don't care about hooks outside of components. - code: ` + code: normalizeIndent` const local = {}; useEffect(() => { console.log(local); @@ -290,7 +300,7 @@ const tests = { }, { // Valid because we don't care about hooks outside of components. - code: ` + code: normalizeIndent` const local1 = {}; { const local2 = {}; @@ -302,7 +312,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { @@ -312,7 +322,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { @@ -322,7 +332,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent({ maybeRef2, foo }) { const definitelyRef1 = useRef(); const definitelyRef2 = useRef(); @@ -376,7 +386,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function MyComponent({ maybeRef2 }) { const definitelyRef1 = useRef(); const definitelyRef2 = useRef(); @@ -434,7 +444,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` const MyComponent = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus() { @@ -445,7 +455,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` const MyComponent = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus() { @@ -459,7 +469,7 @@ const tests = { // This is not ideal but warning would likely create // too many false positives. We do, however, prevent // direct assignments. - code: ` + code: normalizeIndent` function MyComponent(props) { let obj = {}; useEffect(() => { @@ -472,7 +482,7 @@ const tests = { // Valid because we assign ref.current // ourselves. Therefore it's likely not // a ref managed by React. - code: ` + code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { @@ -490,7 +500,7 @@ const tests = { // Valid because we assign ref.current // ourselves. Therefore it's likely not // a ref managed by React. - code: ` + code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMove = () => {}; @@ -504,7 +514,7 @@ const tests = { }, { // Valid because the ref is captured. - code: ` + code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { @@ -519,7 +529,7 @@ const tests = { }, { // Valid because the ref is captured. - code: ` + code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMove = () => {}; @@ -533,7 +543,7 @@ const tests = { }, { // Valid because it's not an effect. - code: ` + code: normalizeIndent` function useMyThing(myRef) { useCallback(() => { const handleMouse = () => {}; @@ -551,7 +561,7 @@ const tests = { }, { // Valid because we read ref.current in a function that isn't cleanup. - code: ` + code: normalizeIndent` function useMyThing() { const myRef = useRef(); useEffect(() => { @@ -567,7 +577,7 @@ const tests = { }, { // Valid because we read ref.current in a function that isn't cleanup. - code: ` + code: normalizeIndent` function useMyThing() { const myRef = useRef(); useEffect(() => { @@ -583,7 +593,7 @@ const tests = { }, { // Valid because it's a primitive constant. - code: ` + code: normalizeIndent` function MyComponent() { const local1 = 42; const local2 = '42'; @@ -598,7 +608,7 @@ const tests = { }, { // It's not a mistake to specify constant values though. - code: ` + code: normalizeIndent` function MyComponent() { const local1 = 42; const local2 = '42'; @@ -613,7 +623,7 @@ const tests = { }, { // It is valid for effects to over-specify their deps. - code: ` + code: normalizeIndent` function MyComponent(props) { const local = props.local; useEffect(() => {}, [local]); @@ -623,7 +633,7 @@ const tests = { { // Valid even though activeTab is "unused". // We allow over-specifying deps for effects, but not callbacks or memo. - code: ` + code: normalizeIndent` function Foo({ activeTab }) { useEffect(() => { window.scrollTo(0, 0); @@ -634,7 +644,7 @@ const tests = { { // It is valid to specify broader effect deps than strictly necessary. // Don't warn for this. - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo.bar.baz); @@ -654,7 +664,7 @@ const tests = { { // It is *also* valid to specify broader memo/callback deps than strictly necessary. // Don't warn for this either. - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar.baz); @@ -674,7 +684,7 @@ const tests = { { // Declaring handleNext is optional because // it doesn't use anything in the function scope. - code: ` + code: normalizeIndent` function MyComponent(props) { function handleNext1() { console.log('hello'); @@ -700,7 +710,7 @@ const tests = { { // Declaring handleNext is optional because // it doesn't use anything in the function scope. - code: ` + code: normalizeIndent` function MyComponent(props) { function handleNext() { console.log('hello'); @@ -720,7 +730,7 @@ const tests = { { // Declaring handleNext is optional because // everything they use is fully static. - code: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let [, dispatch] = React.useReducer(); @@ -751,7 +761,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function useInterval(callback, delay) { const savedCallback = useRef(); useEffect(() => { @@ -770,7 +780,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { const [count, setCount] = useState(0); @@ -786,7 +796,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { const [count, setCount] = useState(0); @@ -806,7 +816,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { const [count, dispatch] = useReducer((state, action) => { if (action === 'inc') { @@ -826,7 +836,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { const [count, dispatch] = useReducer((state, action) => { if (action === 'inc') { @@ -849,7 +859,7 @@ const tests = { }, { // Regression test for a crash - code: ` + code: normalizeIndent` function Podcasts() { useEffect(() => { setPodcasts([]); @@ -859,7 +869,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function withFetch(fetchPodcasts) { return function Podcasts({ id }) { let [podcasts, setPodcasts] = useState(null); @@ -871,7 +881,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Podcasts({ id }) { let [podcasts, setPodcasts] = useState(null); useEffect(() => { @@ -884,7 +894,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); @@ -904,7 +914,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); @@ -924,7 +934,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` import increment from './increment'; function Counter() { let [count, setCount] = useState(0); @@ -941,7 +951,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function withStuff(increment) { return function Counter() { let [count, setCount] = useState(0); @@ -959,7 +969,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function App() { const [query, setQuery] = useState('react'); const [state, setState] = useState(null); @@ -982,7 +992,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Example() { const foo = useCallback(() => { foo(); @@ -991,7 +1001,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Example({ prop }) { const foo = useCallback(() => { if (prop) { @@ -1002,7 +1012,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Hello() { const [state, setState] = useState(0); useEffect(() => { @@ -1015,7 +1025,7 @@ const tests = { }, // Ignore Generic Type Variables for arrow functions { - code: ` + code: normalizeIndent` function Example({ prop }) { const bar = useEffect((a: T): Hello => { prop(); @@ -1025,7 +1035,7 @@ const tests = { }, // Ignore arguments keyword for arrow functions. { - code: ` + code: normalizeIndent` function Example() { useEffect(() => { arguments @@ -1034,7 +1044,7 @@ const tests = { `, }, { - code: ` + code: normalizeIndent` function Example() { useEffect(() => { const bar = () => { @@ -1048,7 +1058,7 @@ const tests = { ], invalid: [ { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1056,24 +1066,32 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { // Note: we *could* detect it's a primitive and never assigned // even though it's not a constant -- but we currently don't. // So this is an error. - code: ` + code: normalizeIndent` function MyComponent() { let local = 42; useEffect(() => { @@ -1081,22 +1099,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - let local = 42; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + let local = 42; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { // Regexes are literals but potentially stateful. - code: ` + code: normalizeIndent` function MyComponent() { const local = /foo/; useEffect(() => { @@ -1104,44 +1130,54 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = /foo/; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = /foo/; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { // Invalid because they don't have a meaning without deps. - code: ` + code: normalizeIndent` function MyComponent(props) { const value = useMemo(() => { return 2*2; }); const fn = useCallback(() => { alert('foo'); }); } `, // We don't know what you meant. - output: ` - function MyComponent(props) { - const value = useMemo(() => { return 2*2; }); - const fn = useCallback(() => { alert('foo'); }); - } - `, errors: [ - 'React Hook useMemo does nothing when called with only one argument. ' + - 'Did you forget to pass an array of dependencies?', - 'React Hook useCallback does nothing when called with only one argument. ' + - 'Did you forget to pass an array of dependencies?', + { + message: + 'React Hook useMemo does nothing when called with only one argument. ' + + 'Did you forget to pass an array of dependencies?', + suggestions: undefined, + }, + { + message: + 'React Hook useCallback does nothing when called with only one argument. ' + + 'Did you forget to pass an array of dependencies?', + suggestions: undefined, + }, ], }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1151,24 +1187,32 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - if (true) { - console.log(local); - } - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + if (true) { + console.log(local); + } + }, [local]); + } + `, + }, + ], + }, ], }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1178,24 +1222,32 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - try { - console.log(local); - } finally {} - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + try { + console.log(local); + } finally {} + }, [local]); + } + `, + }, + ], + }, ], }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1206,24 +1258,32 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - function inner() { - console.log(local); - } - inner(); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + function inner() { + console.log(local); + } + inner(); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; { @@ -1235,25 +1295,33 @@ const tests = { } } `, - output: ` - function MyComponent() { - const local1 = {}; - { - const local2 = {}; - useEffect(() => { - console.log(local1); - console.log(local2); - }, [local1, local2]); - } - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'local1' and 'local2'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'local1' and 'local2'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local1, local2]', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + { + const local2 = {}; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local1, local2]); + } + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; const local2 = {}; @@ -1263,23 +1331,31 @@ const tests = { }, [local1]); } `, - output: ` - function MyComponent() { - const local1 = {}; - const local2 = {}; - useEffect(() => { - console.log(local1); - console.log(local2); - }, [local1, local2]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local2'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local2'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local1, local2]', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + const local2 = {}; + useEffect(() => { + console.log(local1); + console.log(local2); + }, [local1, local2]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; const local2 = {}; @@ -1288,22 +1364,30 @@ const tests = { }, [local1, local2]); } `, - output: ` - function MyComponent() { - const local1 = {}; - const local2 = {}; - useMemo(() => { - console.log(local1); - }, [local1]); - } - `, errors: [ - "React Hook useMemo has an unnecessary dependency: 'local2'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useMemo has an unnecessary dependency: 'local2'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local1]', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + const local2 = {}; + useMemo(() => { + console.log(local1); + }, [local1]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; function MyNestedComponent() { @@ -1315,27 +1399,35 @@ const tests = { } } `, - output: ` - function MyComponent() { - const local1 = {}; - function MyNestedComponent() { - const local2 = {}; - useCallback(() => { - console.log(local1); - console.log(local2); - }, [local2]); - } - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'local2'. " + - 'Either include it or remove the dependency array. ' + - "Outer scope values like 'local1' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useCallback has a missing dependency: 'local2'. " + + 'Either include it or remove the dependency array. ' + + "Outer scope values like 'local1' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [local2]', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + function MyNestedComponent() { + const local2 = {}; + useCallback(() => { + console.log(local1); + console.log(local2); + }, [local2]); + } + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1344,22 +1436,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1368,80 +1468,112 @@ const tests = { }, [local, local]); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a duplicate dependency: 'local'. " + - 'Either omit it or remove the dependency array.', + { + message: + "React Hook useEffect has a duplicate dependency: 'local'. " + + 'Either omit it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { useCallback(() => {}, [window]); } `, - output: ` - function MyComponent() { - useCallback(() => {}, []); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'window'. " + - 'Either exclude it or remove the dependency array. ' + - "Outer scope values like 'window' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useCallback has an unnecessary dependency: 'window'. " + + 'Either exclude it or remove the dependency array. ' + + "Outer scope values like 'window' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent() { + useCallback(() => {}, []); + } + `, + }, + ], + }, ], }, { // It is not valid for useCallback to specify extraneous deps // because it doesn't serve as a side effect trigger unlike useEffect. - code: ` + code: normalizeIndent` function MyComponent(props) { let local = props.foo; useCallback(() => {}, [local]); } `, - output: ` - function MyComponent(props) { - let local = props.foo; - useCallback(() => {}, []); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'local'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'local'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent(props) { + let local = props.foo; + useCallback(() => {}, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ history }) { useEffect(() => { return history.listen(); }, []); } `, - output: ` - function MyComponent({ history }) { - useEffect(() => { - return history.listen(); - }, [history]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'history'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'history'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [history]', + output: normalizeIndent` + function MyComponent({ history }) { + useEffect(() => { + return history.listen(); + }, [history]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ history }) { useEffect(() => { return [ @@ -1451,107 +1583,148 @@ const tests = { }, []); } `, - output: ` - function MyComponent({ history }) { - useEffect(() => { - return [ - history.foo.bar[2].dobedo.listen(), - history.foo.bar().dobedo.listen[2] - ]; - }, [history.foo]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'history.foo'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'history.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [history.foo]', + output: normalizeIndent` + function MyComponent({ history }) { + useEffect(() => { + return [ + history.foo.bar[2].dobedo.listen(), + history.foo.bar().dobedo.listen[2] + ]; + }, [history.foo]); + } + `, + }, + ], + }, ], }, { - code: ` - function MyComponent() { - useEffect(() => {}, ['foo']); - } - `, - // TODO: we could autofix this. - output: ` + code: normalizeIndent` function MyComponent() { useEffect(() => {}, ['foo']); } `, errors: [ - // Don't assume user meant `foo` because it's not used in the effect. - "The 'foo' literal is not a valid dependency because it never changes. " + - 'You can safely remove it.', + { + message: + // Don't assume user meant `foo` because it's not used in the effect. + "The 'foo' literal is not a valid dependency because it never changes. " + + 'You can safely remove it.', + // TODO: provide suggestion. + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ foo, bar, baz }) { useEffect(() => { console.log(foo, bar, baz); }, ['foo', 'bar']); } `, - output: ` - function MyComponent({ foo, bar, baz }) { - useEffect(() => { - console.log(foo, bar, baz); - }, [bar, baz, foo]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'bar', 'baz', and 'foo'. " + - 'Either include them or remove the dependency array.', - "The 'foo' literal is not a valid dependency because it never changes. " + - 'Did you mean to include foo in the array instead?', - "The 'bar' literal is not a valid dependency because it never changes. " + - 'Did you mean to include bar in the array instead?', + { + message: + "React Hook useEffect has missing dependencies: 'bar', 'baz', and 'foo'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [bar, baz, foo]', + output: normalizeIndent` + function MyComponent({ foo, bar, baz }) { + useEffect(() => { + console.log(foo, bar, baz); + }, [bar, baz, foo]); + } + `, + }, + ], + }, + { + message: + "The 'foo' literal is not a valid dependency because it never changes. " + + 'Did you mean to include foo in the array instead?', + suggestions: undefined, + }, + { + message: + "The 'bar' literal is not a valid dependency because it never changes. " + + 'Did you mean to include bar in the array instead?', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ foo, bar, baz }) { useEffect(() => { console.log(foo, bar, baz); }, [42, false, null]); } `, - output: ` - function MyComponent({ foo, bar, baz }) { - useEffect(() => { - console.log(foo, bar, baz); - }, [bar, baz, foo]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'bar', 'baz', and 'foo'. " + - 'Either include them or remove the dependency array.', - 'The 42 literal is not a valid dependency because it never changes. You can safely remove it.', - 'The false literal is not a valid dependency because it never changes. You can safely remove it.', - 'The null literal is not a valid dependency because it never changes. You can safely remove it.', + { + message: + "React Hook useEffect has missing dependencies: 'bar', 'baz', and 'foo'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [bar, baz, foo]', + output: normalizeIndent` + function MyComponent({ foo, bar, baz }) { + useEffect(() => { + console.log(foo, bar, baz); + }, [bar, baz, foo]); + } + `, + }, + ], + }, + { + message: + 'The 42 literal is not a valid dependency because it never changes. You can safely remove it.', + suggestions: undefined, + }, + { + message: + 'The false literal is not a valid dependency because it never changes. You can safely remove it.', + suggestions: undefined, + }, + { + message: + 'The null literal is not a valid dependency because it never changes. You can safely remove it.', + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent() { - const dependencies = []; - useEffect(() => {}, dependencies); - } - `, - output: ` + code: normalizeIndent` function MyComponent() { const dependencies = []; useEffect(() => {}, dependencies); } `, errors: [ - 'React Hook useEffect was passed a dependency list that is not an ' + - "array literal. This means we can't statically verify whether you've " + - 'passed the correct dependencies.', + { + message: + 'React Hook useEffect was passed a dependency list that is not an ' + + "array literal. This means we can't statically verify whether you've " + + 'passed the correct dependencies.', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; const dependencies = [local]; @@ -1560,26 +1733,38 @@ const tests = { }, dependencies); } `, - // TODO: should this autofix or bail out? - output: ` - function MyComponent() { - const local = {}; - const dependencies = [local]; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - 'React Hook useEffect was passed a dependency list that is not an ' + - "array literal. This means we can't statically verify whether you've " + - 'passed the correct dependencies.', - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + 'React Hook useEffect was passed a dependency list that is not an ' + + "array literal. This means we can't statically verify whether you've " + + 'passed the correct dependencies.', + // TODO: should this autofix or bail out? + suggestions: undefined, + }, + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + const dependencies = [local]; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; const dependencies = [local]; @@ -1588,34 +1773,38 @@ const tests = { }, [...dependencies]); } `, - // TODO: should this autofix or bail out? - output: ` - function MyComponent() { - const local = {}; - const dependencies = [local]; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a spread element in its dependency array. ' + - "This means we can't statically verify whether you've passed the " + - 'correct dependencies.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + const dependencies = [local]; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a spread element in its dependency array. ' + + "This means we can't statically verify whether you've passed the " + + 'correct dependencies.', + // TODO: should this autofix or bail out? + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local, ...dependencies]); - } - `, - output: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1624,13 +1813,17 @@ const tests = { } `, errors: [ - 'React Hook useEffect has a spread element in its dependency array. ' + - "This means we can't statically verify whether you've passed the " + - 'correct dependencies.', + { + message: + 'React Hook useEffect has a spread element in its dependency array. ' + + "This means we can't statically verify whether you've passed the " + + 'correct dependencies.', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -1638,55 +1831,71 @@ const tests = { }, [computeCacheKey(local)]); } `, - // TODO: I'm not sure this is a good idea. - // Maybe bail out? - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + // TODO: I'm not sure this is a good idea. + // Maybe bail out? + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.items[0]); }, [props.items[0]]); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - console.log(props.items[0]); - }, [props.items]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props.items'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + "React Hook useEffect has a missing dependency: 'props.items'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.items]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.items[0]); + }, [props.items]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent(props) { - useEffect(() => { - console.log(props.items[0]); - }, [props.items, props.items[0]]); - } - `, - // TODO: ideally autofix would remove the bad expression? - output: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.items[0]); @@ -1694,42 +1903,51 @@ const tests = { } `, errors: [ - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + // TODO: ideally suggestion would remove the bad expression? + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ items }) { useEffect(() => { console.log(items[0]); }, [items[0]]); } `, - output: ` - function MyComponent({ items }) { - useEffect(() => { - console.log(items[0]); - }, [items]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'items'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + "React Hook useEffect has a missing dependency: 'items'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [items]', + output: normalizeIndent` + function MyComponent({ items }) { + useEffect(() => { + console.log(items[0]); + }, [items]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent({ items }) { - useEffect(() => { - console.log(items[0]); - }, [items, items[0]]); - } - `, - // TODO: ideally autofix would remove the bad expression? - output: ` + code: normalizeIndent` function MyComponent({ items }) { useEffect(() => { console.log(items[0]); @@ -1737,8 +1955,13 @@ const tests = { } `, errors: [ - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + // TODO: ideally suggeston would remove the bad expression? + suggestions: undefined, + }, ], }, { @@ -1747,7 +1970,7 @@ const tests = { // However, we generally allow specifying *broader* deps as escape hatch. // So while [props, props.foo] is unnecessary, 'props' wins here as the // broader one, and this is why 'props.foo' is reported as unnecessary. - code: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useCallback(() => { @@ -1756,23 +1979,31 @@ const tests = { }, [props, props.foo]); } `, - output: ` - function MyComponent(props) { - const local = {}; - useCallback(() => { - console.log(props.foo); - console.log(props.bar); - }, [props]); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'props.foo'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'props.foo'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + const local = {}; + useCallback(() => { + console.log(props.foo); + console.log(props.bar); + }, [props]); + } + `, + }, + ], + }, ], }, { // Since we don't have 'props' in the list, we'll suggest narrow dependencies. - code: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useCallback(() => { @@ -1781,24 +2012,33 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - const local = {}; - useCallback(() => { - console.log(props.foo); - console.log(props.bar); - }, [props.bar, props.foo]); - } - `, errors: [ - "React Hook useCallback has missing dependencies: 'props.bar' and 'props.foo'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useCallback has missing dependencies: 'props.bar' and 'props.foo'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [props.bar, props.foo]', + output: normalizeIndent` + function MyComponent(props) { + const local = {}; + useCallback(() => { + console.log(props.foo); + console.log(props.bar); + }, [props.bar, props.foo]); + } + `, + }, + ], + }, ], }, { // Effects are allowed to over-specify deps. We'll complain about missing // 'local', but we won't remove the already-specified 'local.id' from your list. - code: ` + code: normalizeIndent` function MyComponent() { const local = {id: 42}; useEffect(() => { @@ -1806,23 +2046,31 @@ const tests = { }, [local.id]); } `, - output: ` - function MyComponent() { - const local = {id: 42}; - useEffect(() => { - console.log(local); - }, [local, local.id]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local, local.id]', + output: normalizeIndent` + function MyComponent() { + const local = {id: 42}; + useEffect(() => { + console.log(local); + }, [local, local.id]); + } + `, + }, + ], + }, ], }, { // Callbacks are not allowed to over-specify deps. So we'll complain about missing // 'local' and we will also *remove* 'local.id' from your list. - code: ` + code: normalizeIndent` function MyComponent() { const local = {id: 42}; const fn = useCallback(() => { @@ -1830,23 +2078,31 @@ const tests = { }, [local.id]); } `, - output: ` - function MyComponent() { - const local = {id: 42}; - const fn = useCallback(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {id: 42}; + const fn = useCallback(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { // Callbacks are not allowed to over-specify deps. So we'll complain about // the unnecessary 'local.id'. - code: ` + code: normalizeIndent` function MyComponent() { const local = {id: 42}; const fn = useCallback(() => { @@ -1854,41 +2110,57 @@ const tests = { }, [local.id, local]); } `, - output: ` - function MyComponent() { - const local = {id: 42}; - const fn = useCallback(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'local.id'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'local.id'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {id: 42}; + const fn = useCallback(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar.baz); }, []); } `, - output: ` - function MyComponent(props) { - const fn = useCallback(() => { - console.log(props.foo.bar.baz); - }, [props.foo.bar.baz]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'props.foo.bar.baz'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'props.foo.bar.baz'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo.bar.baz]', + output: normalizeIndent` + function MyComponent(props) { + const fn = useCallback(() => { + console.log(props.foo.bar.baz); + }, [props.foo.bar.baz]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let color = {} const fn = useCallback(() => { @@ -1897,18 +2169,27 @@ const tests = { }, [props.foo, props.foo.bar.baz]); } `, - output: ` - function MyComponent(props) { - let color = {} - const fn = useCallback(() => { - console.log(props.foo.bar.baz); - console.log(color); - }, [color, props.foo.bar.baz]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'color'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'color'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [color, props.foo.bar.baz]', + output: normalizeIndent` + function MyComponent(props) { + let color = {} + const fn = useCallback(() => { + console.log(props.foo.bar.baz); + console.log(color); + }, [color, props.foo.bar.baz]); + } + `, + }, + ], + }, ], }, { @@ -1917,27 +2198,35 @@ const tests = { // So in this case we ask you to remove 'props.foo.bar.baz' because 'props.foo' // already covers it, and having both is unnecessary. // TODO: maybe consider suggesting a narrower one by default in these cases. - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar.baz); }, [props.foo.bar.baz, props.foo]); } `, - output: ` - function MyComponent(props) { - const fn = useCallback(() => { - console.log(props.foo.bar.baz); - }, [props.foo]); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'props.foo.bar.baz'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'props.foo.bar.baz'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + const fn = useCallback(() => { + console.log(props.foo.bar.baz); + }, [props.foo]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar.baz); @@ -1945,17 +2234,26 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - const fn = useCallback(() => { - console.log(props.foo.bar.baz); - console.log(props.foo.fizz.bizz); - }, [props.foo.bar.baz, props.foo.fizz.bizz]); - } - `, errors: [ - "React Hook useCallback has missing dependencies: 'props.foo.bar.baz' and 'props.foo.fizz.bizz'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useCallback has missing dependencies: 'props.foo.bar.baz' and 'props.foo.fizz.bizz'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [props.foo.bar.baz, props.foo.fizz.bizz]', + output: normalizeIndent` + function MyComponent(props) { + const fn = useCallback(() => { + console.log(props.foo.bar.baz); + console.log(props.foo.fizz.bizz); + }, [props.foo.bar.baz, props.foo.fizz.bizz]); + } + `, + }, + ], + }, ], }, { @@ -1965,27 +2263,35 @@ const tests = { // When we're sure there is a mistake, for callbacks we will rebuild the list // from scratch. This will set the user on a better path by default. // This is why we end up with just 'props.foo.bar', and not them both. - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props.foo.bar); }, [props.foo.bar.baz]); } `, - output: ` - function MyComponent(props) { - const fn = useCallback(() => { - console.log(props.foo.bar); - }, [props.foo.bar]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'props.foo.bar'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'props.foo.bar'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo.bar]', + output: normalizeIndent` + function MyComponent(props) { + const fn = useCallback(() => { + console.log(props.foo.bar); + }, [props.foo.bar]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const fn = useCallback(() => { console.log(props); @@ -1993,21 +2299,29 @@ const tests = { }, [props.foo.bar.baz]); } `, - output: ` - function MyComponent(props) { - const fn = useCallback(() => { - console.log(props); - console.log(props.hello); - }, [props]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + const fn = useCallback(() => { + console.log(props); + console.log(props.hello); + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -2015,21 +2329,29 @@ const tests = { }, [local, local]); } `, - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a duplicate dependency: 'local'. " + - 'Either omit it or remove the dependency array.', + { + message: + "React Hook useEffect has a duplicate dependency: 'local'. " + + 'Either omit it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; useCallback(() => { @@ -2038,60 +2360,84 @@ const tests = { }, [local1]); } `, - output: ` - function MyComponent() { - const local1 = {}; - useCallback(() => { - const local1 = {}; - console.log(local1); - }, []); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'local1'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'local1'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + useCallback(() => { + const local1 = {}; + console.log(local1); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local1 = {}; useCallback(() => {}, [local1]); } `, - output: ` - function MyComponent() { - const local1 = {}; - useCallback(() => {}, []); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'local1'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'local1'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent() { + const local1 = {}; + useCallback(() => {}, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - console.log(props.foo); - }, [props.foo]); - } - `, - errors: [ - "React Hook useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - ], - }, - { - code: ` + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); @@ -2099,21 +2445,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - console.log(props.foo); - console.log(props.bar); - }, [props.bar, props.foo]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'props.bar' and 'props.foo'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'props.bar' and 'props.foo'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [props.bar, props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + }, [props.bar, props.foo]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let a, b, c, d, e, f, g; useEffect(() => { @@ -2121,22 +2476,31 @@ const tests = { }, [c, a, g]); } `, - // Don't alphabetize if it wasn't alphabetized in the first place. - output: ` - function MyComponent(props) { - let a, b, c, d, e, f, g; - useEffect(() => { - console.log(b, e, d, c, a, g, f); - }, [c, a, g, b, e, d, f]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " + + 'Either include them or remove the dependency array.', + // Don't alphabetize if it wasn't alphabetized in the first place. + suggestions: [ + { + desc: + 'Update the dependencies array to be: [c, a, g, b, e, d, f]', + output: normalizeIndent` + function MyComponent(props) { + let a, b, c, d, e, f, g; + useEffect(() => { + console.log(b, e, d, c, a, g, f); + }, [c, a, g, b, e, d, f]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let a, b, c, d, e, f, g; useEffect(() => { @@ -2144,22 +2508,31 @@ const tests = { }, [a, c, g]); } `, - // Alphabetize if it was alphabetized. - output: ` - function MyComponent(props) { - let a, b, c, d, e, f, g; - useEffect(() => { - console.log(b, e, d, c, a, g, f); - }, [a, b, c, d, e, f, g]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'b', 'd', 'e', and 'f'. " + + 'Either include them or remove the dependency array.', + // Alphabetize if it was alphabetized. + suggestions: [ + { + desc: + 'Update the dependencies array to be: [a, b, c, d, e, f, g]', + output: normalizeIndent` + function MyComponent(props) { + let a, b, c, d, e, f, g; + useEffect(() => { + console.log(b, e, d, c, a, g, f); + }, [a, b, c, d, e, f, g]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let a, b, c, d, e, f, g; useEffect(() => { @@ -2167,22 +2540,31 @@ const tests = { }, []); } `, - // Alphabetize if it was empty. - output: ` - function MyComponent(props) { - let a, b, c, d, e, f, g; - useEffect(() => { - console.log(b, e, d, c, a, g, f); - }, [a, b, c, d, e, f, g]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'a', 'b', 'c', 'd', 'e', 'f', and 'g'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'a', 'b', 'c', 'd', 'e', 'f', and 'g'. " + + 'Either include them or remove the dependency array.', + // Alphabetize if it was empty. + suggestions: [ + { + desc: + 'Update the dependencies array to be: [a, b, c, d, e, f, g]', + output: normalizeIndent` + function MyComponent(props) { + let a, b, c, d, e, f, g; + useEffect(() => { + console.log(b, e, d, c, a, g, f); + }, [a, b, c, d, e, f, g]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useEffect(() => { @@ -2192,23 +2574,32 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - const local = {}; - useEffect(() => { - console.log(props.foo); - console.log(props.bar); - console.log(local); - }, [local, props.bar, props.foo]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'local', 'props.bar', and 'props.foo'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'local', 'props.bar', and 'props.foo'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [local, props.bar, props.foo]', + output: normalizeIndent` + function MyComponent(props) { + const local = {}; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + console.log(local); + }, [local, props.bar, props.foo]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const local = {}; useEffect(() => { @@ -2218,23 +2609,31 @@ const tests = { }, [props]); } `, - output: ` - function MyComponent(props) { - const local = {}; - useEffect(() => { - console.log(props.foo); - console.log(props.bar); - console.log(local); - }, [local, props]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [local, props]', + output: normalizeIndent` + function MyComponent(props) { + const local = {}; + useEffect(() => { + console.log(props.foo); + console.log(props.bar); + console.log(local); + }, [local, props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { console.log(props.foo); @@ -2259,48 +2658,221 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - console.log(props.foo); - }, [props.foo]); - useCallback(() => { - console.log(props.foo); - }, [props.foo]); - useMemo(() => { - console.log(props.foo); - }, [props.foo]); - React.useEffect(() => { - console.log(props.foo); - }, [props.foo]); - React.useCallback(() => { - console.log(props.foo); - }, [props.foo]); - React.useMemo(() => { - console.log(props.foo); - }, [props.foo]); - React.notReactiveHook(() => { - console.log(props.foo); - }, []); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook useCallback has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook useMemo has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook React.useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook React.useCallback has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook React.useMemo has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, [props.foo]); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useCallback has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, [props.foo]); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useMemo has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, [props.foo]); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook React.useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, [props.foo]); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook React.useCallback has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, [props.foo]); + React.useMemo(() => { + console.log(props.foo); + }, []); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook React.useMemo has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + console.log(props.foo); + }, []); + useCallback(() => { + console.log(props.foo); + }, []); + useMemo(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCallback(() => { + console.log(props.foo); + }, []); + React.useMemo(() => { + console.log(props.foo); + }, [props.foo]); + React.notReactiveHook(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useCustomEffect(() => { console.log(props.foo); @@ -2316,34 +2888,90 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useCustomEffect(() => { - console.log(props.foo); - }, [props.foo]); - useEffect(() => { - console.log(props.foo); - }, [props.foo]); - React.useEffect(() => { - console.log(props.foo); - }, [props.foo]); - React.useCustomEffect(() => { - console.log(props.foo); - }, []); - } - `, options: [{additionalHooks: 'useCustomEffect'}], errors: [ - "React Hook useCustomEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', - "React Hook React.useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCustomEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, [props.foo]); + useEffect(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, []); + useEffect(() => { + console.log(props.foo); + }, [props.foo]); + React.useEffect(() => { + console.log(props.foo); + }, []); + React.useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook React.useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useCustomEffect(() => { + console.log(props.foo); + }, []); + useEffect(() => { + console.log(props.foo); + }, []); + React.useEffect(() => { + console.log(props.foo); + }, [props.foo]); + React.useCustomEffect(() => { + console.log(props.foo); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -2351,24 +2979,36 @@ const tests = { }, [a ? local : b]); } `, - // TODO: should we bail out instead? - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + // TODO: should we bail out instead? + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const local = {}; useEffect(() => { @@ -2376,24 +3016,36 @@ const tests = { }, [a && local]); } `, - // TODO: should we bail out instead? - output: ` - function MyComponent() { - const local = {}; - useEffect(() => { - console.log(local); - }, [local]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'local'. " + - 'Either include it or remove the dependency array.', - 'React Hook useEffect has a complex expression in the dependency array. ' + - 'Extract it to a separate variable so it can be statically checked.', + { + message: + "React Hook useEffect has a missing dependency: 'local'. " + + 'Either include it or remove the dependency array.', + // TODO: should we bail out instead? + suggestions: [ + { + desc: 'Update the dependencies array to be: [local]', + output: normalizeIndent` + function MyComponent() { + const local = {}; + useEffect(() => { + console.log(local); + }, [local]); + } + `, + }, + ], + }, + { + message: + 'React Hook useEffect has a complex expression in the dependency array. ' + + 'Extract it to a separate variable so it can be statically checked.', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); const [state, setState] = useState(); @@ -2403,25 +3055,33 @@ const tests = { }, []); } `, - output: ` - function MyComponent() { - const ref = useRef(); - const [state, setState] = useState(); - useEffect(() => { - ref.current = {}; - setState(state + 1); - }, [state]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'state'. " + - 'Either include it or remove the dependency array. ' + - `You can also do a functional update 'setState(s => ...)' ` + - `if you only need 'state' in the 'setState' call.`, + { + message: + "React Hook useEffect has a missing dependency: 'state'. " + + 'Either include it or remove the dependency array. ' + + `You can also do a functional update 'setState(s => ...)' ` + + `if you only need 'state' in the 'setState' call.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [state]', + output: normalizeIndent` + function MyComponent() { + const ref = useRef(); + const [state, setState] = useState(); + useEffect(() => { + ref.current = {}; + setState(state + 1); + }, [state]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); const [state, setState] = useState(); @@ -2431,28 +3091,36 @@ const tests = { }, [ref]); } `, - // We don't ask to remove static deps but don't add them either. - // Don't suggest removing "ref" (it's fine either way) - // but *do* add "state". *Don't* add "setState" ourselves. - output: ` - function MyComponent() { - const ref = useRef(); - const [state, setState] = useState(); - useEffect(() => { - ref.current = {}; - setState(state + 1); - }, [ref, state]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'state'. " + - 'Either include it or remove the dependency array. ' + - `You can also do a functional update 'setState(s => ...)' ` + - `if you only need 'state' in the 'setState' call.`, + { + message: + "React Hook useEffect has a missing dependency: 'state'. " + + 'Either include it or remove the dependency array. ' + + `You can also do a functional update 'setState(s => ...)' ` + + `if you only need 'state' in the 'setState' call.`, + // We don't ask to remove static deps but don't add them either. + // Don't suggest removing "ref" (it's fine either way) + // but *do* add "state". *Don't* add "setState" ourselves. + suggestions: [ + { + desc: 'Update the dependencies array to be: [ref, state]', + output: normalizeIndent` + function MyComponent() { + const ref = useRef(); + const [state, setState] = useState(); + useEffect(() => { + ref.current = {}; + setState(state + 1); + }, [ref, state]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const ref1 = useRef(); const ref2 = useRef(); @@ -2464,25 +3132,34 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - const ref1 = useRef(); - const ref2 = useRef(); - useEffect(() => { - ref1.current.focus(); - console.log(ref2.current.textContent); - alert(props.someOtherRefs.current.innerHTML); - fetch(props.color); - }, [props.color, props.someOtherRefs]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'props.color' and 'props.someOtherRefs'. " + - 'Either include them or remove the dependency array.', + { + message: + "React Hook useEffect has missing dependencies: 'props.color' and 'props.someOtherRefs'. " + + 'Either include them or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [props.color, props.someOtherRefs]', + output: normalizeIndent` + function MyComponent(props) { + const ref1 = useRef(); + const ref2 = useRef(); + useEffect(() => { + ref1.current.focus(); + console.log(ref2.current.textContent); + alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [props.color, props.someOtherRefs]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const ref1 = useRef(); const ref2 = useRef(); @@ -2494,27 +3171,36 @@ const tests = { }, [ref1.current, ref2.current, props.someOtherRefs, props.color]); } `, - output: ` - function MyComponent(props) { - const ref1 = useRef(); - const ref2 = useRef(); - useEffect(() => { - ref1.current.focus(); - console.log(ref2.current.textContent); - alert(props.someOtherRefs.current.innerHTML); - fetch(props.color); - }, [props.someOtherRefs, props.color]); - } - `, errors: [ - "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " + - 'Either exclude them or remove the dependency array. ' + - "Mutable values like 'ref1.current' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " + + 'Either exclude them or remove the dependency array. ' + + "Mutable values like 'ref1.current' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: + 'Update the dependencies array to be: [props.someOtherRefs, props.color]', + output: normalizeIndent` + function MyComponent(props) { + const ref1 = useRef(); + const ref2 = useRef(); + useEffect(() => { + ref1.current.focus(); + console.log(ref2.current.textContent); + alert(props.someOtherRefs.current.innerHTML); + fetch(props.color); + }, [props.someOtherRefs, props.color]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { @@ -2522,23 +3208,31 @@ const tests = { }, [ref.current]); } `, - output: ` - function MyComponent() { - const ref = useRef(); - useEffect(() => { - console.log(ref.current); - }, []); - } - `, errors: [ - "React Hook useEffect has an unnecessary dependency: 'ref.current'. " + - 'Either exclude it or remove the dependency array. ' + - "Mutable values like 'ref.current' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has an unnecessary dependency: 'ref.current'. " + + 'Either exclude it or remove the dependency array. ' + + "Mutable values like 'ref.current' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent() { + const ref = useRef(); + useEffect(() => { + console.log(ref.current); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ activeTab }) { const ref1 = useRef(); const ref2 = useRef(); @@ -2548,25 +3242,33 @@ const tests = { }, [ref1.current, ref2.current, activeTab]); } `, - output: ` - function MyComponent({ activeTab }) { - const ref1 = useRef(); - const ref2 = useRef(); - useEffect(() => { - ref1.current.scrollTop = 0; - ref2.current.scrollTop = 0; - }, [activeTab]); - } - `, errors: [ - "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " + - 'Either exclude them or remove the dependency array. ' + - "Mutable values like 'ref1.current' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has unnecessary dependencies: 'ref1.current' and 'ref2.current'. " + + 'Either exclude them or remove the dependency array. ' + + "Mutable values like 'ref1.current' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [activeTab]', + output: normalizeIndent` + function MyComponent({ activeTab }) { + const ref1 = useRef(); + const ref2 = useRef(); + useEffect(() => { + ref1.current.scrollTop = 0; + ref2.current.scrollTop = 0; + }, [activeTab]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent({ activeTab, initY }) { const ref1 = useRef(); const ref2 = useRef(); @@ -2576,25 +3278,33 @@ const tests = { }, [ref1.current, ref2.current, activeTab, initY]); } `, - output: ` - function MyComponent({ activeTab, initY }) { - const ref1 = useRef(); - const ref2 = useRef(); - const fn = useCallback(() => { - ref1.current.scrollTop = initY; - ref2.current.scrollTop = initY; - }, [initY]); - } - `, errors: [ - "React Hook useCallback has unnecessary dependencies: 'activeTab', 'ref1.current', and 'ref2.current'. " + - 'Either exclude them or remove the dependency array. ' + - "Mutable values like 'ref1.current' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useCallback has unnecessary dependencies: 'activeTab', 'ref1.current', and 'ref2.current'. " + + 'Either exclude them or remove the dependency array. ' + + "Mutable values like 'ref1.current' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [initY]', + output: normalizeIndent` + function MyComponent({ activeTab, initY }) { + const ref1 = useRef(); + const ref2 = useRef(); + const fn = useCallback(() => { + ref1.current.scrollTop = initY; + ref2.current.scrollTop = initY; + }, [initY]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { const ref = useRef(); useEffect(() => { @@ -2602,23 +3312,31 @@ const tests = { }, [ref.current, ref]); } `, - output: ` - function MyComponent() { - const ref = useRef(); - useEffect(() => { - console.log(ref.current); - }, [ref]); - } - `, errors: [ - "React Hook useEffect has an unnecessary dependency: 'ref.current'. " + - 'Either exclude it or remove the dependency array. ' + - "Mutable values like 'ref.current' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has an unnecessary dependency: 'ref.current'. " + + 'Either exclude it or remove the dependency array. ' + + "Mutable values like 'ref.current' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [ref]', + output: normalizeIndent` + function MyComponent() { + const ref = useRef(); + useEffect(() => { + console.log(ref.current); + }, [ref]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` const MyComponent = forwardRef((props, ref) => { useImperativeHandle(ref, () => ({ focus() { @@ -2627,22 +3345,30 @@ const tests = { }), []) }); `, - output: ` - const MyComponent = forwardRef((props, ref) => { - useImperativeHandle(ref, () => ({ - focus() { - alert(props.hello); - } - }), [props.hello]) - }); - `, errors: [ - "React Hook useImperativeHandle has a missing dependency: 'props.hello'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useImperativeHandle has a missing dependency: 'props.hello'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.hello]', + output: normalizeIndent` + const MyComponent = forwardRef((props, ref) => { + useImperativeHandle(ref, () => ({ + focus() { + alert(props.hello); + } + }), [props.hello]) + }); + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { if (props.onChange) { @@ -2651,29 +3377,37 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - if (props.onChange) { - props.onChange(); - } - }, [props]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array. ' + - `However, 'props' will change when *any* prop changes, so the ` + - `preferred fix is to destructure the 'props' object outside ` + - `of the useEffect call and refer to those specific ` + - `props inside useEffect.`, + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array. ' + + `However, 'props' will change when *any* prop changes, so the ` + + `preferred fix is to destructure the 'props' object outside ` + + `of the useEffect call and refer to those specific ` + + `props inside useEffect.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + if (props.onChange) { + props.onChange(); + } + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { - function play() { + function play() { props.onPlay(); } function pause() { @@ -2682,29 +3416,37 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - function play() { - props.onPlay(); - } - function pause() { - props.onPause(); - } - }, [props]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array. ' + - `However, 'props' will change when *any* prop changes, so the ` + - `preferred fix is to destructure the 'props' object outside ` + - `of the useEffect call and refer to those specific ` + - `props inside useEffect.`, + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array. ' + + `However, 'props' will change when *any* prop changes, so the ` + + `preferred fix is to destructure the 'props' object outside ` + + `of the useEffect call and refer to those specific ` + + `props inside useEffect.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + function play() { + props.onPlay(); + } + function pause() { + props.onPause(); + } + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { if (props.foo.onChange) { @@ -2713,22 +3455,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - if (props.foo.onChange) { - props.foo.onChange(); - } - }, [props.foo]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props.foo'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'props.foo'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + if (props.foo.onChange) { + props.foo.onChange(); + } + }, [props.foo]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { props.onChange(); @@ -2738,27 +3488,35 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - props.onChange(); - if (props.foo.onChange) { - props.foo.onChange(); - } - }, [props]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array. ' + - `However, 'props' will change when *any* prop changes, so the ` + - `preferred fix is to destructure the 'props' object outside ` + - `of the useEffect call and refer to those specific ` + - `props inside useEffect.`, + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array. ' + + `However, 'props' will change when *any* prop changes, so the ` + + `preferred fix is to destructure the 'props' object outside ` + + `of the useEffect call and refer to those specific ` + + `props inside useEffect.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + props.onChange(); + if (props.foo.onChange) { + props.foo.onChange(); + } + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const [skillsCount] = useState(); useEffect(() => { @@ -2768,27 +3526,36 @@ const tests = { }, [skillsCount, props.isEditMode, props.toggleEditMode]); } `, - output: ` - function MyComponent(props) { - const [skillsCount] = useState(); - useEffect(() => { - if (skillsCount === 0 && !props.isEditMode) { - props.toggleEditMode(); - } - }, [skillsCount, props.isEditMode, props.toggleEditMode, props]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array. ' + - `However, 'props' will change when *any* prop changes, so the ` + - `preferred fix is to destructure the 'props' object outside ` + - `of the useEffect call and refer to those specific ` + - `props inside useEffect.`, + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array. ' + + `However, 'props' will change when *any* prop changes, so the ` + + `preferred fix is to destructure the 'props' object outside ` + + `of the useEffect call and refer to those specific ` + + `props inside useEffect.`, + suggestions: [ + { + desc: + 'Update the dependencies array to be: [skillsCount, props.isEditMode, props.toggleEditMode, props]', + output: normalizeIndent` + function MyComponent(props) { + const [skillsCount] = useState(); + useEffect(() => { + if (skillsCount === 0 && !props.isEditMode) { + props.toggleEditMode(); + } + }, [skillsCount, props.isEditMode, props.toggleEditMode, props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const [skillsCount] = useState(); useEffect(() => { @@ -2798,27 +3565,35 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - const [skillsCount] = useState(); - useEffect(() => { - if (skillsCount === 0 && !props.isEditMode) { - props.toggleEditMode(); - } - }, [props, skillsCount]); - } - `, errors: [ - "React Hook useEffect has missing dependencies: 'props' and 'skillsCount'. " + - 'Either include them or remove the dependency array. ' + - `However, 'props' will change when *any* prop changes, so the ` + - `preferred fix is to destructure the 'props' object outside ` + - `of the useEffect call and refer to those specific ` + - `props inside useEffect.`, + { + message: + "React Hook useEffect has missing dependencies: 'props' and 'skillsCount'. " + + 'Either include them or remove the dependency array. ' + + `However, 'props' will change when *any* prop changes, so the ` + + `preferred fix is to destructure the 'props' object outside ` + + `of the useEffect call and refer to those specific ` + + `props inside useEffect.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [props, skillsCount]', + output: normalizeIndent` + function MyComponent(props) { + const [skillsCount] = useState(); + useEffect(() => { + if (skillsCount === 0 && !props.isEditMode) { + props.toggleEditMode(); + } + }, [props, skillsCount]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { externalCall(props); @@ -2826,22 +3601,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - externalCall(props); - props.onChange(); - }, [props]); - } - `, // Don't suggest to destructure props here since you can't. errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + externalCall(props); + props.onChange(); + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { useEffect(() => { props.onChange(); @@ -2849,22 +3632,30 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - useEffect(() => { - props.onChange(); - externalCall(props); - }, [props]); - } - `, // Don't suggest to destructure props here since you can't. errors: [ - "React Hook useEffect has a missing dependency: 'props'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'props'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [props]', + output: normalizeIndent` + function MyComponent(props) { + useEffect(() => { + props.onChange(); + externalCall(props); + }, [props]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let value; let value2; @@ -2888,54 +3679,48 @@ const tests = { `, // This is a separate warning unrelated to others. // We could've made a separate rule for it but it's rare enough to name it. - // No autofix suggestion because the intent isn't clear. - output: ` - function MyComponent(props) { - let value; - let value2; - let value3; - let value4; - let asyncValue; - useEffect(() => { - if (value4) { - value = {}; - } - value2 = 100; - value = 43; - value4 = true; - console.log(value2); - console.log(value3); - setTimeout(() => { - asyncValue = 100; - }); - }, []); - } - `, + // No suggestions because the intent isn't clear. errors: [ - // value2 - `Assignments to the 'value2' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, - // value - `Assignments to the 'value' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, - // value4 - `Assignments to the 'value4' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, - // asyncValue - `Assignments to the 'asyncValue' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, + { + message: + // value2 + `Assignments to the 'value2' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, + { + message: + // value + `Assignments to the 'value' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, + { + message: + // value4 + `Assignments to the 'value4' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, + { + message: + // asyncValue + `Assignments to the 'asyncValue' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let value; let value2; @@ -2955,56 +3740,39 @@ const tests = { `, // This is a separate warning unrelated to others. // We could've made a separate rule for it but it's rare enough to name it. - // No autofix suggestion because the intent isn't clear. - output: ` - function MyComponent(props) { - let value; - let value2; - let value3; - let asyncValue; - useEffect(() => { - value = {}; - value2 = 100; - value = 43; - console.log(value2); - console.log(value3); - setTimeout(() => { - asyncValue = 100; - }); - }, [value, value2, value3]); - } - `, + // No suggestions because the intent isn't clear. errors: [ - // value - `Assignments to the 'value' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, - // value2 - `Assignments to the 'value2' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, - // asyncValue - `Assignments to the 'asyncValue' variable from inside React Hook useEffect ` + - `will be lost after each render. To preserve the value over time, ` + - `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + - `Otherwise, you can move this variable directly inside useEffect.`, + { + message: + // value + `Assignments to the 'value' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, + { + message: + // value2 + `Assignments to the 'value2' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, + { + message: + // asyncValue + `Assignments to the 'asyncValue' variable from inside React Hook useEffect ` + + `will be lost after each render. To preserve the value over time, ` + + `store it in a useRef Hook and keep the mutable value in the '.current' property. ` + + `Otherwise, you can move this variable directly inside useEffect.`, + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent() { - const myRef = useRef(); - useEffect(() => { - const handleMove = () => {}; - myRef.current.addEventListener('mousemove', handleMove); - return () => myRef.current.removeEventListener('mousemove', handleMove); - }, []); - return
; - } - `, - output: ` + code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { @@ -3016,25 +3784,18 @@ const tests = { } `, errors: [ - `The ref value 'myRef.current' will likely have changed by the time ` + - `this effect cleanup function runs. If this ref points to a node ` + - `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + - `and use that variable in the cleanup function.`, + { + message: + `The ref value 'myRef.current' will likely have changed by the time ` + + `this effect cleanup function runs. If this ref points to a node ` + + `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + + `and use that variable in the cleanup function.`, + suggestions: undefined, + }, ], }, { - code: ` - function MyComponent() { - const myRef = useRef(); - useEffect(() => { - const handleMove = () => {}; - myRef.current.addEventListener('mousemove', handleMove); - return () => myRef.current.removeEventListener('mousemove', handleMove); - }); - return
; - } - `, - output: ` + code: normalizeIndent` function MyComponent() { const myRef = useRef(); useEffect(() => { @@ -3046,23 +3807,18 @@ const tests = { } `, errors: [ - `The ref value 'myRef.current' will likely have changed by the time ` + - `this effect cleanup function runs. If this ref points to a node ` + - `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + - `and use that variable in the cleanup function.`, + { + message: + `The ref value 'myRef.current' will likely have changed by the time ` + + `this effect cleanup function runs. If this ref points to a node ` + + `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + + `and use that variable in the cleanup function.`, + suggestions: undefined, + }, ], }, { - code: ` - function useMyThing(myRef) { - useEffect(() => { - const handleMove = () => {}; - myRef.current.addEventListener('mousemove', handleMove); - return () => myRef.current.removeEventListener('mousemove', handleMove); - }, [myRef]); - } - `, - output: ` + code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMove = () => {}; @@ -3072,29 +3828,18 @@ const tests = { } `, errors: [ - `The ref value 'myRef.current' will likely have changed by the time ` + - `this effect cleanup function runs. If this ref points to a node ` + - `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + - `and use that variable in the cleanup function.`, + { + message: + `The ref value 'myRef.current' will likely have changed by the time ` + + `this effect cleanup function runs. If this ref points to a node ` + + `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + + `and use that variable in the cleanup function.`, + suggestions: undefined, + }, ], }, { - code: ` - function useMyThing(myRef) { - useEffect(() => { - 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]); - } - `, - output: ` + code: normalizeIndent` function useMyThing(myRef) { useEffect(() => { const handleMouse = () => {}; @@ -3110,29 +3855,18 @@ const tests = { } `, errors: [ - `The ref value 'myRef.current' will likely have changed by the time ` + - `this effect cleanup function runs. If this ref points to a node ` + - `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + - `and use that variable in the cleanup function.`, + { + message: + `The ref value 'myRef.current' will likely have changed by the time ` + + `this effect cleanup function runs. If this ref points to a node ` + + `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + + `and use that variable in the cleanup function.`, + suggestions: undefined, + }, ], }, { - code: ` - function useMyThing(myRef, active) { - useEffect(() => { - const handleMove = () => {}; - if (active) { - myRef.current.addEventListener('mousemove', handleMove); - return function() { - setTimeout(() => { - myRef.current.removeEventListener('mousemove', handleMove); - }); - } - } - }, [myRef, active]); - } - `, - output: ` + code: normalizeIndent` function useMyThing(myRef, active) { useEffect(() => { const handleMove = () => {}; @@ -3148,10 +3882,14 @@ const tests = { } `, errors: [ - `The ref value 'myRef.current' will likely have changed by the time ` + - `this effect cleanup function runs. If this ref points to a node ` + - `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + - `and use that variable in the cleanup function.`, + { + message: + `The ref value 'myRef.current' will likely have changed by the time ` + + `this effect cleanup function runs. If this ref points to a node ` + + `rendered by React, copy 'myRef.current' to a variable inside the effect, ` + + `and use that variable in the cleanup function.`, + suggestions: undefined, + }, ], }, { @@ -3187,41 +3925,50 @@ const tests = { }, { // Autofix ignores constant primitives (leaving the ones that are there). - code: ` - function MyComponent() { - const local1 = 42; - const local2 = '42'; - const local3 = null; - const local4 = {}; - useEffect(() => { - console.log(local1); - console.log(local2); - console.log(local3); - console.log(local4); - }, [local1, local3]); - } - `, - output: ` - function MyComponent() { - const local1 = 42; - const local2 = '42'; - const local3 = null; - const local4 = {}; - useEffect(() => { - console.log(local1); - console.log(local2); - console.log(local3); - console.log(local4); - }, [local1, local3, local4]); - } - `, + code: normalizeIndent` + function MyComponent() { + const local1 = 42; + const local2 = '42'; + const local3 = null; + const local4 = {}; + useEffect(() => { + console.log(local1); + console.log(local2); + console.log(local3); + console.log(local4); + }, [local1, local3]); + } + `, errors: [ - "React Hook useEffect has a missing dependency: 'local4'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'local4'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: + 'Update the dependencies array to be: [local1, local3, local4]', + output: normalizeIndent` + function MyComponent() { + const local1 = 42; + const local2 = '42'; + const local3 = null; + const local4 = {}; + useEffect(() => { + console.log(local1); + console.log(local2); + console.log(local3); + console.log(local4); + }, [local1, local3, local4]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent() { useEffect(() => { window.scrollTo(0, 0); @@ -3229,14 +3976,29 @@ const tests = { } `, errors: [ - "React Hook useEffect has an unnecessary dependency: 'window'. " + - 'Either exclude it or remove the dependency array. ' + - "Outer scope values like 'window' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has an unnecessary dependency: 'window'. " + + 'Either exclude it or remove the dependency array. ' + + "Outer scope values like 'window' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function MyComponent() { + useEffect(() => { + window.scrollTo(0, 0); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` import MutableStore from 'store'; function MyComponent() { @@ -3245,24 +4007,32 @@ const tests = { }, [MutableStore.hello]); } `, - output: ` - import MutableStore from 'store'; - - function MyComponent() { - useEffect(() => { - console.log(MutableStore.hello); - }, []); - } - `, errors: [ - "React Hook useEffect has an unnecessary dependency: 'MutableStore.hello'. " + - 'Either exclude it or remove the dependency array. ' + - "Outer scope values like 'MutableStore.hello' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + "React Hook useEffect has an unnecessary dependency: 'MutableStore.hello'. " + + 'Either exclude it or remove the dependency array. ' + + "Outer scope values like 'MutableStore.hello' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + import MutableStore from 'store'; + + function MyComponent() { + useEffect(() => { + console.log(MutableStore.hello); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` import MutableStore from 'store'; let z = {}; @@ -3276,30 +4046,38 @@ const tests = { } } `, - output: ` - import MutableStore from 'store'; - let z = {}; - - function MyComponent(props) { - let x = props.foo; - { - let y = props.bar; - useEffect(() => { - console.log(MutableStore.hello.world, props.foo, x, y, z, global.stuff); - }, [props.foo, x, y]); - } - } - `, - errors: [ - 'React Hook useEffect has unnecessary dependencies: ' + - "'MutableStore.hello.world', 'global.stuff', and 'z'. " + - 'Either exclude them or remove the dependency array. ' + - "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", - ], - }, - { - code: ` + errors: [ + { + message: + 'React Hook useEffect has unnecessary dependencies: ' + + "'MutableStore.hello.world', 'global.stuff', and 'z'. " + + 'Either exclude them or remove the dependency array. ' + + "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo, x, y]', + output: normalizeIndent` + import MutableStore from 'store'; + let z = {}; + + function MyComponent(props) { + let x = props.foo; + { + let y = props.bar; + useEffect(() => { + console.log(MutableStore.hello.world, props.foo, x, y, z, global.stuff); + }, [props.foo, x, y]); + } + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` import MutableStore from 'store'; let z = {}; @@ -3313,32 +4091,40 @@ const tests = { } } `, - // The output should contain the ones that are inside a component - // since there are legit reasons to over-specify them for effects. - output: ` - import MutableStore from 'store'; - let z = {}; - - function MyComponent(props) { - let x = props.foo; - { - let y = props.bar; - useEffect(() => { - // nothing - }, [props.foo, x, y]); - } - } - `, errors: [ - 'React Hook useEffect has unnecessary dependencies: ' + - "'MutableStore.hello.world', 'global.stuff', and 'z'. " + - 'Either exclude them or remove the dependency array. ' + - "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + 'React Hook useEffect has unnecessary dependencies: ' + + "'MutableStore.hello.world', 'global.stuff', and 'z'. " + + 'Either exclude them or remove the dependency array. ' + + "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + // The output should contain the ones that are inside a component + // since there are legit reasons to over-specify them for effects. + suggestions: [ + { + desc: 'Update the dependencies array to be: [props.foo, x, y]', + output: normalizeIndent` + import MutableStore from 'store'; + let z = {}; + + function MyComponent(props) { + let x = props.foo; + { + let y = props.bar; + useEffect(() => { + // nothing + }, [props.foo, x, y]); + } + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` import MutableStore from 'store'; let z = {}; @@ -3352,31 +4138,39 @@ const tests = { } } `, - output: ` - import MutableStore from 'store'; - let z = {}; - - function MyComponent(props) { - let x = props.foo; - { - let y = props.bar; - const fn = useCallback(() => { - // nothing - }, []); - } - } - `, errors: [ - 'React Hook useCallback has unnecessary dependencies: ' + - "'MutableStore.hello.world', 'global.stuff', 'props.foo', 'x', 'y', and 'z'. " + - 'Either exclude them or remove the dependency array. ' + - "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + - "because mutating them doesn't re-render the component.", + { + message: + 'React Hook useCallback has unnecessary dependencies: ' + + "'MutableStore.hello.world', 'global.stuff', 'props.foo', 'x', 'y', and 'z'. " + + 'Either exclude them or remove the dependency array. ' + + "Outer scope values like 'MutableStore.hello.world' aren't valid dependencies " + + "because mutating them doesn't re-render the component.", + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + import MutableStore from 'store'; + let z = {}; + + function MyComponent(props) { + let x = props.foo; + { + let y = props.bar; + const fn = useCallback(() => { + // nothing + }, []); + } + } + `, + }, + ], + }, ], }, { // Every almost-static function is tainted by a dynamic value. - code: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let [, dispatch] = React.useReducer(); @@ -3406,48 +4200,132 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - let [, setState] = useState(); - let [, dispatch] = React.useReducer(); - let taint = props.foo; - - function handleNext1(value) { - let value2 = value * taint; - setState(value2); - console.log('hello'); - } - const handleNext2 = (value) => { - setState(taint(value)); - console.log('hello'); - }; - let handleNext3 = function(value) { - setTimeout(() => console.log(taint)); - dispatch({ type: 'x', value }); - }; - useEffect(() => { - return Store.subscribe(handleNext1); - }, [handleNext1]); - useLayoutEffect(() => { - return Store.subscribe(handleNext2); - }, [handleNext2]); - useMemo(() => { - return Store.subscribe(handleNext3); - }, [handleNext3]); - } - `, errors: [ - "React Hook useEffect has a missing dependency: 'handleNext1'. " + - 'Either include it or remove the dependency array.', - "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + - 'Either include it or remove the dependency array.', - "React Hook useMemo has a missing dependency: 'handleNext3'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'handleNext1'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext1]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + setTimeout(() => console.log(taint)); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext2]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + setTimeout(() => console.log(taint)); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useMemo has a missing dependency: 'handleNext3'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext3]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + setTimeout(() => console.log(taint)); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, [handleNext3]); + } + `, + }, + ], + }, ], }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let [, dispatch] = React.useReducer(); @@ -3480,51 +4358,141 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - let [, setState] = useState(); - let [, dispatch] = React.useReducer(); - let taint = props.foo; + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'handleNext1'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext1]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; - // Shouldn't affect anything - function handleChange() {} + // Shouldn't affect anything + function handleChange() {} - function handleNext1(value) { - let value2 = value * taint; - setState(value2); - console.log('hello'); - } - const handleNext2 = (value) => { - setState(taint(value)); - console.log('hello'); - }; - let handleNext3 = function(value) { - console.log(taint); - dispatch({ type: 'x', value }); - }; - useEffect(() => { - return Store.subscribe(handleNext1); - }, [handleNext1]); - useLayoutEffect(() => { - return Store.subscribe(handleNext2); - }, [handleNext2]); - useMemo(() => { - return Store.subscribe(handleNext3); - }, [handleNext3]); - } - `, - errors: [ - "React Hook useEffect has a missing dependency: 'handleNext1'. " + - 'Either include it or remove the dependency array.', - "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + - 'Either include it or remove the dependency array.', - "React Hook useMemo has a missing dependency: 'handleNext3'. " + - 'Either include it or remove the dependency array.', + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext2]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + // Shouldn't affect anything + function handleChange() {} + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useMemo has a missing dependency: 'handleNext3'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext3]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + // Shouldn't affect anything + function handleChange() {} + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, [handleNext3]); + } + `, + }, + ], + }, ], }, { // Regression test - code: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let [, dispatch] = React.useReducer(); @@ -3557,67 +4525,140 @@ const tests = { }, []); } `, - output: ` - function MyComponent(props) { - let [, setState] = useState(); - let [, dispatch] = React.useReducer(); - let taint = props.foo; + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'handleNext1'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext1]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; - // Shouldn't affect anything - const handleChange = () => {}; + // Shouldn't affect anything + const handleChange = () => {}; - function handleNext1(value) { - let value2 = value * taint; - setState(value2); - console.log('hello'); - } - const handleNext2 = (value) => { - setState(taint(value)); - console.log('hello'); - }; - let handleNext3 = function(value) { - console.log(taint); - dispatch({ type: 'x', value }); - }; - useEffect(() => { - return Store.subscribe(handleNext1); - }, [handleNext1]); - useLayoutEffect(() => { - return Store.subscribe(handleNext2); - }, [handleNext2]); - useMemo(() => { - return Store.subscribe(handleNext3); - }, [handleNext3]); - } - `, - errors: [ - "React Hook useEffect has a missing dependency: 'handleNext1'. " + - 'Either include it or remove the dependency array.', - "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + - 'Either include it or remove the dependency array.', - "React Hook useMemo has a missing dependency: 'handleNext3'. " + - 'Either include it or remove the dependency array.', + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, [handleNext1]); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useLayoutEffect has a missing dependency: 'handleNext2'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext2]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + // Shouldn't affect anything + const handleChange = () => {}; + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, [handleNext2]); + useMemo(() => { + return Store.subscribe(handleNext3); + }, []); + } + `, + }, + ], + }, + { + message: + "React Hook useMemo has a missing dependency: 'handleNext3'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [handleNext3]', + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); + let [, dispatch] = React.useReducer(); + let taint = props.foo; + + // Shouldn't affect anything + const handleChange = () => {}; + + function handleNext1(value) { + let value2 = value * taint; + setState(value2); + console.log('hello'); + } + const handleNext2 = (value) => { + setState(taint(value)); + console.log('hello'); + }; + let handleNext3 = function(value) { + console.log(taint); + dispatch({ type: 'x', value }); + }; + useEffect(() => { + return Store.subscribe(handleNext1); + }, []); + useLayoutEffect(() => { + return Store.subscribe(handleNext2); + }, []); + useMemo(() => { + return Store.subscribe(handleNext3); + }, [handleNext3]); + } + `, + }, + ], + }, ], }, { - // Even if the function only references static values, - // once you specify it in deps, it will invalidate them. - code: ` - function MyComponent(props) { - let [, setState] = useState(); - - function handleNext(value) { - setState(value); - } - - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); - } - `, - // Not gonna autofix a function definition - // because it's not always safe due to hoisting. - output: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); @@ -3631,31 +4672,22 @@ const tests = { } `, errors: [ - `The 'handleNext' function makes the dependencies of ` + - `useEffect Hook (at line 11) change on every render. ` + - `Move it inside the useEffect callback. Alternatively, ` + - `wrap the 'handleNext' definition into its own useCallback() Hook.`, + { + message: + `The 'handleNext' function makes the dependencies of ` + + `useEffect Hook (at line 11) change on every render. ` + + `Move it inside the useEffect callback. Alternatively, ` + + `wrap the 'handleNext' definition into its own useCallback() Hook.`, + // Not gonna fix a function definition + // because it's not always safe due to hoisting. + suggestions: undefined, + }, ], }, { // Even if the function only references static values, // once you specify it in deps, it will invalidate them. - code: ` - function MyComponent(props) { - let [, setState] = useState(); - - const handleNext = (value) => { - setState(value); - }; - - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); - } - `, - // We don't autofix moving (too invasive). But that's the suggested fix - // when only effect uses this function. Otherwise, we'd useCallback. - output: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); @@ -3669,10 +4701,16 @@ const tests = { } `, errors: [ - `The 'handleNext' function makes the dependencies of ` + - `useEffect Hook (at line 11) change on every render. ` + - `Move it inside the useEffect callback. Alternatively, ` + - `wrap the 'handleNext' definition into its own useCallback() Hook.`, + { + message: + `The 'handleNext' function makes the dependencies of ` + + `useEffect Hook (at line 11) change on every render. ` + + `Move it inside the useEffect callback. Alternatively, ` + + `wrap the 'handleNext' definition into its own useCallback() Hook.`, + // We don't fix moving (too invasive). But that's the suggested fix + // when only effect uses this function. Otherwise, we'd useCallback. + suggestions: undefined, + }, ], }, { @@ -3681,7 +4719,7 @@ const tests = { // However, we can't suggest moving handleNext into the // effect because it is *also* used outside of it. // So our suggestion is useCallback(). - code: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); @@ -3696,55 +4734,40 @@ const tests = { return
; } `, - // We autofix this one with useCallback since it's - // the easy fix and you can't just move it into effect. - output: ` - function MyComponent(props) { - let [, setState] = useState(); + errors: [ + { + message: + `The 'handleNext' function makes the dependencies of ` + + `useEffect Hook (at line 11) change on every render. ` + + `To fix this, wrap the 'handleNext' definition into its own useCallback() Hook.`, + // We fix this one with useCallback since it's + // the easy fix and you can't just move it into effect. + suggestions: [ + { + desc: + "Wrap the 'handleNext' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + let [, setState] = useState(); - const handleNext = useCallback((value) => { - setState(value); - }); + const handleNext = useCallback((value) => { + setState(value); + }); - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); + useEffect(() => { + return Store.subscribe(handleNext); + }, [handleNext]); - return
; - } - `, - errors: [ - `The 'handleNext' function makes the dependencies of ` + - `useEffect Hook (at line 11) change on every render. ` + - `To fix this, wrap the 'handleNext' definition into its own useCallback() Hook.`, + return
; + } + `, + }, + ], + }, ], }, { - code: ` - function MyComponent(props) { - function handleNext1() { - console.log('hello'); - } - const handleNext2 = () => { - console.log('hello'); - }; - let handleNext3 = function() { - console.log('hello'); - }; - useEffect(() => { - return Store.subscribe(handleNext1); - }, [handleNext1]); - useLayoutEffect(() => { - return Store.subscribe(handleNext2); - }, [handleNext2]); - useMemo(() => { - return Store.subscribe(handleNext3); - }, [handleNext3]); - } - `, - // Autofix doesn't wrap into useCallback here - // because they are only referenced by effect itself. - output: ` + code: normalizeIndent` function MyComponent(props) { function handleNext1() { console.log('hello'); @@ -3767,19 +4790,31 @@ const tests = { } `, errors: [ - "The 'handleNext1' function makes the dependencies of useEffect Hook " + - '(at line 14) change on every render. Move it inside the useEffect callback. ' + - "Alternatively, wrap the 'handleNext1' definition into its own useCallback() Hook.", - "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + - '(at line 17) change on every render. Move it inside the useLayoutEffect callback. ' + - "Alternatively, wrap the 'handleNext2' definition into its own useCallback() Hook.", - "The 'handleNext3' function makes the dependencies of useMemo Hook " + - '(at line 20) change on every render. Move it inside the useMemo callback. ' + - "Alternatively, wrap the 'handleNext3' definition into its own useCallback() Hook.", + { + message: + "The 'handleNext1' function makes the dependencies of useEffect Hook " + + '(at line 14) change on every render. Move it inside the useEffect callback. ' + + "Alternatively, wrap the 'handleNext1' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + + '(at line 17) change on every render. Move it inside the useLayoutEffect callback. ' + + "Alternatively, wrap the 'handleNext2' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext3' function makes the dependencies of useMemo Hook " + + '(at line 20) change on every render. Move it inside the useMemo callback. ' + + "Alternatively, wrap the 'handleNext3' definition into its own useCallback() Hook.", + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { function handleNext1() { console.log('hello'); @@ -3804,47 +4839,34 @@ const tests = { }, [handleNext3]); } `, - // Autofix doesn't wrap into useCallback here + // Suggestions don't wrap into useCallback here // because they are only referenced by effect itself. - output: ` - function MyComponent(props) { - function handleNext1() { - console.log('hello'); - } - const handleNext2 = () => { - console.log('hello'); - }; - let handleNext3 = function() { - console.log('hello'); - }; - useEffect(() => { - handleNext1(); - return Store.subscribe(() => handleNext1()); - }, [handleNext1]); - useLayoutEffect(() => { - handleNext2(); - return Store.subscribe(() => handleNext2()); - }, [handleNext2]); - useMemo(() => { - handleNext3(); - return Store.subscribe(() => handleNext3()); - }, [handleNext3]); - } - `, errors: [ - "The 'handleNext1' function makes the dependencies of useEffect Hook " + - '(at line 15) change on every render. Move it inside the useEffect callback. ' + - "Alternatively, wrap the 'handleNext1' definition into its own useCallback() Hook.", - "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + - '(at line 19) change on every render. Move it inside the useLayoutEffect callback. ' + - "Alternatively, wrap the 'handleNext2' definition into its own useCallback() Hook.", - "The 'handleNext3' function makes the dependencies of useMemo Hook " + - '(at line 23) change on every render. Move it inside the useMemo callback. ' + - "Alternatively, wrap the 'handleNext3' definition into its own useCallback() Hook.", + { + message: + "The 'handleNext1' function makes the dependencies of useEffect Hook " + + '(at line 15) change on every render. Move it inside the useEffect callback. ' + + "Alternatively, wrap the 'handleNext1' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + + '(at line 19) change on every render. Move it inside the useLayoutEffect callback. ' + + "Alternatively, wrap the 'handleNext2' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext3' function makes the dependencies of useMemo Hook " + + '(at line 23) change on every render. Move it inside the useMemo callback. ' + + "Alternatively, wrap the 'handleNext3' definition into its own useCallback() Hook.", + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { function handleNext1() { console.log('hello'); @@ -3880,58 +4902,118 @@ const tests = { ); } `, - // Autofix wraps into useCallback where possible (variables only) - // because they are only referenced outside the effect. - output: ` - function MyComponent(props) { - function handleNext1() { - console.log('hello'); - } - const handleNext2 = useCallback(() => { - console.log('hello'); - }); - let handleNext3 = useCallback(function() { - console.log('hello'); - }); - useEffect(() => { - handleNext1(); - return Store.subscribe(() => handleNext1()); - }, [handleNext1]); - useLayoutEffect(() => { - handleNext2(); - return Store.subscribe(() => handleNext2()); - }, [handleNext2]); - useMemo(() => { - handleNext3(); - return Store.subscribe(() => handleNext3()); - }, [handleNext3]); - return ( -
{ - handleNext1(); - setTimeout(handleNext2); - setTimeout(() => { - handleNext3(); - }); - }} - /> - ); - } - `, errors: [ - "The 'handleNext1' function makes the dependencies of useEffect Hook " + - '(at line 15) change on every render. To fix this, wrap the ' + - "'handleNext1' definition into its own useCallback() Hook.", - "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + - '(at line 19) change on every render. To fix this, wrap the ' + - "'handleNext2' definition into its own useCallback() Hook.", - "The 'handleNext3' function makes the dependencies of useMemo Hook " + - '(at line 23) change on every render. To fix this, wrap the ' + - "'handleNext3' definition into its own useCallback() Hook.", + { + message: + "The 'handleNext1' function makes the dependencies of useEffect Hook " + + '(at line 15) change on every render. To fix this, wrap the ' + + "'handleNext1' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext2' function makes the dependencies of useLayoutEffect Hook " + + '(at line 19) change on every render. To fix this, wrap the ' + + "'handleNext2' definition into its own useCallback() Hook.", + // Suggestion wraps into useCallback where possible (variables only) + // because they are only referenced outside the effect. + suggestions: [ + { + desc: + "Wrap the 'handleNext2' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + function handleNext1() { + console.log('hello'); + } + const handleNext2 = useCallback(() => { + console.log('hello'); + }); + let handleNext3 = function() { + console.log('hello'); + }; + useEffect(() => { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() => { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() => { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( +
{ + handleNext1(); + setTimeout(handleNext2); + setTimeout(() => { + handleNext3(); + }); + }} + /> + ); + } + `, + }, + ], + }, + { + message: + "The 'handleNext3' function makes the dependencies of useMemo Hook " + + '(at line 23) change on every render. To fix this, wrap the ' + + "'handleNext3' definition into its own useCallback() Hook.", + // Autofix wraps into useCallback where possible (variables only) + // because they are only referenced outside the effect. + suggestions: [ + { + desc: + "Wrap the 'handleNext3' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + function handleNext1() { + console.log('hello'); + } + const handleNext2 = () => { + console.log('hello'); + }; + let handleNext3 = useCallback(function() { + console.log('hello'); + }); + useEffect(() => { + handleNext1(); + return Store.subscribe(() => handleNext1()); + }, [handleNext1]); + useLayoutEffect(() => { + handleNext2(); + return Store.subscribe(() => handleNext2()); + }, [handleNext2]); + useMemo(() => { + handleNext3(); + return Store.subscribe(() => handleNext3()); + }, [handleNext3]); + return ( +
{ + handleNext1(); + setTimeout(handleNext2); + setTimeout(() => { + handleNext3(); + }); + }} + /> + ); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { const handleNext1 = () => { console.log('hello'); @@ -3953,42 +5035,86 @@ const tests = { // effect. But it's used by more than one. So we // suggest useCallback() and use it for the autofix // where possible (variable but not declaration). - output: ` - function MyComponent(props) { - const handleNext1 = useCallback(() => { - console.log('hello'); - }); - function handleNext2() { - console.log('hello'); - } - useEffect(() => { - return Store.subscribe(handleNext1); - return Store.subscribe(handleNext2); - }, [handleNext1, handleNext2]); - useEffect(() => { - return Store.subscribe(handleNext1); - return Store.subscribe(handleNext2); - }, [handleNext1, handleNext2]); - } - `, // TODO: we could coalesce messages for the same function if it affects multiple Hooks. errors: [ - "The 'handleNext1' function makes the dependencies of useEffect Hook " + - '(at line 12) change on every render. To fix this, wrap the ' + - "'handleNext1' definition into its own useCallback() Hook.", - "The 'handleNext1' function makes the dependencies of useEffect Hook " + - '(at line 16) change on every render. To fix this, wrap the ' + - "'handleNext1' definition into its own useCallback() Hook.", - "The 'handleNext2' function makes the dependencies of useEffect Hook " + - '(at line 12) change on every render. To fix this, wrap the ' + - "'handleNext2' definition into its own useCallback() Hook.", - "The 'handleNext2' function makes the dependencies of useEffect Hook " + - '(at line 16) change on every render. To fix this, wrap the ' + - "'handleNext2' definition into its own useCallback() Hook.", + { + message: + "The 'handleNext1' function makes the dependencies of useEffect Hook " + + '(at line 12) change on every render. To fix this, wrap the ' + + "'handleNext1' definition into its own useCallback() Hook.", + suggestions: [ + { + desc: + "Wrap the 'handleNext1' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + const handleNext1 = useCallback(() => { + console.log('hello'); + }); + function handleNext2() { + console.log('hello'); + } + useEffect(() => { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() => { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + } + `, + }, + ], + }, + { + message: + "The 'handleNext1' function makes the dependencies of useEffect Hook " + + '(at line 16) change on every render. To fix this, wrap the ' + + "'handleNext1' definition into its own useCallback() Hook.", + suggestions: [ + { + desc: + "Wrap the 'handleNext1' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + const handleNext1 = useCallback(() => { + console.log('hello'); + }); + function handleNext2() { + console.log('hello'); + } + useEffect(() => { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + useEffect(() => { + return Store.subscribe(handleNext1); + return Store.subscribe(handleNext2); + }, [handleNext1, handleNext2]); + } + `, + }, + ], + }, + { + message: + "The 'handleNext2' function makes the dependencies of useEffect Hook " + + '(at line 12) change on every render. To fix this, wrap the ' + + "'handleNext2' definition into its own useCallback() Hook.", + suggestions: undefined, + }, + { + message: + "The 'handleNext2' function makes the dependencies of useEffect Hook " + + '(at line 16) change on every render. To fix this, wrap the ' + + "'handleNext2' definition into its own useCallback() Hook.", + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function MyComponent(props) { let handleNext = () => { console.log('hello'); @@ -4003,49 +5129,42 @@ const tests = { }, [handleNext]); } `, - // Normally we'd suggest moving handleNext inside an - // effect. But it's used more than once. - // TODO: our autofix here isn't quite sufficient because - // it only wraps the first definition. But seems ok. - output: ` - function MyComponent(props) { - let handleNext = useCallback(() => { - console.log('hello'); - }); - if (props.foo) { - handleNext = () => { - console.log('hello'); - }; - } - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); - } - `, errors: [ - "The 'handleNext' function makes the dependencies of useEffect Hook " + - '(at line 13) change on every render. To fix this, wrap the ' + - "'handleNext' definition into its own useCallback() Hook.", + { + message: + "The 'handleNext' function makes the dependencies of useEffect Hook " + + '(at line 13) change on every render. To fix this, wrap the ' + + "'handleNext' definition into its own useCallback() Hook.", + // Normally we'd suggest moving handleNext inside an + // effect. But it's used more than once. + // TODO: our autofix here isn't quite sufficient because + // it only wraps the first definition. But seems ok. + suggestions: [ + { + desc: + "Wrap the 'handleNext' definition into its own useCallback() Hook.", + output: normalizeIndent` + function MyComponent(props) { + let handleNext = useCallback(() => { + console.log('hello'); + }); + if (props.foo) { + handleNext = () => { + console.log('hello'); + }; + } + useEffect(() => { + return Store.subscribe(handleNext); + }, [handleNext]); + } + `, + }, + ], + }, ], }, { - code: ` - function MyComponent(props) { - let [, setState] = useState(); - let taint = props.foo; - - function handleNext(value) { - let value2 = value * taint; - setState(value2); - console.log('hello'); - } - - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); - } - `, - output: ` + code: normalizeIndent` function MyComponent(props) { let [, setState] = useState(); let taint = props.foo; @@ -4056,101 +5175,30 @@ const tests = { console.log('hello'); } - useEffect(() => { - return Store.subscribe(handleNext); - }, [handleNext]); - } - `, - errors: [ - `The 'handleNext' function makes the dependencies of ` + - `useEffect Hook (at line 14) change on every render. ` + - `Move it inside the useEffect callback. Alternatively, wrap the ` + - `'handleNext' definition into its own useCallback() Hook.`, - ], - }, - { - code: ` - function Counter() { - let [count, setCount] = useState(0); - - useEffect(() => { - let id = setInterval(() => { - setCount(count + 1); - }, 1000); - return () => clearInterval(id); - }, []); - - return

{count}

; - } - `, - output: ` - function Counter() { - let [count, setCount] = useState(0); - - useEffect(() => { - let id = setInterval(() => { - setCount(count + 1); - }, 1000); - return () => clearInterval(id); - }, [count]); - - return

{count}

; - } - `, - errors: [ - "React Hook useEffect has a missing dependency: 'count'. " + - 'Either include it or remove the dependency array. ' + - `You can also do a functional update 'setCount(c => ...)' if you ` + - `only need 'count' in the 'setCount' call.`, - ], - }, - { - code: ` - function Counter() { - let [count, setCount] = useState(0); - let [increment, setIncrement] = useState(0); - - useEffect(() => { - let id = setInterval(() => { - setCount(count + increment); - }, 1000); - return () => clearInterval(id); - }, []); - - return

{count}

; - } - `, - output: ` - function Counter() { - let [count, setCount] = useState(0); - let [increment, setIncrement] = useState(0); - - useEffect(() => { - let id = setInterval(() => { - setCount(count + increment); - }, 1000); - return () => clearInterval(id); - }, [count, increment]); - - return

{count}

; + useEffect(() => { + return Store.subscribe(handleNext); + }, [handleNext]); } `, errors: [ - "React Hook useEffect has missing dependencies: 'count' and 'increment'. " + - 'Either include them or remove the dependency array. ' + - `You can also do a functional update 'setCount(c => ...)' if you ` + - `only need 'count' in the 'setCount' call.`, + { + message: + `The 'handleNext' function makes the dependencies of ` + + `useEffect Hook (at line 14) change on every render. ` + + `Move it inside the useEffect callback. Alternatively, wrap the ` + + `'handleNext' definition into its own useCallback() Hook.`, + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); - let [increment, setIncrement] = useState(0); useEffect(() => { let id = setInterval(() => { - setCount(count => count + increment); + setCount(count + 1); }, 1000); return () => clearInterval(id); }, []); @@ -4158,33 +5206,86 @@ const tests = { return

{count}

; } `, - output: ` + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'count'. " + + 'Either include it or remove the dependency array. ' + + `You can also do a functional update 'setCount(c => ...)' if you ` + + `only need 'count' in the 'setCount' call.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [count]', + output: normalizeIndent` + function Counter() { + let [count, setCount] = useState(0); + + useEffect(() => { + let id = setInterval(() => { + setCount(count + 1); + }, 1000); + return () => clearInterval(id); + }, [count]); + + return

{count}

; + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); let [increment, setIncrement] = useState(0); useEffect(() => { let id = setInterval(() => { - setCount(count => count + increment); + setCount(count + increment); }, 1000); return () => clearInterval(id); - }, [increment]); + }, []); return

{count}

; } `, errors: [ - "React Hook useEffect has a missing dependency: 'increment'. " + - 'Either include it or remove the dependency array. ' + - `You can also replace multiple useState variables with useReducer ` + - `if 'setCount' needs the current value of 'increment'.`, + { + message: + "React Hook useEffect has missing dependencies: 'count' and 'increment'. " + + 'Either include them or remove the dependency array. ' + + `You can also do a functional update 'setCount(c => ...)' if you ` + + `only need 'count' in the 'setCount' call.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [count, increment]', + output: normalizeIndent` + function Counter() { + let [count, setCount] = useState(0); + let [increment, setIncrement] = useState(0); + + useEffect(() => { + let id = setInterval(() => { + setCount(count + increment); + }, 1000); + return () => clearInterval(id); + }, [count, increment]); + + return

{count}

; + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); - let increment = useCustomHook(); + let [increment, setIncrement] = useState(0); useEffect(() => { let id = setInterval(() => { @@ -4196,7 +5297,38 @@ const tests = { return

{count}

; } `, - output: ` + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'increment'. " + + 'Either include it or remove the dependency array. ' + + `You can also replace multiple useState variables with useReducer ` + + `if 'setCount' needs the current value of 'increment'.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [increment]', + output: normalizeIndent` + function Counter() { + let [count, setCount] = useState(0); + let [increment, setIncrement] = useState(0); + + useEffect(() => { + let id = setInterval(() => { + setCount(count => count + increment); + }, 1000); + return () => clearInterval(id); + }, [increment]); + + return

{count}

; + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` function Counter() { let [count, setCount] = useState(0); let increment = useCustomHook(); @@ -4206,7 +5338,7 @@ const tests = { setCount(count => count + increment); }, 1000); return () => clearInterval(id); - }, [increment]); + }, []); return

{count}

; } @@ -4215,12 +5347,35 @@ const tests = { // because we don't know if it's safe for it to close over a value. // We only show it for state variables (and possibly props). errors: [ - "React Hook useEffect has a missing dependency: 'increment'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'increment'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [increment]', + output: normalizeIndent` + function Counter() { + let [count, setCount] = useState(0); + let increment = useCustomHook(); + + useEffect(() => { + let id = setInterval(() => { + setCount(count => count + increment); + }, 1000); + return () => clearInterval(id); + }, [increment]); + + return

{count}

; + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Counter({ step }) { let [count, setCount] = useState(0); @@ -4238,52 +5393,42 @@ const tests = { return

{count}

; } `, - output: ` - function Counter({ step }) { - let [count, setCount] = useState(0); - - function increment(x) { - return x + step; - } - - useEffect(() => { - let id = setInterval(() => { - setCount(count => increment(count)); - }, 1000); - return () => clearInterval(id); - }, [increment]); - - return

{count}

; - } - `, // This intentionally doesn't show the reducer message // because we don't know if it's safe for it to close over a value. // We only show it for state variables (and possibly props). errors: [ - "React Hook useEffect has a missing dependency: 'increment'. " + - 'Either include it or remove the dependency array.', - ], - }, - { - code: ` - function Counter({ step }) { - let [count, setCount] = useState(0); + { + message: + "React Hook useEffect has a missing dependency: 'increment'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [increment]', + output: normalizeIndent` + function Counter({ step }) { + let [count, setCount] = useState(0); - function increment(x) { - return x + step; - } + function increment(x) { + return x + step; + } - useEffect(() => { - let id = setInterval(() => { - setCount(count => increment(count)); - }, 1000); - return () => clearInterval(id); - }, [increment]); + useEffect(() => { + let id = setInterval(() => { + setCount(count => increment(count)); + }, 1000); + return () => clearInterval(id); + }, [increment]); - return

{count}

; - } - `, - output: ` + return

{count}

; + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` function Counter({ step }) { let [count, setCount] = useState(0); @@ -4302,14 +5447,18 @@ const tests = { } `, errors: [ - `The 'increment' function makes the dependencies of useEffect Hook ` + - `(at line 14) change on every render. Move it inside the useEffect callback. ` + - `Alternatively, wrap the \'increment\' definition into its own ` + - `useCallback() Hook.`, + { + message: + `The 'increment' function makes the dependencies of useEffect Hook ` + + `(at line 14) change on every render. Move it inside the useEffect callback. ` + + `Alternatively, wrap the \'increment\' definition into its own ` + + `useCallback() Hook.`, + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function Counter({ increment }) { let [count, setCount] = useState(0); @@ -4323,29 +5472,37 @@ const tests = { return

{count}

; } `, - output: ` - function Counter({ increment }) { - let [count, setCount] = useState(0); + errors: [ + { + message: + "React Hook useEffect has a missing dependency: 'increment'. " + + 'Either include it or remove the dependency array. ' + + `If 'setCount' needs the current value of 'increment', ` + + `you can also switch to useReducer instead of useState and read 'increment' in the reducer.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [increment]', + output: normalizeIndent` + function Counter({ increment }) { + let [count, setCount] = useState(0); - useEffect(() => { - let id = setInterval(() => { - setCount(count => count + increment); - }, 1000); - return () => clearInterval(id); - }, [increment]); + useEffect(() => { + let id = setInterval(() => { + setCount(count => count + increment); + }, 1000); + return () => clearInterval(id); + }, [increment]); - return

{count}

; - } - `, - errors: [ - "React Hook useEffect has a missing dependency: 'increment'. " + - 'Either include it or remove the dependency array. ' + - `If 'setCount' needs the current value of 'increment', ` + - `you can also switch to useReducer instead of useState and read 'increment' in the reducer.`, + return

{count}

; + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Counter() { const [count, setCount] = useState(0); @@ -4363,36 +5520,44 @@ const tests = { return

{count}

; } `, - output: ` - function Counter() { - const [count, setCount] = useState(0); - - function tick() { - setCount(count + 1); - } - - useEffect(() => { - let id = setInterval(() => { - tick(); - }, 1000); - return () => clearInterval(id); - }, [tick]); - - return

{count}

; - } - `, // TODO: ideally this should suggest useState updater form // since this code doesn't actually work. The autofix could // at least avoid suggesting 'tick' since it's obviously // always different, and thus useless. errors: [ - "React Hook useEffect has a missing dependency: 'tick'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useEffect has a missing dependency: 'tick'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [tick]', + output: normalizeIndent` + function Counter() { + const [count, setCount] = useState(0); + + function tick() { + setCount(count + 1); + } + + useEffect(() => { + let id = setInterval(() => { + tick(); + }, 1000); + return () => clearInterval(id); + }, [tick]); + + return

{count}

; + } + `, + }, + ], + }, ], }, { // Regression test for a crash - code: ` + code: normalizeIndent` function Podcasts() { useEffect(() => { alert(podcasts); @@ -4400,24 +5565,32 @@ const tests = { let [podcasts, setPodcasts] = useState(null); } `, - // Note: this autofix is shady because - // the variable is used before declaration. - // TODO: Maybe we can catch those fixes and not autofix. - output: ` - function Podcasts() { - useEffect(() => { - alert(podcasts); - }, [podcasts]); - let [podcasts, setPodcasts] = useState(null); - } - `, errors: [ - `React Hook useEffect has a missing dependency: 'podcasts'. ` + - `Either include it or remove the dependency array.`, + { + message: + `React Hook useEffect has a missing dependency: 'podcasts'. ` + + `Either include it or remove the dependency array.`, + // Note: this autofix is shady because + // the variable is used before declaration. + // TODO: Maybe we can catch those fixes and not autofix. + suggestions: [ + { + desc: 'Update the dependencies array to be: [podcasts]', + output: normalizeIndent` + function Podcasts() { + useEffect(() => { + alert(podcasts); + }, [podcasts]); + let [podcasts, setPodcasts] = useState(null); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Podcasts({ fetchPodcasts, id }) { let [podcasts, setPodcasts] = useState(null); useEffect(() => { @@ -4425,23 +5598,31 @@ const tests = { }, [id]); } `, - output: ` - function Podcasts({ fetchPodcasts, id }) { - let [podcasts, setPodcasts] = useState(null); - useEffect(() => { - fetchPodcasts(id).then(setPodcasts); - }, [fetchPodcasts, id]); - } - `, errors: [ - `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + - `Either include it or remove the dependency array. ` + - `If 'fetchPodcasts' changes too often, ` + - `find the parent component that defines it and wrap that definition in useCallback.`, + { + message: + `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + + `Either include it or remove the dependency array. ` + + `If 'fetchPodcasts' changes too often, ` + + `find the parent component that defines it and wrap that definition in useCallback.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [fetchPodcasts, id]', + output: normalizeIndent` + function Podcasts({ fetchPodcasts, id }) { + let [podcasts, setPodcasts] = useState(null); + useEffect(() => { + fetchPodcasts(id).then(setPodcasts); + }, [fetchPodcasts, id]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Podcasts({ api: { fetchPodcasts }, id }) { let [podcasts, setPodcasts] = useState(null); useEffect(() => { @@ -4449,23 +5630,31 @@ const tests = { }, [id]); } `, - output: ` - function Podcasts({ api: { fetchPodcasts }, id }) { - let [podcasts, setPodcasts] = useState(null); - useEffect(() => { - fetchPodcasts(id).then(setPodcasts); - }, [fetchPodcasts, id]); - } - `, errors: [ - `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + - `Either include it or remove the dependency array. ` + - `If 'fetchPodcasts' changes too often, ` + - `find the parent component that defines it and wrap that definition in useCallback.`, + { + message: + `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + + `Either include it or remove the dependency array. ` + + `If 'fetchPodcasts' changes too often, ` + + `find the parent component that defines it and wrap that definition in useCallback.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [fetchPodcasts, id]', + output: normalizeIndent` + function Podcasts({ api: { fetchPodcasts }, id }) { + let [podcasts, setPodcasts] = useState(null); + useEffect(() => { + fetchPodcasts(id).then(setPodcasts); + }, [fetchPodcasts, id]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Podcasts({ fetchPodcasts, fetchPodcasts2, id }) { let [podcasts, setPodcasts] = useState(null); useEffect(() => { @@ -4477,27 +5666,36 @@ const tests = { }, [id]); } `, - output: ` - function Podcasts({ fetchPodcasts, fetchPodcasts2, id }) { - let [podcasts, setPodcasts] = useState(null); - useEffect(() => { - setTimeout(() => { - console.log(id); - fetchPodcasts(id).then(setPodcasts); - fetchPodcasts2(id).then(setPodcasts); - }); - }, [fetchPodcasts, fetchPodcasts2, id]); - } - `, errors: [ - `React Hook useEffect has missing dependencies: 'fetchPodcasts' and 'fetchPodcasts2'. ` + - `Either include them or remove the dependency array. ` + - `If 'fetchPodcasts' changes too often, ` + - `find the parent component that defines it and wrap that definition in useCallback.`, + { + message: + `React Hook useEffect has missing dependencies: 'fetchPodcasts' and 'fetchPodcasts2'. ` + + `Either include them or remove the dependency array. ` + + `If 'fetchPodcasts' changes too often, ` + + `find the parent component that defines it and wrap that definition in useCallback.`, + suggestions: [ + { + desc: + 'Update the dependencies array to be: [fetchPodcasts, fetchPodcasts2, id]', + output: normalizeIndent` + function Podcasts({ fetchPodcasts, fetchPodcasts2, id }) { + let [podcasts, setPodcasts] = useState(null); + useEffect(() => { + setTimeout(() => { + console.log(id); + fetchPodcasts(id).then(setPodcasts); + fetchPodcasts2(id).then(setPodcasts); + }); + }, [fetchPodcasts, fetchPodcasts2, id]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Podcasts({ fetchPodcasts, id }) { let [podcasts, setPodcasts] = useState(null); useEffect(() => { @@ -4506,26 +5704,34 @@ const tests = { }, [id]); } `, - output: ` - function Podcasts({ fetchPodcasts, id }) { - let [podcasts, setPodcasts] = useState(null); - useEffect(() => { - console.log(fetchPodcasts); - fetchPodcasts(id).then(setPodcasts); - }, [fetchPodcasts, id]); - } - `, errors: [ - `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + - `Either include it or remove the dependency array. ` + - `If 'fetchPodcasts' changes too often, ` + - `find the parent component that defines it and wrap that definition in useCallback.`, + { + message: + `React Hook useEffect has a missing dependency: 'fetchPodcasts'. ` + + `Either include it or remove the dependency array. ` + + `If 'fetchPodcasts' changes too often, ` + + `find the parent component that defines it and wrap that definition in useCallback.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: [fetchPodcasts, id]', + output: normalizeIndent` + function Podcasts({ fetchPodcasts, id }) { + let [podcasts, setPodcasts] = useState(null); + useEffect(() => { + console.log(fetchPodcasts); + fetchPodcasts(id).then(setPodcasts); + }, [fetchPodcasts, id]); + } + `, + }, + ], + }, ], }, { // The mistake here is that it was moved inside the effect // so it can't be referenced in the deps array. - code: ` + code: normalizeIndent` function Thing() { useEffect(() => { const fetchData = async () => {}; @@ -4533,21 +5739,29 @@ const tests = { }, [fetchData]); } `, - output: ` - function Thing() { - useEffect(() => { - const fetchData = async () => {}; - fetchData(); - }, []); - } - `, errors: [ - `React Hook useEffect has an unnecessary dependency: 'fetchData'. ` + - `Either exclude it or remove the dependency array.`, + { + message: + `React Hook useEffect has an unnecessary dependency: 'fetchData'. ` + + `Either exclude it or remove the dependency array.`, + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function Thing() { + useEffect(() => { + const fetchData = async () => {}; + fetchData(); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Hello() { const [state, setState] = useState(0); useEffect(() => { @@ -4555,22 +5769,30 @@ const tests = { }); } `, - output: ` - function Hello() { - const [state, setState] = useState(0); - useEffect(() => { - setState({}); - }, []); - } - `, errors: [ - `React Hook useEffect contains a call to 'setState'. ` + - `Without a list of dependencies, this can lead to an infinite chain of updates. ` + - `To fix this, pass [] as a second argument to the useEffect Hook.`, + { + message: + `React Hook useEffect contains a call to 'setState'. ` + + `Without a list of dependencies, this can lead to an infinite chain of updates. ` + + `To fix this, pass [] as a second argument to the useEffect Hook.`, + suggestions: [ + { + desc: 'Add dependencies array: []', + output: normalizeIndent` + function Hello() { + const [state, setState] = useState(0); + useEffect(() => { + setState({}); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Hello() { const [data, setData] = useState(0); useEffect(() => { @@ -4578,22 +5800,30 @@ const tests = { }); } `, - output: ` - function Hello() { - const [data, setData] = useState(0); - useEffect(() => { - fetchData.then(setData); - }, []); - } - `, errors: [ - `React Hook useEffect contains a call to 'setData'. ` + - `Without a list of dependencies, this can lead to an infinite chain of updates. ` + - `To fix this, pass [] as a second argument to the useEffect Hook.`, + { + message: + `React Hook useEffect contains a call to 'setData'. ` + + `Without a list of dependencies, this can lead to an infinite chain of updates. ` + + `To fix this, pass [] as a second argument to the useEffect Hook.`, + suggestions: [ + { + desc: 'Add dependencies array: []', + output: normalizeIndent` + function Hello() { + const [data, setData] = useState(0); + useEffect(() => { + fetchData.then(setData); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Hello({ country }) { const [data, setData] = useState(0); useEffect(() => { @@ -4601,22 +5831,30 @@ const tests = { }); } `, - output: ` - function Hello({ country }) { - const [data, setData] = useState(0); - useEffect(() => { - fetchData(country).then(setData); - }, [country]); - } - `, errors: [ - `React Hook useEffect contains a call to 'setData'. ` + - `Without a list of dependencies, this can lead to an infinite chain of updates. ` + - `To fix this, pass [country] as a second argument to the useEffect Hook.`, + { + message: + `React Hook useEffect contains a call to 'setData'. ` + + `Without a list of dependencies, this can lead to an infinite chain of updates. ` + + `To fix this, pass [country] as a second argument to the useEffect Hook.`, + suggestions: [ + { + desc: 'Add dependencies array: [country]', + output: normalizeIndent` + function Hello({ country }) { + const [data, setData] = useState(0); + useEffect(() => { + fetchData(country).then(setData); + }, [country]); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Hello({ prop1, prop2 }) { const [state, setState] = useState(0); useEffect(() => { @@ -4626,94 +5864,108 @@ const tests = { }); } `, - output: ` - function Hello({ prop1, prop2 }) { - const [state, setState] = useState(0); - useEffect(() => { - if (prop1) { - setState(prop2); - } - }, [prop1, prop2]); - } - `, errors: [ - `React Hook useEffect contains a call to 'setState'. ` + - `Without a list of dependencies, this can lead to an infinite chain of updates. ` + - `To fix this, pass [prop1, prop2] as a second argument to the useEffect Hook.`, + { + message: + `React Hook useEffect contains a call to 'setState'. ` + + `Without a list of dependencies, this can lead to an infinite chain of updates. ` + + `To fix this, pass [prop1, prop2] as a second argument to the useEffect Hook.`, + suggestions: [ + { + desc: 'Add dependencies array: [prop1, prop2]', + output: normalizeIndent` + function Hello({ prop1, prop2 }) { + const [state, setState] = useState(0); + useEffect(() => { + if (prop1) { + setState(prop2); + } + }, [prop1, prop2]); + } + `, + }, + ], + }, ], }, { - code: ` - function Thing() { - useEffect(async () => {}, []); - } - `, - output: ` + code: normalizeIndent` function Thing() { useEffect(async () => {}, []); } `, errors: [ - `Effect callbacks are synchronous to prevent race conditions. ` + - `Put the async function inside:\n\n` + - 'useEffect(() => {\n' + - ' async function fetchData() {\n' + - ' // You can await here\n' + - ' const response = await MyAPI.getData(someId);\n' + - ' // ...\n' + - ' }\n' + - ' fetchData();\n' + - `}, [someId]); // Or [] if effect doesn't need props or state\n\n` + - 'Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching', + { + message: + `Effect callbacks are synchronous to prevent race conditions. ` + + `Put the async function inside:\n\n` + + 'useEffect(() => {\n' + + ' async function fetchData() {\n' + + ' // You can await here\n' + + ' const response = await MyAPI.getData(someId);\n' + + ' // ...\n' + + ' }\n' + + ' fetchData();\n' + + `}, [someId]); // Or [] if effect doesn't need props or state\n\n` + + 'Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching', + suggestions: undefined, + }, ], }, { - code: ` - function Thing() { - useEffect(async () => {}); - } - `, - output: ` + code: normalizeIndent` function Thing() { useEffect(async () => {}); } `, errors: [ - `Effect callbacks are synchronous to prevent race conditions. ` + - `Put the async function inside:\n\n` + - 'useEffect(() => {\n' + - ' async function fetchData() {\n' + - ' // You can await here\n' + - ' const response = await MyAPI.getData(someId);\n' + - ' // ...\n' + - ' }\n' + - ' fetchData();\n' + - `}, [someId]); // Or [] if effect doesn't need props or state\n\n` + - 'Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching', + { + message: + `Effect callbacks are synchronous to prevent race conditions. ` + + `Put the async function inside:\n\n` + + 'useEffect(() => {\n' + + ' async function fetchData() {\n' + + ' // You can await here\n' + + ' const response = await MyAPI.getData(someId);\n' + + ' // ...\n' + + ' }\n' + + ' fetchData();\n' + + `}, [someId]); // Or [] if effect doesn't need props or state\n\n` + + 'Learn more about data fetching with Hooks: https://fb.me/react-hooks-data-fetching', + suggestions: undefined, + }, ], }, { - code: ` + code: normalizeIndent` function Example() { const foo = useCallback(() => { foo(); }, [foo]); } `, - output: ` - function Example() { - const foo = useCallback(() => { - foo(); - }, []); - } - `, errors: [ - "React Hook useCallback has an unnecessary dependency: 'foo'. " + - 'Either exclude it or remove the dependency array.', + { + message: + "React Hook useCallback has an unnecessary dependency: 'foo'. " + + 'Either exclude it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: []', + output: normalizeIndent` + function Example() { + const foo = useCallback(() => { + foo(); + }, []); + } + `, + }, + ], + }, ], }, { - code: ` + code: normalizeIndent` function Example({ prop }) { const foo = useCallback(() => { prop.hello(foo); @@ -4723,19 +5975,27 @@ const tests = { }, [foo]); } `, - output: ` - function Example({ prop }) { - const foo = useCallback(() => { - prop.hello(foo); - }, [prop]); - const bar = useCallback(() => { - foo(); - }, [foo]); - } - `, errors: [ - "React Hook useCallback has a missing dependency: 'prop'. " + - 'Either include it or remove the dependency array.', + { + message: + "React Hook useCallback has a missing dependency: 'prop'. " + + 'Either include it or remove the dependency array.', + suggestions: [ + { + desc: 'Update the dependencies array to be: [prop]', + output: normalizeIndent` + function Example({ prop }) { + const foo = useCallback(() => { + prop.hello(foo); + }, [prop]); + const bar = useCallback(() => { + foo(); + }, [foo]); + } + `, + }, + ], + }, ], }, ], diff --git a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js index 9ec5e562641eb..42881f0f5b78e 100644 --- a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +++ b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js @@ -11,7 +11,6 @@ export default { meta: { - fixable: 'code', schema: [ { type: 'object', @@ -94,7 +93,7 @@ export default { reactiveHookName === 'useMemo' || reactiveHookName === 'useCallback' ) { - // TODO: Can this have an autofix? + // TODO: Can this have a suggestion? context.report({ node: node.parent.callee, message: @@ -558,12 +557,19 @@ export default { `To fix this, pass [` + suggestedDependencies.join(', ') + `] as a second argument to the ${reactiveHookName} Hook.`, - fix(fixer) { - return fixer.insertTextAfter( - node, - `, [${suggestedDependencies.join(', ')}]`, - ); - }, + suggest: [ + { + desc: `Add dependencies array: [${suggestedDependencies.join( + ', ', + )}]`, + fix(fixer) { + return fixer.insertTextAfter( + node, + `, [${suggestedDependencies.join(', ')}]`, + ); + }, + }, + ], }); } return; @@ -702,27 +708,37 @@ export default { ` Move it inside the ${reactiveHookName} callback. ` + `Alternatively, wrap the '${fn.name.name}' definition into its own useCallback() Hook.`; } + + let suggest; + // Only handle the simple case: arrow functions. + // Wrapping function declarations can mess up hoisting. + if (suggestUseCallback && fn.type === 'Variable') { + suggest = [ + { + desc: `Wrap the '${ + fn.name.name + }' definition into its own useCallback() Hook.`, + fix(fixer) { + return [ + // TODO: also add an import? + fixer.insertTextBefore(fn.node.init, 'useCallback('), + // TODO: ideally we'd gather deps here but it would require + // restructuring the rule code. This will cause a new lint + // error to appear immediately for useCallback. Note we're + // not adding [] because would that changes semantics. + fixer.insertTextAfter(fn.node.init, ')'), + ]; + }, + }, + ]; + } // TODO: What if the function needs to change on every render anyway? // Should we suggest removing effect deps as an appropriate fix too? context.report({ // TODO: Why not report this at the dependency site? node: fn.node, message, - fix(fixer) { - // Only handle the simple case: arrow functions. - // Wrapping function declarations can mess up hoisting. - if (suggestUseCallback && fn.type === 'Variable') { - return [ - // TODO: also add an import? - fixer.insertTextBefore(fn.node.init, 'useCallback('), - // TODO: ideally we'd gather deps here but it would require - // restructuring the rule code. This will cause a new lint - // error to appear immediately for useCallback. Note we're - // not adding [] because would that changes semantics. - fixer.insertTextAfter(fn.node.init, ')'), - ]; - } - }, + suggest, }); }); return; @@ -1008,13 +1024,20 @@ export default { 'omit', )) + extraWarning, - fix(fixer) { - // TODO: consider preserving the comments or formatting? - return fixer.replaceText( - declaredDependenciesNode, - `[${suggestedDependencies.join(', ')}]`, - ); - }, + suggest: [ + { + desc: `Update the dependencies array to be: [${suggestedDependencies.join( + ', ', + )}]`, + fix(fixer) { + // TODO: consider preserving the comments or formatting? + return fixer.replaceText( + declaredDependenciesNode, + `[${suggestedDependencies.join(', ')}]`, + ); + }, + }, + ], }); } }, From 56a8c353219ac93ab358eb28009de881ae48251e Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 17 Feb 2020 20:26:56 +0000 Subject: [PATCH 02/24] eslint-plugin-react-hooks@2.4.0 --- packages/eslint-plugin-react-hooks/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eslint-plugin-react-hooks/package.json b/packages/eslint-plugin-react-hooks/package.json index 6f523bcd5b6fc..8b87f13c487d1 100644 --- a/packages/eslint-plugin-react-hooks/package.json +++ b/packages/eslint-plugin-react-hooks/package.json @@ -1,7 +1,7 @@ { "name": "eslint-plugin-react-hooks", "description": "ESLint rules for React Hooks", - "version": "2.3.0", + "version": "2.4.0", "repository": { "type": "git", "url": "https://github.com/facebook/react.git", From d533229fba8f7e7e576436bf52bbcae56c862906 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Mon, 17 Feb 2020 20:36:16 +0000 Subject: [PATCH 03/24] Fix Prettier --- packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js index 42881f0f5b78e..b30694014e25e 100644 --- a/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js +++ b/packages/eslint-plugin-react-hooks/src/ExhaustiveDeps.js @@ -715,9 +715,7 @@ export default { if (suggestUseCallback && fn.type === 'Variable') { suggest = [ { - desc: `Wrap the '${ - fn.name.name - }' definition into its own useCallback() Hook.`, + desc: `Wrap the '${fn.name.name}' definition into its own useCallback() Hook.`, fix(fixer) { return [ // TODO: also add an import? From f48a5e64e8fd903293f5b854beb795dcc6bae86d Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 18 Feb 2020 13:31:59 +0000 Subject: [PATCH 04/24] Further cleanup of plugin event system (#18056) --- .../ReactBrowserEventEmitter-test.internal.js | 24 ++- .../react-dom/src/client/ReactDOMComponent.js | 10 +- packages/react-dom/src/client/ReactDOMFB.js | 2 +- .../src/client/ReactDOMHostConfig.js | 2 +- .../src/events/DOMEventListenerMap.js | 31 +++ .../src/events/DOMEventPluginSystem.js | 166 ++++++++++++++ .../src/events/ReactBrowserEventEmitter.js | 203 ------------------ .../src/events/ReactDOMEventReplaying.js | 8 +- .../react-dom/src/events/SelectEventPlugin.js | 2 +- 9 files changed, 221 insertions(+), 227 deletions(-) create mode 100644 packages/react-dom/src/events/DOMEventListenerMap.js delete mode 100644 packages/react-dom/src/events/ReactBrowserEventEmitter.js diff --git a/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js b/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js index 114565d342f9a..0b377d16eceaf 100644 --- a/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactBrowserEventEmitter-test.internal.js @@ -14,7 +14,8 @@ let EventPluginRegistry; let React; let ReactDOM; let ReactDOMComponentTree; -let ReactBrowserEventEmitter; +let DOMEventPluginSystem; +let ReactDOMEventListener; let ReactTestUtils; let idCallOrder; @@ -52,18 +53,21 @@ function registerSimpleTestHandler() { return getListener(CHILD, ON_CLICK_KEY); } +// We should probably remove this file at some point, it's just full of +// internal API usage. ReactBrowserEventEmitter was refactored out in +// #18056 too. The majority of this code lives in DOMEventPluginSystem. describe('ReactBrowserEventEmitter', () => { beforeEach(() => { jest.resetModules(); LISTENER.mockClear(); - // TODO: can we express this test with only public API? EventPluginGetListener = require('legacy-events/getListener').default; EventPluginRegistry = require('legacy-events/EventPluginRegistry'); React = require('react'); ReactDOM = require('react-dom'); ReactDOMComponentTree = require('../client/ReactDOMComponentTree'); - ReactBrowserEventEmitter = require('../events/ReactBrowserEventEmitter'); + DOMEventPluginSystem = require('../events/DOMEventPluginSystem'); + ReactDOMEventListener = require('../events/ReactDOMEventListener'); ReactTestUtils = require('react-dom/test-utils'); container = document.createElement('div'); @@ -177,12 +181,12 @@ describe('ReactBrowserEventEmitter', () => { expect(LISTENER).toHaveBeenCalledTimes(1); }); - it('should not invoke handlers if ReactBrowserEventEmitter is disabled', () => { + it('should not invoke handlers if ReactDOMEventListener is disabled', () => { registerSimpleTestHandler(); - ReactBrowserEventEmitter.setEnabled(false); + ReactDOMEventListener.setEnabled(false); CHILD.click(); expect(LISTENER).toHaveBeenCalledTimes(0); - ReactBrowserEventEmitter.setEnabled(true); + ReactDOMEventListener.setEnabled(true); CHILD.click(); expect(LISTENER).toHaveBeenCalledTimes(1); }); @@ -346,15 +350,15 @@ describe('ReactBrowserEventEmitter', () => { it('should listen to events only once', () => { spyOnDevAndProd(EventTarget.prototype, 'addEventListener'); - ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document); - ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document); + DOMEventPluginSystem.listenToEvent(ON_CLICK_KEY, document); + DOMEventPluginSystem.listenToEvent(ON_CLICK_KEY, document); expect(EventTarget.prototype.addEventListener).toHaveBeenCalledTimes(1); }); it('should work with event plugins without dependencies', () => { spyOnDevAndProd(EventTarget.prototype, 'addEventListener'); - ReactBrowserEventEmitter.listenTo(ON_CLICK_KEY, document); + DOMEventPluginSystem.listenToEvent(ON_CLICK_KEY, document); expect(EventTarget.prototype.addEventListener.calls.argsFor(0)[0]).toBe( 'click', @@ -364,7 +368,7 @@ describe('ReactBrowserEventEmitter', () => { it('should work with event plugins with dependencies', () => { spyOnDevAndProd(EventTarget.prototype, 'addEventListener'); - ReactBrowserEventEmitter.listenTo(ON_CHANGE_KEY, document); + DOMEventPluginSystem.listenToEvent(ON_CHANGE_KEY, document); const setEventListeners = []; const listenCalls = EventTarget.prototype.addEventListener.calls.allArgs(); diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index e5111ae603562..aa01964f9bf42 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -57,14 +57,11 @@ import { TOP_SUBMIT, TOP_TOGGLE, } from '../events/DOMTopLevelEventTypes'; -import { - listenTo, - trapBubbledEvent, - getListenerMapForElement, -} from '../events/ReactBrowserEventEmitter'; +import {getListenerMapForElement} from '../events/DOMEventListenerMap'; import { addResponderEventSystemEvent, removeActiveResponderEventSystemEvent, + trapBubbledEvent, } from '../events/ReactDOMEventListener.js'; import {mediaEventTypes} from '../events/DOMTopLevelEventTypes'; import { @@ -90,6 +87,7 @@ import { enableDeprecatedFlareAPI, enableTrustedTypesIntegration, } from 'shared/ReactFeatureFlags'; +import {listenToEvent} from '../events/DOMEventPluginSystem'; let didWarnInvalidHydration = false; let didWarnShadyDOM = false; @@ -274,7 +272,7 @@ function ensureListeningTo( const doc = isDocumentOrFragment ? rootContainerElement : rootContainerElement.ownerDocument; - listenTo(registrationName, doc); + listenToEvent(registrationName, doc); } function getOwnerDocumentFromRootContainer( diff --git a/packages/react-dom/src/client/ReactDOMFB.js b/packages/react-dom/src/client/ReactDOMFB.js index 5acb45d81e9bb..02403919e77dd 100644 --- a/packages/react-dom/src/client/ReactDOMFB.js +++ b/packages/react-dom/src/client/ReactDOMFB.js @@ -10,7 +10,7 @@ import {addUserTimingListener} from 'shared/ReactFeatureFlags'; import ReactDOM from './ReactDOM'; -import {isEnabled} from '../events/ReactBrowserEventEmitter'; +import {isEnabled} from '../events/ReactDOMEventListener'; import {getClosestInstanceFromNode} from './ReactDOMComponentTree'; if (__EXPERIMENTAL__) { diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 2e90978b31c03..0cf8db9da956a 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -34,7 +34,7 @@ import {validateDOMNesting, updatedAncestorInfo} from './validateDOMNesting'; import { isEnabled as ReactBrowserEventEmitterIsEnabled, setEnabled as ReactBrowserEventEmitterSetEnabled, -} from '../events/ReactBrowserEventEmitter'; +} from '../events/ReactDOMEventListener'; import {getChildNamespace} from '../shared/DOMNamespaces'; import { ELEMENT_NODE, diff --git a/packages/react-dom/src/events/DOMEventListenerMap.js b/packages/react-dom/src/events/DOMEventListenerMap.js new file mode 100644 index 0000000000000..b951eed1b61a1 --- /dev/null +++ b/packages/react-dom/src/events/DOMEventListenerMap.js @@ -0,0 +1,31 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; + +const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; +// prettier-ignore +const elementListenerMap: + // $FlowFixMe Work around Flow bug + | WeakMap + | Map< + Document | Element | Node, + Map void)>, + > = new PossiblyWeakMap(); + +export function getListenerMapForElement( + element: Document | Element | Node, +): Map void)> { + let listenerMap = elementListenerMap.get(element); + if (listenerMap === undefined) { + listenerMap = new Map(); + elementListenerMap.set(element, listenerMap); + } + return listenerMap; +} diff --git a/packages/react-dom/src/events/DOMEventPluginSystem.js b/packages/react-dom/src/events/DOMEventPluginSystem.js index 40190f30e3cfe..ad8b38f794ff6 100644 --- a/packages/react-dom/src/events/DOMEventPluginSystem.js +++ b/packages/react-dom/src/events/DOMEventPluginSystem.js @@ -21,9 +21,81 @@ import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching'; import {runEventsInBatch} from 'legacy-events/EventBatching'; import {plugins} from 'legacy-events/EventPluginRegistry'; import accumulateInto from 'legacy-events/accumulateInto'; +import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; import getEventTarget from './getEventTarget'; import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; +import {trapCapturedEvent, trapBubbledEvent} from './ReactDOMEventListener'; +import {getListenerMapForElement} from './DOMEventListenerMap'; +import isEventSupported from './isEventSupported'; +import { + TOP_BLUR, + TOP_CANCEL, + TOP_CLOSE, + TOP_FOCUS, + TOP_INVALID, + TOP_RESET, + TOP_SCROLL, + TOP_SUBMIT, + getRawEventName, + mediaEventTypes, +} from './DOMTopLevelEventTypes'; + +/** + * Summary of `DOMEventPluginSystem` event handling: + * + * - Top-level delegation is used to trap most native browser events. This + * may only occur in the main thread and is the responsibility of + * ReactDOMEventListener, which is injected and can therefore support + * pluggable event sources. This is the only work that occurs in the main + * thread. + * + * - We normalize and de-duplicate events to account for browser quirks. This + * may be done in the worker thread. + * + * - Forward these native events (with the associated top-level type used to + * trap it) to `EventPluginRegistry`, which in turn will ask plugins if they want + * to extract any synthetic events. + * + * - The `EventPluginRegistry` will then process each event by annotating them with + * "dispatches", a sequence of listeners and IDs that care about that event. + * + * - The `EventPluginRegistry` then dispatches the events. + * + * Overview of React and the event system: + * + * +------------+ . + * | DOM | . + * +------------+ . + * | . + * v . + * +------------+ . + * | ReactEvent | . + * | Listener | . + * +------------+ . +-----------+ + * | . +--------+|SimpleEvent| + * | . | |Plugin | + * +-----|------+ . v +-----------+ + * | | | . +--------------+ +------------+ + * | +-----------.--->|PluginRegistry| | Event | + * | | . | | +-----------+ | Propagators| + * | ReactEvent | . | | |TapEvent | |------------| + * | Emitter | . | |<---+|Plugin | |other plugin| + * | | . | | +-----------+ | utilities | + * | +-----------.--->| | +------------+ + * | | | . +--------------+ + * +-----|------+ . ^ +-----------+ + * | . | |Enter/Leave| + * + . +-------+|Plugin | + * +-------------+ . +-----------+ + * | application | . + * |-------------| . + * | | . + * | | . + * +-------------+ . + * . + * React Core . General Purpose Event Plugin System + */ const CALLBACK_BOOKKEEPING_POOL_SIZE = 10; const callbackBookkeepingPool = []; @@ -213,3 +285,97 @@ export function dispatchEventForPluginEventSystem( releaseTopLevelCallbackBookKeeping(bookKeeping); } } + +/** + * We listen for bubbled touch events on the document object. + * + * Firefox v8.01 (and possibly others) exhibited strange behavior when + * mounting `onmousemove` events at some node that was not the document + * element. The symptoms were that if your mouse is not moving over something + * contained within that mount point (for example on the background) the + * top-level listeners for `onmousemove` won't be called. However, if you + * register the `mousemove` on the document object, then it will of course + * catch all `mousemove`s. This along with iOS quirks, justifies restricting + * top-level listeners to the document object only, at least for these + * movement types of events and possibly all events. + * + * @see http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html + * + * Also, `keyup`/`keypress`/`keydown` do not bubble to the window on IE, but + * they bubble to document. + * + * @param {string} registrationName Name of listener (e.g. `onClick`). + * @param {object} mountAt Container where to mount the listener + */ +export function listenToEvent( + registrationName: string, + mountAt: Document | Element | Node, +): void { + const listeningSet = getListenerMapForElement(mountAt); + const dependencies = registrationNameDependencies[registrationName]; + + for (let i = 0; i < dependencies.length; i++) { + const dependency = dependencies[i]; + listenToTopLevelEvent(dependency, mountAt, listeningSet); + } +} + +export function listenToTopLevelEvent( + topLevelType: DOMTopLevelEventType, + mountAt: Document | Element | Node, + listenerMap: Map void)>, +): void { + if (!listenerMap.has(topLevelType)) { + switch (topLevelType) { + case TOP_SCROLL: + trapCapturedEvent(TOP_SCROLL, mountAt); + break; + case TOP_FOCUS: + case TOP_BLUR: + trapCapturedEvent(TOP_FOCUS, mountAt); + trapCapturedEvent(TOP_BLUR, mountAt); + // We set the flag for a single dependency later in this function, + // but this ensures we mark both as attached rather than just one. + listenerMap.set(TOP_BLUR, null); + listenerMap.set(TOP_FOCUS, null); + break; + case TOP_CANCEL: + case TOP_CLOSE: + if (isEventSupported(getRawEventName(topLevelType))) { + trapCapturedEvent(topLevelType, mountAt); + } + break; + case TOP_INVALID: + case TOP_SUBMIT: + case TOP_RESET: + // We listen to them on the target DOM elements. + // Some of them bubble so we don't want them to fire twice. + break; + default: + // By default, listen on the top level to all non-media events. + // Media events don't bubble so adding the listener wouldn't do anything. + const isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1; + if (!isMediaEvent) { + trapBubbledEvent(topLevelType, mountAt); + } + break; + } + listenerMap.set(topLevelType, null); + } +} + +export function isListeningToAllDependencies( + registrationName: string, + mountAt: Document | Element, +): boolean { + const listenerMap = getListenerMapForElement(mountAt); + const dependencies = registrationNameDependencies[registrationName]; + + for (let i = 0; i < dependencies.length; i++) { + const dependency = dependencies[i]; + if (!listenerMap.has(dependency)) { + return false; + } + } + return true; +} diff --git a/packages/react-dom/src/events/ReactBrowserEventEmitter.js b/packages/react-dom/src/events/ReactBrowserEventEmitter.js deleted file mode 100644 index 3cc7aae2f2ccf..0000000000000 --- a/packages/react-dom/src/events/ReactBrowserEventEmitter.js +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; -import type {DOMTopLevelEventType} from 'legacy-events/TopLevelEventTypes'; -import { - TOP_BLUR, - TOP_CANCEL, - TOP_CLOSE, - TOP_FOCUS, - TOP_INVALID, - TOP_RESET, - TOP_SCROLL, - TOP_SUBMIT, - getRawEventName, - mediaEventTypes, -} from './DOMTopLevelEventTypes'; -import { - setEnabled, - isEnabled, - trapBubbledEvent, - trapCapturedEvent, -} from './ReactDOMEventListener'; -import isEventSupported from './isEventSupported'; - -/** - * Summary of `ReactBrowserEventEmitter` event handling: - * - * - Top-level delegation is used to trap most native browser events. This - * may only occur in the main thread and is the responsibility of - * ReactDOMEventListener, which is injected and can therefore support - * pluggable event sources. This is the only work that occurs in the main - * thread. - * - * - We normalize and de-duplicate events to account for browser quirks. This - * may be done in the worker thread. - * - * - Forward these native events (with the associated top-level type used to - * trap it) to `EventPluginRegistry`, which in turn will ask plugins if they want - * to extract any synthetic events. - * - * - The `EventPluginRegistry` will then process each event by annotating them with - * "dispatches", a sequence of listeners and IDs that care about that event. - * - * - The `EventPluginRegistry` then dispatches the events. - * - * Overview of React and the event system: - * - * +------------+ . - * | DOM | . - * +------------+ . - * | . - * v . - * +------------+ . - * | ReactEvent | . - * | Listener | . - * +------------+ . +-----------+ - * | . +--------+|SimpleEvent| - * | . | |Plugin | - * +-----|------+ . v +-----------+ - * | | | . +--------------+ +------------+ - * | +-----------.--->|PluginRegistry| | Event | - * | | . | | +-----------+ | Propagators| - * | ReactEvent | . | | |TapEvent | |------------| - * | Emitter | . | |<---+|Plugin | |other plugin| - * | | . | | +-----------+ | utilities | - * | +-----------.--->| | +------------+ - * | | | . +--------------+ - * +-----|------+ . ^ +-----------+ - * | . | |Enter/Leave| - * + . +-------+|Plugin | - * +-------------+ . +-----------+ - * | application | . - * |-------------| . - * | | . - * | | . - * +-------------+ . - * . - * React Core . General Purpose Event Plugin System - */ - -const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; -// prettier-ignore -const elementListenerMap: - // $FlowFixMe Work around Flow bug - | WeakMap - | Map< - Document | Element | Node, - Map void)>, - > = new PossiblyWeakMap(); - -export function getListenerMapForElement( - element: Document | Element | Node, -): Map void)> { - let listenerMap = elementListenerMap.get(element); - if (listenerMap === undefined) { - listenerMap = new Map(); - elementListenerMap.set(element, listenerMap); - } - return listenerMap; -} - -/** - * We listen for bubbled touch events on the document object. - * - * Firefox v8.01 (and possibly others) exhibited strange behavior when - * mounting `onmousemove` events at some node that was not the document - * element. The symptoms were that if your mouse is not moving over something - * contained within that mount point (for example on the background) the - * top-level listeners for `onmousemove` won't be called. However, if you - * register the `mousemove` on the document object, then it will of course - * catch all `mousemove`s. This along with iOS quirks, justifies restricting - * top-level listeners to the document object only, at least for these - * movement types of events and possibly all events. - * - * @see http://www.quirksmode.org/blog/archives/2010/09/click_event_del.html - * - * Also, `keyup`/`keypress`/`keydown` do not bubble to the window on IE, but - * they bubble to document. - * - * @param {string} registrationName Name of listener (e.g. `onClick`). - * @param {object} mountAt Container where to mount the listener - */ -export function listenTo( - registrationName: string, - mountAt: Document | Element | Node, -): void { - const listeningSet = getListenerMapForElement(mountAt); - const dependencies = registrationNameDependencies[registrationName]; - - for (let i = 0; i < dependencies.length; i++) { - const dependency = dependencies[i]; - listenToTopLevel(dependency, mountAt, listeningSet); - } -} - -export function listenToTopLevel( - topLevelType: DOMTopLevelEventType, - mountAt: Document | Element | Node, - listenerMap: Map void)>, -): void { - if (!listenerMap.has(topLevelType)) { - switch (topLevelType) { - case TOP_SCROLL: - trapCapturedEvent(TOP_SCROLL, mountAt); - break; - case TOP_FOCUS: - case TOP_BLUR: - trapCapturedEvent(TOP_FOCUS, mountAt); - trapCapturedEvent(TOP_BLUR, mountAt); - // We set the flag for a single dependency later in this function, - // but this ensures we mark both as attached rather than just one. - listenerMap.set(TOP_BLUR, null); - listenerMap.set(TOP_FOCUS, null); - break; - case TOP_CANCEL: - case TOP_CLOSE: - if (isEventSupported(getRawEventName(topLevelType))) { - trapCapturedEvent(topLevelType, mountAt); - } - break; - case TOP_INVALID: - case TOP_SUBMIT: - case TOP_RESET: - // We listen to them on the target DOM elements. - // Some of them bubble so we don't want them to fire twice. - break; - default: - // By default, listen on the top level to all non-media events. - // Media events don't bubble so adding the listener wouldn't do anything. - const isMediaEvent = mediaEventTypes.indexOf(topLevelType) !== -1; - if (!isMediaEvent) { - trapBubbledEvent(topLevelType, mountAt); - } - break; - } - listenerMap.set(topLevelType, null); - } -} - -export function isListeningToAllDependencies( - registrationName: string, - mountAt: Document | Element, -): boolean { - const listenerMap = getListenerMapForElement(mountAt); - const dependencies = registrationNameDependencies[registrationName]; - - for (let i = 0; i < dependencies.length; i++) { - const dependency = dependencies[i]; - if (!listenerMap.has(dependency)) { - return false; - } - } - return true; -} - -export {setEnabled, isEnabled, trapBubbledEvent, trapCapturedEvent}; diff --git a/packages/react-dom/src/events/ReactDOMEventReplaying.js b/packages/react-dom/src/events/ReactDOMEventReplaying.js index b0c999caf4781..36a78b5d0fcf7 100644 --- a/packages/react-dom/src/events/ReactDOMEventReplaying.js +++ b/packages/react-dom/src/events/ReactDOMEventReplaying.js @@ -32,10 +32,7 @@ import { attemptToDispatchEvent, addResponderEventSystemEvent, } from './ReactDOMEventListener'; -import { - getListenerMapForElement, - listenToTopLevel, -} from './ReactBrowserEventEmitter'; +import {getListenerMapForElement} from './DOMEventListenerMap'; import { getInstanceFromNode, getClosestInstanceFromNode, @@ -120,6 +117,7 @@ import { TOP_BLUR, } from './DOMTopLevelEventTypes'; import {IS_REPLAYED} from 'legacy-events/EventSystemFlags'; +import {listenToTopLevelEvent} from './DOMEventPluginSystem'; type QueuedReplayableEvent = {| blockedOn: null | Container | SuspenseInstance, @@ -217,7 +215,7 @@ function trapReplayableEvent( document: Document, listenerMap: Map void)>, ) { - listenToTopLevel(topLevelType, document, listenerMap); + listenToTopLevelEvent(topLevelType, document, listenerMap); if (enableDeprecatedFlareAPI) { // Trap events for the responder system. const topLevelTypeString = unsafeCastDOMTopLevelTypeToString(topLevelType); diff --git a/packages/react-dom/src/events/SelectEventPlugin.js b/packages/react-dom/src/events/SelectEventPlugin.js index b358e896eaf37..2c3da14bbe18a 100644 --- a/packages/react-dom/src/events/SelectEventPlugin.js +++ b/packages/react-dom/src/events/SelectEventPlugin.js @@ -22,11 +22,11 @@ import { TOP_MOUSE_UP, TOP_SELECTION_CHANGE, } from './DOMTopLevelEventTypes'; -import {isListeningToAllDependencies} from './ReactBrowserEventEmitter'; import getActiveElement from '../client/getActiveElement'; import {getNodeFromInstance} from '../client/ReactDOMComponentTree'; import {hasSelectionCapabilities} from '../client/ReactInputSelection'; import {DOCUMENT_NODE} from '../shared/HTMLNodeType'; +import {isListeningToAllDependencies} from './DOMEventPluginSystem'; const skipSelectionChangeEvent = canUseDOM && 'documentMode' in document && document.documentMode <= 11; From d5ddc16a3398c33d70489cee87f5176c85f3c9f5 Mon Sep 17 00:00:00 2001 From: Haseeb Furkhan Mohammed <61028636+hmohamme@users.noreply.github.com> Date: Tue, 18 Feb 2020 09:40:30 -0800 Subject: [PATCH 05/24] React developer tools extension for Microsoft Edge (#18041) * Port Chrome extension to Microsoft Edge --- .../deploy.edge.html | 8 +++ .../react-devtools-extensions/edge/README.md | 12 +++++ .../react-devtools-extensions/edge/build.js | 46 ++++++++++++++++ .../react-devtools-extensions/edge/deploy.js | 9 ++++ .../edge/manifest.json | 52 +++++++++++++++++++ .../react-devtools-extensions/edge/now.json | 5 ++ .../react-devtools-extensions/edge/test.js | 18 +++++++ .../react-devtools-extensions/package.json | 13 +++-- 8 files changed, 159 insertions(+), 4 deletions(-) create mode 100644 packages/react-devtools-extensions/deploy.edge.html create mode 100644 packages/react-devtools-extensions/edge/README.md create mode 100644 packages/react-devtools-extensions/edge/build.js create mode 100644 packages/react-devtools-extensions/edge/deploy.js create mode 100644 packages/react-devtools-extensions/edge/manifest.json create mode 100644 packages/react-devtools-extensions/edge/now.json create mode 100644 packages/react-devtools-extensions/edge/test.js diff --git a/packages/react-devtools-extensions/deploy.edge.html b/packages/react-devtools-extensions/deploy.edge.html new file mode 100644 index 0000000000000..10f51c570e633 --- /dev/null +++ b/packages/react-devtools-extensions/deploy.edge.html @@ -0,0 +1,8 @@ +
    +
  1. download extension
  2. +
  3. Double-click to extract
  4. +
  5. Navigate to edge://extensions/
  6. +
  7. Enable "Developer mode"
  8. +
  9. Click "LOAD UNPACKED"
  10. +
  11. Select extracted extension folder (ReactDevTools)
  12. +
\ No newline at end of file diff --git a/packages/react-devtools-extensions/edge/README.md b/packages/react-devtools-extensions/edge/README.md new file mode 100644 index 0000000000000..af7ff30a20405 --- /dev/null +++ b/packages/react-devtools-extensions/edge/README.md @@ -0,0 +1,12 @@ +# The Microsoft Edge extension + +The source code for this extension has moved to `shells/webextension`. + +Modify the source code there and then rebuild this extension by running `node build` from this directory or `yarn run build:extension:edge` from the root directory. + +## Testing in Microsoft Edge + +You can test a local build of the web extension like so: + + 1. Build the extension: `node build` + 1. Follow the on-screen instructions. diff --git a/packages/react-devtools-extensions/edge/build.js b/packages/react-devtools-extensions/edge/build.js new file mode 100644 index 0000000000000..21fa6e5383c22 --- /dev/null +++ b/packages/react-devtools-extensions/edge/build.js @@ -0,0 +1,46 @@ +#!/usr/bin/env node + +'use strict'; + +const chalk = require('chalk'); +const {execSync} = require('child_process'); +const {join} = require('path'); +const {argv} = require('yargs'); +const build = require('../build'); + +const main = async () => { + const {crx} = argv; + + await build('edge'); + + const cwd = join(__dirname, 'build'); + if (crx) { + const crxPath = join( + __dirname, + '..', + '..', + '..', + 'node_modules', + '.bin', + 'crx' + ); + + execSync(`${crxPath} pack ./unpacked -o ReactDevTools.crx`, { + cwd, + }); + } + + console.log(chalk.green('\nThe Microsoft Edge extension has been built!')); + + console.log(chalk.green('\nTo load this extension:')); + console.log(chalk.yellow('Navigate to edge://extensions/')); + console.log(chalk.yellow('Enable "Developer mode"')); + console.log(chalk.yellow('Click "LOAD UNPACKED"')); + console.log(chalk.yellow('Select extension folder - ' + cwd + '\\unpacked')); + + console.log(chalk.green('\nYou can test this build by running:')); + console.log(chalk.gray('\n# From the react-devtools root directory:')); + console.log('yarn run test:edge\n'); +}; + +main(); diff --git a/packages/react-devtools-extensions/edge/deploy.js b/packages/react-devtools-extensions/edge/deploy.js new file mode 100644 index 0000000000000..33d102c688468 --- /dev/null +++ b/packages/react-devtools-extensions/edge/deploy.js @@ -0,0 +1,9 @@ +#!/usr/bin/env node + +'use strict'; + +const deploy = require('../deploy'); + +const main = async () => await deploy('edge'); + +main(); diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json new file mode 100644 index 0000000000000..46d683f81e3eb --- /dev/null +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -0,0 +1,52 @@ +{ + "manifest_version": 2, + "name": "React Developer Tools", + "description": "Adds React debugging tools to the Microsoft Edge Developer Tools.", + "version": "4.4.0", + "version_name": "4.4.0", + + "minimum_chrome_version": "49", + + "icons": { + "16": "icons/16-production.png", + "32": "icons/32-production.png", + "48": "icons/48-production.png", + "128": "icons/128-production.png" + }, + + "browser_action": { + "default_icon": { + "16": "icons/16-disabled.png", + "32": "icons/32-disabled.png", + "48": "icons/48-disabled.png", + "128": "icons/128-disabled.png" + }, + + "default_popup": "popups/disabled.html" + }, + + "devtools_page": "main.html", + + "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "web_accessible_resources": [ + "main.html", + "panel.html", + "build/react_devtools_backend.js", + "build/renderer.js" + ], + + "background": { + "scripts": ["build/background.js"], + "persistent": false + }, + + "permissions": ["file:///*", "http://*/*", "https://*/*"], + + "content_scripts": [ + { + "matches": [""], + "js": ["build/injectGlobalHook.js"], + "run_at": "document_start" + } + ] +} diff --git a/packages/react-devtools-extensions/edge/now.json b/packages/react-devtools-extensions/edge/now.json new file mode 100644 index 0000000000000..6faa13a7efefd --- /dev/null +++ b/packages/react-devtools-extensions/edge/now.json @@ -0,0 +1,5 @@ +{ + "name": "react-devtools-experimental-edge", + "alias": ["react-devtools-experimental-edge"], + "files": ["index.html", "ReactDevTools.zip"] +} diff --git a/packages/react-devtools-extensions/edge/test.js b/packages/react-devtools-extensions/edge/test.js new file mode 100644 index 0000000000000..b9c4f6a7833d1 --- /dev/null +++ b/packages/react-devtools-extensions/edge/test.js @@ -0,0 +1,18 @@ +#!/usr/bin/env node + +'use strict'; + +const edge = require('windows-edge'); + +const START_URL = 'https://facebook.github.io/react/'; + +edge({uri: START_URL}, (err, ps) => { + if (err) throw err; + ps.on('error', console.error); + ps.on('exit', code => { + // Browser exited + }); + setTimeout(() => { + ps.kill(); + }, 2000); +}); diff --git a/packages/react-devtools-extensions/package.json b/packages/react-devtools-extensions/package.json index ce42fc7799f87..5ccaeef289a61 100644 --- a/packages/react-devtools-extensions/package.json +++ b/packages/react-devtools-extensions/package.json @@ -3,15 +3,19 @@ "version": "0.0.0", "private": true, "scripts": { - "build": "cross-env NODE_ENV=production yarn run build:chrome && yarn run build:firefox", - "build:dev": "cross-env NODE_ENV=development yarn run build:chrome && yarn run build:firefox", + "build": "cross-env NODE_ENV=production yarn run build:chrome && yarn run build:firefox && yarn run build:edge", + "build:dev": "cross-env NODE_ENV=development yarn run build:chrome && yarn run build:firefox && yarn run build:edge", "build:chrome": "cross-env NODE_ENV=production node ./chrome/build", "build:chrome:crx": "cross-env NODE_ENV=production node ./chrome/build --crx", "build:chrome:dev": "cross-env NODE_ENV=development node ./chrome/build", "build:firefox": "cross-env NODE_ENV=production node ./firefox/build", "build:firefox:dev": "cross-env NODE_ENV=development node ./firefox/build", + "build:edge": "cross-env NODE_ENV=production node ./edge/build", + "build:edge:crx": "cross-env NODE_ENV=production node ./edge/build --crx", + "build:edge:dev": "cross-env NODE_ENV=development node ./edge/build", "test:chrome": "node ./chrome/test", - "test:firefox": "node ./firefox/test" + "test:firefox": "node ./firefox/test", + "test:edge": "node ./edge/test" }, "devDependencies": { "@babel/core": "^7.1.6", @@ -37,6 +41,7 @@ "web-ext": "^3.0.0", "webpack": "^4.41.2", "webpack-cli": "^3.3.10", - "webpack-dev-server": "^3.3.1" + "webpack-dev-server": "^3.3.1", + "windows-edge": "^1.0.1" } } From 90be006da8e231279151e7b1518bb64c62e26851 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 18 Feb 2020 09:40:59 -0800 Subject: [PATCH 06/24] Updated Yarn lockfile --- yarn.lock | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 86 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index dd15271097244..1456c34cc4e42 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3171,7 +3171,7 @@ buffer-alloc-unsafe@^1.1.0: resolved "https://registry.yarnpkg.com/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz#bd7dc26ae2972d0eda253be061dba992349c19f0" integrity sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg== -buffer-alloc@^1.2.0: +buffer-alloc@^1.1.0, buffer-alloc@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/buffer-alloc/-/buffer-alloc-1.2.0.tgz#890dd90d923a873e08e10e5fd51a57e5b7cce0ec" integrity sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow== @@ -3837,7 +3837,7 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s= -concat-stream@1.6.2, concat-stream@^1.4.7, concat-stream@^1.5.0, concat-stream@^1.5.2: +concat-stream@1.6.2, concat-stream@^1.4.7, concat-stream@^1.4.8, concat-stream@^1.5.0, concat-stream@^1.5.2: version "1.6.2" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== @@ -4228,6 +4228,19 @@ cssstyle@^2.0.0: dependencies: cssom "~0.3.6" +csv-parser@^1.6.0: + version "1.12.1" + resolved "https://registry.yarnpkg.com/csv-parser/-/csv-parser-1.12.1.tgz#391e1ef961b1f9dcb4c7c0f82eb450a1bd916158" + integrity sha512-r45M92nLnGP246ot0Yo5RvbiiMF5Bw/OTIdWJ3OQ4Vbv4hpOeoXVIPxdSmUw+fPJlQOseY+iigJyLSfPMIrddQ== + dependencies: + buffer-alloc "^1.1.0" + buffer-from "^1.0.0" + generate-function "^1.0.1" + generate-object-property "^1.0.0" + inherits "^2.0.1" + minimist "^1.2.0" + ndjson "^1.4.0" + currently-unhandled@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/currently-unhandled/-/currently-unhandled-0.4.1.tgz#988df33feab191ef799a61369dd76c17adf957ea" @@ -6047,7 +6060,7 @@ fresh@0.5.2: resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha1-PYyt2Q2XZWn6g1qx+OSyOhBWBac= -from2@^2.1.0: +from2@^2.1.0, from2@^2.1.1: version "2.3.0" resolved "https://registry.yarnpkg.com/from2/-/from2-2.3.0.tgz#8bfb5502bde4a4d36cfdeea007fcca21d7e382af" integrity sha1-i/tVAr3kpNNs/e6gB/zKIdfjgq8= @@ -6170,12 +6183,17 @@ gauge@~2.7.3: strip-ansi "^3.0.1" wide-align "^1.1.0" +generate-function@^1.0.1: + version "1.1.0" + resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-1.1.0.tgz#54c21b080192b16d9877779c5bb81666e772365f" + integrity sha1-VMIbCAGSsW2Yd3ecW7gWZudyNl8= + generate-function@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/generate-function/-/generate-function-2.0.0.tgz#6858fe7c0969b7d4e9093337647ac79f60dfbe74" integrity sha1-aFj+fAlpt9TpCTM3ZHrHn2DfvnQ= -generate-object-property@^1.1.0: +generate-object-property@^1.0.0, generate-object-property@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/generate-object-property/-/generate-object-property-1.2.0.tgz#9c0e1c40308ce804f4783618b937fa88f99d50d0" integrity sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA= @@ -7136,6 +7154,13 @@ interpret@1.2.0, interpret@^1.0.0: resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" integrity sha512-mT34yGKMNceBQUoVn7iCDKDntA7SC6gycMAWzGx1z/CMCTV7b2AAtXlo3nRyHZ1FelRkQbQjprHSYGwzLtkVbw== +into-stream@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/into-stream/-/into-stream-2.0.1.tgz#db9b003694453eae091d8a5c84cc11507b781d31" + integrity sha1-25sANpRFPq4JHYpchMwRUHt4HTE= + dependencies: + from2 "^2.1.1" + invariant@^2.2.2, invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -9249,6 +9274,25 @@ ncp@^2.0.0, ncp@~2.0.0: resolved "https://registry.yarnpkg.com/ncp/-/ncp-2.0.0.tgz#195a21d6c46e361d2fb1281ba38b91e9df7bdbb3" integrity sha1-GVoh1sRuNh0vsSgbo4uR6d9727M= +ndjson@^1.4.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/ndjson/-/ndjson-1.5.0.tgz#ae603b36b134bcec347b452422b0bf98d5832ec8" + integrity sha1-rmA7NrE0vOw0e0UkIrC/mNWDLsg= + dependencies: + json-stringify-safe "^5.0.1" + minimist "^1.2.0" + split2 "^2.1.0" + through2 "^2.0.3" + +neat-csv@^1.0.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/neat-csv/-/neat-csv-1.1.0.tgz#357a412f84b55b21160259945def3a70be30301c" + integrity sha1-NXpBL4S1WyEWAlmUXe86cL4wMBw= + dependencies: + concat-stream "^1.4.8" + csv-parser "^1.6.0" + into-stream "^2.0.0" + needle@^2.2.1, needle@^2.2.4: version "2.4.0" resolved "https://registry.yarnpkg.com/needle/-/needle-2.4.0.tgz#6833e74975c444642590e15a750288c5f939b57c" @@ -10232,7 +10276,7 @@ picomatch@^2.0.4, picomatch@^2.0.5: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.1.tgz#21bac888b6ed8601f831ce7816e335bc779f0a4a" integrity sha512-ISBaA8xQNmwELC7eOjqFKMESB2VIqt4PPDD0nsS95b/9dZXvVKOlz9keMSnoGGKcOHXfTvDD6WMaRoSc9UuhRA== -pify@^2.0.0: +pify@^2.0.0, pify@^2.2.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw= @@ -11565,6 +11609,11 @@ schema-utils@^2.0.1: ajv "^6.1.0" ajv-keywords "^3.1.0" +sec@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/sec/-/sec-1.0.0.tgz#033d60a3ad20ecf2e00940d14f97823465774335" + integrity sha1-Az1go60g7PLgCUDRT5eCNGV3QzU= + secure-keys@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/secure-keys/-/secure-keys-1.0.0.tgz#f0c82d98a3b139a8776a8808050b824431087fca" @@ -12306,6 +12355,13 @@ split-string@^3.0.1, split-string@^3.0.2: dependencies: extend-shallow "^3.0.0" +split2@^2.1.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/split2/-/split2-2.2.0.tgz#186b2575bcf83e85b7d18465756238ee4ee42493" + integrity sha512-RAb22TG39LhI31MbreBgIuKiIKhVsawfTgEGqKHTK87aG+ul/PB8Sqoi3I7kVdRWiCfrKxK3uo4/YUkpNvhPbw== + dependencies: + through2 "^2.0.2" + split@~0.3.3: version "0.3.3" resolved "https://registry.yarnpkg.com/split/-/split-0.3.3.tgz#cd0eea5e63a211dfff7eb0f091c4133e2d0dd28f" @@ -12768,6 +12824,16 @@ targz@^1.0.1: dependencies: tar-fs "^1.8.1" +tasklist@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tasklist/-/tasklist-2.1.0.tgz#53382e962e090adaf4c67d255057a97eec616e54" + integrity sha1-Uzguli4JCtr0xn0lUFepfuxhblQ= + dependencies: + neat-csv "^1.0.0" + pify "^2.2.0" + pinkie-promise "^2.0.0" + sec "^1.0.0" + temp-dir@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" @@ -12886,6 +12952,14 @@ through2@^2.0.0, through2@^2.0.1, through2@~2.0.0: readable-stream "^2.1.5" xtend "~4.0.1" +through2@^2.0.2, through2@^2.0.3: + version "2.0.5" + resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd" + integrity sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ== + dependencies: + readable-stream "~2.3.6" + xtend "~4.0.1" + through2@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/through2/-/through2-3.0.1.tgz#39276e713c3302edf9e388dd9c812dd3b825bd5a" @@ -13839,6 +13913,13 @@ window-size@^0.1.4: resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.1.4.tgz#f8e1aa1ee5a53ec5bf151ffa09742a6ad7697876" integrity sha1-+OGqHuWlPsW/FR/6CXQqatdpeHY= +windows-edge@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/windows-edge/-/windows-edge-1.0.1.tgz#ecb8dbf56b380ec226fbf9874db5b977e7ec03ad" + integrity sha1-7Ljb9Ws4DsIm+/mHTbW5d+fsA60= + dependencies: + tasklist "^2.1.0" + windows-release@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/windows-release/-/windows-release-3.2.0.tgz#8122dad5afc303d833422380680a79cdfa91785f" From 48c4867d745bbf91ae73892545960c0979c2dbf7 Mon Sep 17 00:00:00 2001 From: Ryota Murakami Date: Wed, 19 Feb 2020 03:30:00 +0900 Subject: [PATCH 07/24] Update issue templates to directly link to relevant sources (#18039) GitHub supports linking to off-site sources for certain types of issue. --- .github/ISSUE_TEMPLATE/config.yml | 7 ++++++ .github/ISSUE_TEMPLATE/documentation.md | 13 ----------- .github/ISSUE_TEMPLATE/question.md | 29 ------------------------- 3 files changed, 7 insertions(+), 42 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml delete mode 100644 .github/ISSUE_TEMPLATE/documentation.md delete mode 100644 .github/ISSUE_TEMPLATE/question.md diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000..e5bb31b2b3787 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +contact_links: + - name: 📃 Documentation Issue + url: https://github.com/reactjs/reactjs.org/issues/new + about: This issue tracker is not for documentation issues. Please file documentation issues here. + - name: 🤔 Questions and Help + url: https://reactjs.org/community/support.html + about: This issue tracker is not for support questions. Please refer to the React community's help and discussion forums. diff --git a/.github/ISSUE_TEMPLATE/documentation.md b/.github/ISSUE_TEMPLATE/documentation.md deleted file mode 100644 index 57c380e2ac97a..0000000000000 --- a/.github/ISSUE_TEMPLATE/documentation.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -name: "📃 Documentation Issue" -about: This issue tracker is not for documentation issues. Please file documentation issues at https://github.com/reactjs/reactjs.org. -title: 'Docs: ' -labels: 'Resolution: Invalid' - ---- - -🚨 This issue tracker is not for documentation issues. 🚨 - -The React website is hosted on a separate repository. You may let the -team know about any issues with the documentation by opening an issue there: -- https://github.com/reactjs/reactjs.org/issues/new diff --git a/.github/ISSUE_TEMPLATE/question.md b/.github/ISSUE_TEMPLATE/question.md deleted file mode 100644 index 0131925d2c7a3..0000000000000 --- a/.github/ISSUE_TEMPLATE/question.md +++ /dev/null @@ -1,29 +0,0 @@ ---- -name: "🤔 Questions and Help" -about: This issue tracker is not for questions. Please ask questions at https://stackoverflow.com/questions/tagged/react. -title: 'Question: ' -labels: 'Resolution: Invalid, Type: Question' - ---- - -🚨 This issue tracker is not for questions. 🚨 - -As it happens, support requests that are created as issues are likely to be closed. We want to make sure you are able to find the help you seek. Please take a look at the following resources. - -## Coding Questions - -If you have a coding question related to React and React DOM, it might be better suited for Stack Overflow. It's a great place to browse through frequent questions about using React, as well as ask for help with specific questions. - -https://stackoverflow.com/questions/tagged/react - -## Talk to other React developers - -There are many online forums which are a great place for discussion about best practices and application architecture as well as the future of React. - -https://reactjs.org/community/support.html#popular-discussion-forums - -## Proposals - -If you'd like to discuss topics related to the future of React, or would like to propose a new feature or change before sending a pull request, please check out the discussions and proposals repository. - -https://github.com/reactjs/rfcs From 1a6d8179b6dd427fdf7ee50d5ac45ae5a40979eb Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 18 Feb 2020 18:50:38 +0000 Subject: [PATCH 08/24] [react-interactions] Ensure onBeforeBlur fires for hideInstance (#18064) --- .../src/client/ReactDOMHostConfig.js | 42 +++++++++--- .../__tests__/FocusWithin-test.internal.js | 64 +++++++++++++++++++ 2 files changed, 96 insertions(+), 10 deletions(-) diff --git a/packages/react-dom/src/client/ReactDOMHostConfig.js b/packages/react-dom/src/client/ReactDOMHostConfig.js index 0cf8db9da956a..4baee93d58b9d 100644 --- a/packages/react-dom/src/client/ReactDOMHostConfig.js +++ b/packages/react-dom/src/client/ReactDOMHostConfig.js @@ -58,6 +58,17 @@ import { } from '../events/DeprecatedDOMEventResponderSystem'; import {retryIfBlockedOn} from '../events/ReactDOMEventReplaying'; +import { + enableSuspenseServerRenderer, + enableDeprecatedFlareAPI, + enableFundamentalAPI, +} from 'shared/ReactFeatureFlags'; +import {HostComponent} from 'shared/ReactWorkTags'; +import { + RESPONDER_EVENT_SYSTEM, + IS_PASSIVE, +} from 'legacy-events/EventSystemFlags'; + export type Type = string; export type Props = { autoFocus?: boolean, @@ -112,16 +123,6 @@ type SelectionInformation = {| selectionRange: mixed, |}; -import { - enableSuspenseServerRenderer, - enableDeprecatedFlareAPI, - enableFundamentalAPI, -} from 'shared/ReactFeatureFlags'; -import { - RESPONDER_EVENT_SYSTEM, - IS_PASSIVE, -} from 'legacy-events/EventSystemFlags'; - let SUPPRESS_HYDRATION_WARNING; if (__DEV__) { SUPPRESS_HYDRATION_WARNING = 'suppressHydrationWarning'; @@ -584,7 +585,28 @@ export function clearSuspenseBoundaryFromContainer( retryIfBlockedOn(container); } +function instanceContainsElem(instance: Instance, element: HTMLElement) { + let fiber = getClosestInstanceFromNode(element); + while (fiber !== null) { + if (fiber.tag === HostComponent && fiber.stateNode === element) { + return true; + } + fiber = fiber.return; + } + return false; +} + export function hideInstance(instance: Instance): void { + // Ensure we trigger `onBeforeBlur` if the active focused elment + // is ether the instance of a child or the instance. We need + // to traverse the Fiber tree here rather than use node.contains() + // as the child node might be inside a Portal. + if (enableDeprecatedFlareAPI && selectionInformation) { + const focusedElem = selectionInformation.focusedElem; + if (focusedElem !== null && instanceContainsElem(instance, focusedElem)) { + dispatchBeforeDetachedBlur(((focusedElem: any): HTMLElement)); + } + } // TODO: Does this work for all element types? What about MathML? Should we // pass host context to this method? instance = ((instance: any): HTMLElement); diff --git a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js index 0c2b783b03c0e..647527e4e4417 100644 --- a/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js +++ b/packages/react-interactions/events/src/dom/__tests__/FocusWithin-test.internal.js @@ -16,6 +16,7 @@ let ReactFeatureFlags; let ReactDOM; let FocusWithinResponder; let useFocusWithin; +let Scheduler; const initializeModules = hasPointerEvents => { setPointerEvent(hasPointerEvents); @@ -27,6 +28,7 @@ const initializeModules = hasPointerEvents => { FocusWithinResponder = require('react-interactions/events/focus') .FocusWithinResponder; useFocusWithin = require('react-interactions/events/focus').useFocusWithin; + Scheduler = require('scheduler'); }; const forcePointerEvents = true; @@ -336,6 +338,68 @@ describe.each(table)('FocusWithin responder', hasPointerEvents => { expect.objectContaining({isTargetAttached: false}), ); }); + + it.experimental( + 'is called after a focused suspended element is hidden', + () => { + const Suspense = React.Suspense; + let suspend = false; + let resolve; + let promise = new Promise(resolvePromise => (resolve = resolvePromise)); + + function Child() { + if (suspend) { + throw promise; + } else { + return ; + } + } + + const Component = ({show}) => { + const listener = useFocusWithin({ + onBeforeBlurWithin, + onBlurWithin, + }); + + return ( +
+ + + +
+ ); + }; + + const container2 = document.createElement('div'); + document.body.appendChild(container2); + + let root = ReactDOM.createRoot(container2); + root.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + expect(container2.innerHTML).toBe('
'); + + const inner = innerRef.current; + const target = createEventTarget(inner); + target.keydown({key: 'Tab'}); + target.focus(); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(0); + expect(onBlurWithin).toHaveBeenCalledTimes(0); + + suspend = true; + root.render(); + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + expect(container2.innerHTML).toBe( + '
Loading...
', + ); + expect(onBeforeBlurWithin).toHaveBeenCalledTimes(1); + expect(onBlurWithin).toHaveBeenCalledTimes(1); + resolve(); + + document.body.removeChild(container2); + }, + ); }); it('expect displayName to show up for event component', () => { From 56d8a73affad624ee4d48f1685e0a92adce0bd9c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Tue, 18 Feb 2020 13:43:16 -0800 Subject: [PATCH 09/24] [www] Disable Scheduler `timeout` w/ dynamic flag (#18069) Before attempting to land an expiration times refactor, I want to see if this particular change will impact performance (either positively or negatively). I will test this with a GK. --- packages/shared/forks/ReactFeatureFlags.www.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index eff0275c99f19..74394efdab883 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -17,6 +17,7 @@ export const { enableTrustedTypesIntegration, deferPassiveEffectCleanupDuringUnmount, warnAboutShorthandPropertyCollision, + disableSchedulerTimeoutBasedOnReactExpirationTime, } = require('ReactFeatureFlags'); // On WWW, __EXPERIMENTAL__ is used for a new modern build. @@ -39,7 +40,6 @@ export const warnAboutDeprecatedLifecycles = true; export const disableLegacyContext = __EXPERIMENTAL__; export const warnAboutStringRefs = false; export const warnAboutDefaultPropsOnFunctionComponents = false; -export const disableSchedulerTimeoutBasedOnReactExpirationTime = false; export const enableTrainModelFix = true; From 691096c95d1019f57e0da2c9a060c5e094b7c586 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 18 Feb 2020 14:19:43 -0800 Subject: [PATCH 10/24] Split recent passive effects changes into 2 flags (#18030) * Split recent passive effects changes into 2 flags Separate flags can now be used to opt passive effects into: 1) Deferring destroy functions on unmount to subsequent passive effects flush 2) Running all destroy functions (for all fibers) before create functions This allows us to test the less risky feature (2) separately from the more risky one. * deferPassiveEffectCleanupDuringUnmount is ignored unless runAllPassiveEffectDestroysBeforeCreates is true --- .../src/ReactFiberCommitWork.js | 10 +- .../src/ReactFiberWorkLoop.js | 8 +- ...eactHooksWithNoopRenderer-test.internal.js | 5132 ++++++++-------- ...tSuspenseWithNoopRenderer-test.internal.js | 5333 +++++++++-------- packages/shared/ReactFeatureFlags.js | 9 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.persistent.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 3 +- 12 files changed, 5400 insertions(+), 5101 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.js b/packages/react-reconciler/src/ReactFiberCommitWork.js index 9d9d220f33d52..ba682cc93f53c 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.js @@ -34,6 +34,7 @@ import { enableFundamentalAPI, enableSuspenseCallback, enableScopeAPI, + runAllPassiveEffectDestroysBeforeCreates, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -398,7 +399,7 @@ function commitHookEffectListMount(tag: number, finishedWork: Fiber) { } function schedulePassiveEffects(finishedWork: Fiber) { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { const updateQueue: FunctionComponentUpdateQueue | null = (finishedWork.updateQueue: any); let lastEffect = updateQueue !== null ? updateQueue.lastEffect : null; if (lastEffect !== null) { @@ -456,7 +457,7 @@ function commitLifeCycles( // by a create function in another component during the same commit. commitHookEffectListMount(HookLayout | HookHasEffect, finishedWork); - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { schedulePassiveEffects(finishedWork); } return; @@ -795,7 +796,10 @@ function commitUnmount( if (lastEffect !== null) { const firstEffect = lastEffect.next; - if (deferPassiveEffectCleanupDuringUnmount) { + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { let effect = firstEffect; do { const {destroy, tag} = effect; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f647ccb88b2ce..116bb2e99cf60 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -18,7 +18,7 @@ import type {Effect as HookEffect} from './ReactFiberHooks'; import { warnAboutDeprecatedLifecycles, - deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, enableUserTimingAPI, enableSuspenseServerRenderer, replayFailedUnitOfWorkWithInvokeGuardedCallback, @@ -2174,7 +2174,7 @@ export function enqueuePendingPassiveHookEffectMount( fiber: Fiber, effect: HookEffect, ): void { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { pendingPassiveHookEffectsMount.push(effect, fiber); if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects = true; @@ -2190,7 +2190,7 @@ export function enqueuePendingPassiveHookEffectUnmount( fiber: Fiber, effect: HookEffect, ): void { - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { pendingPassiveHookEffectsUnmount.push(effect, fiber); if (!rootDoesHavePassiveEffects) { rootDoesHavePassiveEffects = true; @@ -2224,7 +2224,7 @@ function flushPassiveEffectsImpl() { executionContext |= CommitContext; const prevInteractions = pushInteractions(root); - if (deferPassiveEffectCleanupDuringUnmount) { + if (runAllPassiveEffectDestroysBeforeCreates) { // It's important that ALL pending passive effect destroy functions are called // before ANY passive effect create functions are called. // Otherwise effects in sibling components might interfere with each other. diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js index 438a748fb75a4..6e50579addd31 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.internal.js @@ -34,2759 +34,2953 @@ let forwardRef; let memo; let act; -describe('ReactHooksWithNoopRenderer', () => { - beforeEach(() => { - jest.resetModules(); - jest.useFakeTimers(); - - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.enableSchedulerTracing = true; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = true; - React = require('react'); - ReactNoop = require('react-noop-renderer'); - Scheduler = require('scheduler'); - SchedulerTracing = require('scheduler/tracing'); - ReactCache = require('react-cache'); - useState = React.useState; - useReducer = React.useReducer; - useEffect = React.useEffect; - useLayoutEffect = React.useLayoutEffect; - useCallback = React.useCallback; - useMemo = React.useMemo; - useRef = React.useRef; - useImperativeHandle = React.useImperativeHandle; - forwardRef = React.forwardRef; - memo = React.memo; - useTransition = React.useTransition; - useDeferredValue = React.useDeferredValue; - Suspense = React.Suspense; - act = ReactNoop.act; - - TextResource = ReactCache.unstable_createResource( - ([text, ms = 0]) => { - return new Promise((resolve, reject) => - setTimeout(() => { - Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); - resolve(text); - }, ms), +function loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, +}) { + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.enableSchedulerTracing = true; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; + ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates; + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + SchedulerTracing = require('scheduler/tracing'); + ReactCache = require('react-cache'); + useState = React.useState; + useReducer = React.useReducer; + useEffect = React.useEffect; + useLayoutEffect = React.useLayoutEffect; + useCallback = React.useCallback; + useMemo = React.useMemo; + useRef = React.useRef; + useImperativeHandle = React.useImperativeHandle; + forwardRef = React.forwardRef; + memo = React.memo; + useTransition = React.useTransition; + useDeferredValue = React.useDeferredValue; + Suspense = React.Suspense; + act = ReactNoop.act; +} + +[true, false].forEach(deferPassiveEffectCleanupDuringUnmount => { + [true, false].forEach(runAllPassiveEffectDestroysBeforeCreates => { + describe(`ReactHooksWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { + beforeEach(() => { + jest.resetModules(); + jest.useFakeTimers(); + + loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, + }); + + TextResource = ReactCache.unstable_createResource( + ([text, ms = 0]) => { + return new Promise((resolve, reject) => + setTimeout(() => { + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); + resolve(text); + }, ms), + ); + }, + ([text, ms]) => text, ); - }, - ([text, ms]) => text, - ); - }); + }); - function span(prop) { - return {type: 'span', hidden: false, children: [], prop}; - } - - function hiddenSpan(prop) { - return {type: 'span', children: [], prop, hidden: true}; - } - - function Text(props) { - Scheduler.unstable_yieldValue(props.text); - return ; - } - - function AsyncText(props) { - const text = props.text; - try { - TextResource.read([props.text, props.ms]); - Scheduler.unstable_yieldValue(text); - return ; - } catch (promise) { - if (typeof promise.then === 'function') { - Scheduler.unstable_yieldValue(`Suspend! [${text}]`); - } else { - Scheduler.unstable_yieldValue(`Error! [${text}]`); + function span(prop) { + return {type: 'span', hidden: false, children: [], prop}; } - throw promise; - } - } - - function advanceTimers(ms) { - // Note: This advances Jest's virtual time but not React's. Use - // ReactNoop.expire for that. - if (typeof ms !== 'number') { - throw new Error('Must specify ms'); - } - jest.advanceTimersByTime(ms); - // Wait until the end of the current tick - // We cannot use a timer since we're faking them - return Promise.resolve().then(() => {}); - } - - it('resumes after an interruption', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - - // Initial mount - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - // Schedule some updates - ReactNoop.batchedUpdates(() => { - counter.current.updateCount(1); - counter.current.updateCount(count => count + 10); - }); - // Partially flush without committing - expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; + } - // Interrupt with a high priority update - ReactNoop.flushSync(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded(['Total: 0']); + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; + } - // Resume rendering - expect(Scheduler).toFlushAndYield(['Total: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); - }); + function AsyncText(props) { + const text = props.text; + try { + TextResource.read([props.text, props.ms]); + Scheduler.unstable_yieldValue(text); + return ; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; + } + } - it('throws inside class components', () => { - class BadCounter extends React.Component { - render() { - const [count] = useState(0); - return ; + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); } - } - ReactNoop.render(); - - expect(Scheduler).toFlushAndThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + - ' one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', - ); - - // Confirm that a subsequent hook works properly. - function GoodCounter(props, ref) { - const [count] = useState(props.initialCount); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([10]); - }); - it('throws inside module-style components', () => { - function Counter() { - return { - render() { - const [count] = useState(0); - return ; - }, - }; - } - ReactNoop.render(); - expect(() => - expect(Scheduler).toFlushAndThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen ' + - 'for one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', - ), - ).toErrorDev( - 'Warning: The component appears to be a function component that returns a class instance. ' + - 'Change Counter to a class that extends React.Component instead. ' + - "If you can't use a class try assigning the prototype on the function as a workaround. " + - '`Counter.prototype = React.Component.prototype`. ' + - "Don't use an arrow function since it cannot be called with `new` by React.", - ); - - // Confirm that a subsequent hook works properly. - function GoodCounter(props) { - const [count] = useState(props.initialCount); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([10]); - }); + it('resumes after an interruption', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); - it('throws when called outside the render phase', () => { - expect(() => useState(0)).toThrow( - 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + - ' one of the following reasons:\n' + - '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + - '2. You might be breaking the Rules of Hooks\n' + - '3. You might have more than one copy of React in the same app\n' + - 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', - ); - }); + // Initial mount + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - describe('useState', () => { - it('simple mount and update', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => counter.current.updateCount(1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - - act(() => counter.current.updateCount(count => count + 10)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - }); + // Schedule some updates + ReactNoop.batchedUpdates(() => { + counter.current.updateCount(1); + counter.current.updateCount(count => count + 10); + }); + + // Partially flush without committing + expect(Scheduler).toFlushAndYieldThrough(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - it('lazy state initializer', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(() => { - Scheduler.unstable_yieldValue('getInitialState'); - return props.initialState; + // Interrupt with a high priority update + ReactNoop.flushSync(() => { + ReactNoop.render(); }); - useImperativeHandle(ref, () => ({updateCount})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['getInitialState', 'Count: 42']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); - - act(() => counter.current.updateCount(7)); - expect(Scheduler).toHaveYielded(['Count: 7']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); - }); + expect(Scheduler).toHaveYielded(['Total: 0']); - it('multiple states', () => { - function Counter(props, ref) { - const [count, updateCount] = useState(0); - const [label, updateLabel] = useState('Count'); - useImperativeHandle(ref, () => ({updateCount, updateLabel})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + // Resume rendering + expect(Scheduler).toFlushAndYield(['Total: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Total: 11')]); + }); - act(() => counter.current.updateCount(7)); - expect(Scheduler).toHaveYielded(['Count: 7']); + it('throws inside class components', () => { + class BadCounter extends React.Component { + render() { + const [count] = useState(0); + return ; + } + } + ReactNoop.render(); + + expect(Scheduler).toFlushAndThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ); - act(() => counter.current.updateLabel('Total')); - expect(Scheduler).toHaveYielded(['Total: 7']); - }); + // Confirm that a subsequent hook works properly. + function GoodCounter(props, ref) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); - it('returns the same updater function every time', () => { - let updaters = []; - function Counter() { - const [count, updateCount] = useState(0); - updaters.push(updateCount); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + it('throws inside module-style components', () => { + function Counter() { + return { + render() { + const [count] = useState(0); + return ; + }, + }; + } + ReactNoop.render(); + expect(() => + expect(Scheduler).toFlushAndThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen ' + + 'for one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ), + ).toErrorDev( + 'Warning: The component appears to be a function component that returns a class instance. ' + + 'Change Counter to a class that extends React.Component instead. ' + + "If you can't use a class try assigning the prototype on the function as a workaround. " + + '`Counter.prototype = React.Component.prototype`. ' + + "Don't use an arrow function since it cannot be called with `new` by React.", + ); - act(() => updaters[0](1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Confirm that a subsequent hook works properly. + function GoodCounter(props) { + const [count] = useState(props.initialCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([10]); + }); - act(() => updaters[0](count => count + 10)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + it('throws when called outside the render phase', () => { + expect(() => useState(0)).toThrow( + 'Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for' + + ' one of the following reasons:\n' + + '1. You might have mismatching versions of React and the renderer (such as React DOM)\n' + + '2. You might be breaking the Rules of Hooks\n' + + '3. You might have more than one copy of React in the same app\n' + + 'See https://fb.me/react-invalid-hook-call for tips about how to debug and fix this problem.', + ); + }); - expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); - }); + describe('useState', () => { + it('simple mount and update', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => counter.current.updateCount(1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + act(() => counter.current.updateCount(count => count + 10)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + }); - it('warns on set after unmount', () => { - let _updateCount; - function Counter(props, ref) { - const [, updateCount] = useState(0); - _updateCount = updateCount; - return null; - } + it('lazy state initializer', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(() => { + Scheduler.unstable_yieldValue('getInitialState'); + return props.initialState; + }); + useImperativeHandle(ref, () => ({updateCount})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['getInitialState', 'Count: 42']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 42')]); + + act(() => counter.current.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 7')]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushWithoutYielding(); - ReactNoop.render(null); - expect(Scheduler).toFlushWithoutYielding(); - expect(() => act(() => _updateCount(1))).toErrorDev( - "Warning: Can't perform a React state update on an unmounted " + - 'component. This is a no-op, but it indicates a memory leak in your ' + - 'application. To fix, cancel all subscriptions and asynchronous ' + - 'tasks in a useEffect cleanup function.\n' + - ' in Counter (at **)', - ); - }); + it('multiple states', () => { + function Counter(props, ref) { + const [count, updateCount] = useState(0); + const [label, updateLabel] = useState('Count'); + useImperativeHandle(ref, () => ({updateCount, updateLabel})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - it('works with memo', () => { - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - return ; - } - Counter = memo(Counter); + act(() => counter.current.updateCount(7)); + expect(Scheduler).toHaveYielded(['Count: 7']); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + act(() => counter.current.updateLabel('Total')); + expect(Scheduler).toHaveYielded(['Total: 7']); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + it('returns the same updater function every time', () => { + let updaters = []; + function Counter() { + const [count, updateCount] = useState(0); + updaters.push(updateCount); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - act(() => _updateCount(1)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - }); + act(() => updaters[0](1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - describe('updates during the render phase', () => { - it('restarts the render function and applies the new updates on top', () => { - function ScrollView({row: newRow}) { - let [isScrollingDown, setIsScrollingDown] = useState(false); - let [row, setRow] = useState(null); + act(() => updaters[0](count => count + 10)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - if (row !== newRow) { - // Row changed since last render. Update isScrollingDown. - setIsScrollingDown(row !== null && newRow > row); - setRow(newRow); - } + expect(updaters).toEqual([updaters[0], updaters[0], updaters[0]]); + }); - return ; - } + it('warns on set after unmount', () => { + let _updateCount; + function Counter(props, ref) { + const [, updateCount] = useState(0); + _updateCount = updateCount; + return null; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + ReactNoop.render(); + expect(Scheduler).toFlushWithoutYielding(); + ReactNoop.render(null); + expect(Scheduler).toFlushWithoutYielding(); + expect(() => act(() => _updateCount(1))).toErrorDev( + "Warning: Can't perform a React state update on an unmounted " + + 'component. This is a no-op, but it indicates a memory leak in your ' + + 'application. To fix, cancel all subscriptions and asynchronous ' + + 'tasks in a useEffect cleanup function.\n' + + ' in Counter (at **)', + ); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + it('works with memo', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + return ; + } + Counter = memo(Counter); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: true']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: true')]); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); + act(() => _updateCount(1)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Scrolling down: false']); - expect(ReactNoop.getChildren()).toEqual([span('Scrolling down: false')]); - }); + describe('updates during the render phase', () => { + it('restarts the render function and applies the new updates on top', () => { + function ScrollView({row: newRow}) { + let [isScrollingDown, setIsScrollingDown] = useState(false); + let [row, setRow] = useState(null); - it('keeps restarting until there are no more new updates', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - if (count < 3) { - setCount(count + 1); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } + if (row !== newRow) { + // Row changed since last render. Update isScrollingDown. + setIsScrollingDown(row !== null && newRow > row); + setRow(newRow); + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); + return ; + } - it('updates multiple times within same render function', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - if (count < 12) { - setCount(c => c + 1); - setCount(c => c + 1); - setCount(c => c + 1); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // Should increase by three each time - 'Render: 0', - 'Render: 3', - 'Render: 6', - 'Render: 9', - 'Render: 12', - 12, - ]); - expect(ReactNoop.getChildren()).toEqual([span(12)]); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - it('throws after too many iterations', () => { - function Counter({row: newRow}) { - let [count, setCount] = useState(0); - setCount(count + 1); - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - ReactNoop.render(); - expect(Scheduler).toFlushAndThrow( - 'Too many re-renders. React limits the number of renders to prevent ' + - 'an infinite loop.', - ); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - it('works with useReducer', () => { - function reducer(state, action) { - return action === 'increment' ? state + 1 : state; - } - function Counter({row: newRow}) { - let [count, dispatch] = useReducer(reducer, 0); - if (count < 3) { - dispatch('increment'); - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: true']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: true'), + ]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: 0', - 'Render: 1', - 'Render: 2', - 'Render: 3', - 3, - ]); - expect(ReactNoop.getChildren()).toEqual([span(3)]); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); - it('uses reducer passed at time of render, not time of dispatch', () => { - // This test is a bit contrived but it demonstrates a subtle edge case. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Scrolling down: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Scrolling down: false'), + ]); + }); - // Reducer A increments by 1. Reducer B increments by 10. - function reducerA(state, action) { - switch (action) { - case 'increment': - return state + 1; - case 'reset': - return 0; - } - } - function reducerB(state, action) { - switch (action) { - case 'increment': - return state + 10; - case 'reset': - return 0; - } - } + it('keeps restarting until there are no more new updates', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } - function Counter({row: newRow}, ref) { - let [reducer, setReducer] = useState(() => reducerA); - let [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - if (count < 20) { - dispatch('increment'); - // Swap reducers each time we increment - if (reducer === reducerA) { - setReducer(() => reducerB); - } else { - setReducer(() => reducerA); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); + + it('updates multiple times within same render function', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + if (count < 12) { + setCount(c => c + 1); + setCount(c => c + 1); + setCount(c => c + 1); + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; } - } - Scheduler.unstable_yieldValue('Render: ' + count); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // The count should increase by alternating amounts of 10 and 1 - // until we reach 21. - 'Render: 0', - 'Render: 10', - 'Render: 11', - 'Render: 21', - 21, - ]); - expect(ReactNoop.getChildren()).toEqual([span(21)]); - - // Test that it works on update, too. This time the log is a bit different - // because we started with reducerB instead of reducerA. - ReactNoop.act(() => { - counter.current.dispatch('reset'); - }); - ReactNoop.render(); - expect(Scheduler).toHaveYielded([ - 'Render: 0', - 'Render: 1', - 'Render: 11', - 'Render: 12', - 'Render: 22', - 22, - ]); - expect(ReactNoop.getChildren()).toEqual([span(22)]); - }); - it('discards render phase updates if something suspends', () => { - const thenable = {then() {}}; - function Foo({signal}) { - return ( - - - - ); - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // Should increase by three each time + 'Render: 0', + 'Render: 3', + 'Render: 6', + 'Render: 9', + 'Render: 12', + 12, + ]); + expect(ReactNoop.getChildren()).toEqual([span(12)]); + }); - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); - let [signal, setSignal] = useState(true); + it('throws after too many iterations', () => { + function Counter({row: newRow}) { + let [count, setCount] = useState(0); + setCount(count + 1); + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'Too many re-renders. React limits the number of renders to prevent ' + + 'an infinite loop.', + ); + }); - // Increment a counter every time the signal changes - if (signal !== newSignal) { - setCounter(c => c + 1); - setSignal(newSignal); - if (counter === 0) { - // We're suspending during a render that includes render phase - // updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!'); - throw thenable; + it('works with useReducer', () => { + function reducer(state, action) { + return action === 'increment' ? state + 1 : state; + } + function Counter({row: newRow}) { + let [count, dispatch] = useReducer(reducer, 0); + if (count < 3) { + dispatch('increment'); + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; } - } - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: 0', + 'Render: 1', + 'Render: 2', + 'Render: 3', + 3, + ]); + expect(ReactNoop.getChildren()).toEqual([span(3)]); + }); - const root = ReactNoop.createRoot(); - root.render(); + it('uses reducer passed at time of render, not time of dispatch', () => { + // This test is a bit contrived but it demonstrates a subtle edge case. + + // Reducer A increments by 1. Reducer B increments by 10. + function reducerA(state, action) { + switch (action) { + case 'increment': + return state + 1; + case 'reset': + return 0; + } + } + function reducerB(state, action) { + switch (action) { + case 'increment': + return state + 10; + case 'reset': + return 0; + } + } - expect(Scheduler).toFlushAndYield([0]); - expect(root).toMatchRenderedOutput(); + function Counter({row: newRow}, ref) { + let [reducer, setReducer] = useState(() => reducerA); + let [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + if (count < 20) { + dispatch('increment'); + // Swap reducers each time we increment + if (reducer === reducerA) { + setReducer(() => reducerB); + } else { + setReducer(() => reducerA); + } + } + Scheduler.unstable_yieldValue('Render: ' + count); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // The count should increase by alternating amounts of 10 and 1 + // until we reach 21. + 'Render: 0', + 'Render: 10', + 'Render: 11', + 'Render: 21', + 21, + ]); + expect(ReactNoop.getChildren()).toEqual([span(21)]); - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - expect(root).toMatchRenderedOutput(); + // Test that it works on update, too. This time the log is a bit different + // because we started with reducerB instead of reducerA. + ReactNoop.act(() => { + counter.current.dispatch('reset'); + }); + ReactNoop.render(); + expect(Scheduler).toHaveYielded([ + 'Render: 0', + 'Render: 1', + 'Render: 11', + 'Render: 12', + 'Render: 22', + 22, + ]); + expect(ReactNoop.getChildren()).toEqual([span(22)]); + }); - // Rendering again should suspend again. - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - }); + it('discards render phase updates if something suspends', () => { + const thenable = {then() {}}; + function Foo({signal}) { + return ( + + + + ); + } - it('discards render phase updates if something suspends, but not other updates in the same component', async () => { - const thenable = {then() {}}; - function Foo({signal}) { - return ( - - - - ); - } + function Bar({signal: newSignal}) { + let [counter, setCounter] = useState(0); + let [signal, setSignal] = useState(true); + + // Increment a counter every time the signal changes + if (signal !== newSignal) { + setCounter(c => c + 1); + setSignal(newSignal); + if (counter === 0) { + // We're suspending during a render that includes render phase + // updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue('Suspend!'); + throw thenable; + } + } + + return ; + } - let setLabel; - function Bar({signal: newSignal}) { - let [counter, setCounter] = useState(0); + const root = ReactNoop.createRoot(); + root.render(); - if (counter === 1) { - // We're suspending during a render that includes render phase - // updates. Those updates should not persist to the next render. - Scheduler.unstable_yieldValue('Suspend!'); - throw thenable; - } + expect(Scheduler).toFlushAndYield([0]); + expect(root).toMatchRenderedOutput(); - let [signal, setSignal] = useState(true); + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + expect(root).toMatchRenderedOutput(); - // Increment a counter every time the signal changes - if (signal !== newSignal) { - setCounter(c => c + 1); - setSignal(newSignal); - } + // Rendering again should suspend again. + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + }); - let [label, _setLabel] = useState('A'); - setLabel = _setLabel; + it('discards render phase updates if something suspends, but not other updates in the same component', async () => { + const thenable = {then() {}}; + function Foo({signal}) { + return ( + + + + ); + } - return ; - } + let setLabel; + function Bar({signal: newSignal}) { + let [counter, setCounter] = useState(0); - const root = ReactNoop.createRoot(); - root.render(); + if (counter === 1) { + // We're suspending during a render that includes render phase + // updates. Those updates should not persist to the next render. + Scheduler.unstable_yieldValue('Suspend!'); + throw thenable; + } - expect(Scheduler).toFlushAndYield(['A:0']); - expect(root).toMatchRenderedOutput(); + let [signal, setSignal] = useState(true); - await ReactNoop.act(async () => { - root.render(); - setLabel('B'); - }); - expect(Scheduler).toHaveYielded(['Suspend!']); - expect(root).toMatchRenderedOutput(); - - // Rendering again should suspend again. - root.render(); - expect(Scheduler).toFlushAndYield(['Suspend!']); - - // Flip the signal back to "cancel" the update. However, the update to - // label should still proceed. It shouldn't have been dropped. - root.render(); - expect(Scheduler).toFlushAndYield(['B:0']); - expect(root).toMatchRenderedOutput(); - }); + // Increment a counter every time the signal changes + if (signal !== newSignal) { + setCounter(c => c + 1); + setSignal(newSignal); + } - // TODO: This should probably warn - it.experimental('calling startTransition inside render phase', async () => { - let startTransition; - function App() { - let [counter, setCounter] = useState(0); - let [_startTransition] = useTransition(); - startTransition = _startTransition; - - if (counter === 0) { - startTransition(() => { - setCounter(c => c + 1); - }); - } + let [label, _setLabel] = useState('A'); + setLabel = _setLabel; - return ; - } + return ; + } - const root = ReactNoop.createRoot(); - root.render(); - expect(Scheduler).toFlushAndYield([1]); - expect(root).toMatchRenderedOutput(); - }); - }); + const root = ReactNoop.createRoot(); + root.render(); - describe('useReducer', () => { - it('simple mount and update', () => { - const INCREMENT = 'INCREMENT'; - const DECREMENT = 'DECREMENT'; - - function reducer(state, action) { - switch (action) { - case 'INCREMENT': - return state + 1; - case 'DECREMENT': - return state - 1; - default: - return state; - } - } + expect(Scheduler).toFlushAndYield(['A:0']); + expect(root).toMatchRenderedOutput(); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - act(() => counter.current.dispatch(INCREMENT)); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - act(() => { - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); + await ReactNoop.act(async () => { + root.render(); + setLabel('B'); + }); + expect(Scheduler).toHaveYielded(['Suspend!']); + expect(root).toMatchRenderedOutput(); + + // Rendering again should suspend again. + root.render(); + expect(Scheduler).toFlushAndYield(['Suspend!']); + + // Flip the signal back to "cancel" the update. However, the update to + // label should still proceed. It shouldn't have been dropped. + root.render(); + expect(Scheduler).toFlushAndYield(['B:0']); + expect(root).toMatchRenderedOutput(); + }); + + // TODO: This should probably warn + it.experimental( + 'calling startTransition inside render phase', + async () => { + let startTransition; + function App() { + let [counter, setCounter] = useState(0); + let [_startTransition] = useTransition(); + startTransition = _startTransition; + + if (counter === 0) { + startTransition(() => { + setCounter(c => c + 1); + }); + } + + return ; + } + + const root = ReactNoop.createRoot(); + root.render(); + expect(Scheduler).toFlushAndYield([1]); + expect(root).toMatchRenderedOutput(); + }, + ); }); - expect(Scheduler).toHaveYielded(['Count: -2']); - expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); - }); + describe('useReducer', () => { + it('simple mount and update', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } - it('lazy init', () => { - const INCREMENT = 'INCREMENT'; - const DECREMENT = 'DECREMENT'; - - function reducer(state, action) { - switch (action) { - case 'INCREMENT': - return state + 1; - case 'DECREMENT': - return state - 1; - default: - return state; - } - } + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + act(() => counter.current.dispatch(INCREMENT)); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + act(() => { + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + }); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, props, p => { - Scheduler.unstable_yieldValue('Init'); - return p.initialCount; + expect(Scheduler).toHaveYielded(['Count: -2']); + expect(ReactNoop.getChildren()).toEqual([span('Count: -2')]); }); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Init', 'Count: 10']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); - - act(() => counter.current.dispatch(INCREMENT)); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); - - act(() => { - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - counter.current.dispatch(DECREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 8']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); - }); + it('lazy init', () => { + const INCREMENT = 'INCREMENT'; + const DECREMENT = 'DECREMENT'; + + function reducer(state, action) { + switch (action) { + case 'INCREMENT': + return state + 1; + case 'DECREMENT': + return state - 1; + default: + return state; + } + } - // Regression test for https://github.com/facebook/react/issues/14360 - it('handles dispatches with mixed priorities', () => { - const INCREMENT = 'INCREMENT'; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, props, p => { + Scheduler.unstable_yieldValue('Init'); + return p.initialCount; + }); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Init', 'Count: 10']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 10')]); + + act(() => counter.current.dispatch(INCREMENT)); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 11')]); + + act(() => { + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + counter.current.dispatch(DECREMENT); + }); - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + expect(Scheduler).toHaveYielded(['Count: 8']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 8')]); + }); - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({dispatch})); - return ; - } + // Regression test for https://github.com/facebook/react/issues/14360 + it('handles dispatches with mixed priorities', () => { + const INCREMENT = 'INCREMENT'; - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({dispatch})); + return ; + } - ReactNoop.batchedUpdates(() => { - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - counter.current.dispatch(INCREMENT); - }); + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); - ReactNoop.flushSync(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(Scheduler).toFlushAndYield(['Count: 4']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 4')]); - }); - }); + ReactNoop.batchedUpdates(() => { + counter.current.dispatch(INCREMENT); + counter.current.dispatch(INCREMENT); + counter.current.dispatch(INCREMENT); + }); + + ReactNoop.flushSync(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - describe('useEffect', () => { - it('simple mount and update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); + expect(Scheduler).toFlushAndYield(['Count: 4']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 4')]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield(['Passive effect [0]']); }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield(['Passive effect [1]']); - }); - }); + describe('useEffect', () => { + it('simple mount and update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Passive effect [${props.count}]`); + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield(['Passive effect [0]']); + }); - it('flushes passive effects even with sibling deletions', () => { - function LayoutEffect(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue(`Layout effect`); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield(['Passive effect [1]']); + }); }); - return ; - } - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Passive effect`); - }, []); - return ; - } - let passive = ; - act(() => { - ReactNoop.render([, passive]); - expect(Scheduler).toFlushAndYieldThrough([ - 'Layout', - 'Passive', - 'Layout effect', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Layout'), - span('Passive'), - ]); - // Destroying the first child shouldn't prevent the passive effect from - // being executed - ReactNoop.render([passive]); - expect(Scheduler).toFlushAndYield(['Passive effect']); - expect(ReactNoop.getChildren()).toEqual([span('Passive')]); - }); - // exiting act calls flushPassiveEffects(), but there are none left to flush. - expect(Scheduler).toHaveYielded([]); - }); - it('flushes passive effects even if siblings schedule an update', () => { - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Passive effect'); - }); - return ; - } - function LayoutEffect(props) { - let [count, setCount] = useState(0); - useLayoutEffect(() => { - // Scheduling work shouldn't interfere with the queued passive effect - if (count === 0) { - setCount(1); - } - Scheduler.unstable_yieldValue('Layout effect ' + count); + it('flushes passive effects even with sibling deletions', () => { + function LayoutEffect(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`Layout effect`); + }); + return ; + } + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Passive effect`); + }, []); + return ; + } + let passive = ; + act(() => { + ReactNoop.render([, passive]); + expect(Scheduler).toFlushAndYieldThrough([ + 'Layout', + 'Passive', + 'Layout effect', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Layout'), + span('Passive'), + ]); + // Destroying the first child shouldn't prevent the passive effect from + // being executed + ReactNoop.render([passive]); + expect(Scheduler).toFlushAndYield(['Passive effect']); + expect(ReactNoop.getChildren()).toEqual([span('Passive')]); + }); + // exiting act calls flushPassiveEffects(), but there are none left to flush. + expect(Scheduler).toHaveYielded([]); }); - return ; - } - ReactNoop.render([, ]); + it('flushes passive effects even if siblings schedule an update', () => { + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect'); + }); + return ; + } + function LayoutEffect(props) { + let [count, setCount] = useState(0); + useLayoutEffect(() => { + // Scheduling work shouldn't interfere with the queued passive effect + if (count === 0) { + setCount(1); + } + Scheduler.unstable_yieldValue('Layout effect ' + count); + }); + return ; + } - act(() => { - expect(Scheduler).toFlushAndYield([ - 'Passive', - 'Layout', - 'Layout effect 0', - 'Passive effect', - 'Layout', - 'Layout effect 1', - ]); - }); + ReactNoop.render([ + , + , + ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Passive'), - span('Layout'), - ]); - }); + act(() => { + expect(Scheduler).toFlushAndYield([ + 'Passive', + 'Layout', + 'Layout effect 0', + 'Passive effect', + 'Layout', + 'Layout effect 1', + ]); + }); - it('flushes passive effects even if siblings schedule a new root', () => { - function PassiveEffect(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Passive effect'); - }, []); - return ; - } - function LayoutEffect(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue('Layout effect'); - // Scheduling work shouldn't interfere with the queued passive effect - ReactNoop.renderToRootWithID(, 'root2'); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); }); - return ; - } - act(() => { - ReactNoop.render([, ]); - expect(Scheduler).toFlushAndYield([ - 'Passive', - 'Layout', - 'Layout effect', - 'Passive effect', - 'New Root', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Passive'), - span('Layout'), - ]); - }); - }); - it( - 'flushes effects serially by flushing old effects before flushing ' + - "new ones, if they haven't already fired", - () => { - function getCommittedText() { - const children = ReactNoop.getChildren(); - if (children === null) { - return null; + it('flushes passive effects even if siblings schedule a new root', () => { + function PassiveEffect(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Passive effect'); + }, []); + return ; } - return children[0].prop; - } - - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue( - `Committed state when effect was fired: ${getCommittedText()}`, - ); + function LayoutEffect(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Layout effect'); + // Scheduling work shouldn't interfere with the queued passive effect + ReactNoop.renderToRootWithID(, 'root2'); + }); + return ; + } + act(() => { + ReactNoop.render([ + , + , + ]); + expect(Scheduler).toFlushAndYield([ + 'Passive', + 'Layout', + 'Layout effect', + 'Passive effect', + 'New Root', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Passive'), + span('Layout'), + ]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([0, 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span(0)]); - // Before the effects have a chance to flush, schedule another update - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - // The previous effect flushes before the reconciliation - 'Committed state when effect was fired: 0', - 1, - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(1)]); }); - expect(Scheduler).toHaveYielded([ - 'Committed state when effect was fired: 1', - ]); - }, - ); - - it('defers passive effect destroy functions during unmount', () => { - function Child({bar, foo}) { - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive bar create'); - return () => { - Scheduler.unstable_yieldValue('passive bar destroy'); - }; - }, [bar]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout bar create'); - return () => { - Scheduler.unstable_yieldValue('layout bar destroy'); - }; - }, [bar]); - React.useEffect(() => { - Scheduler.unstable_yieldValue('passive foo create'); - return () => { - Scheduler.unstable_yieldValue('passive foo destroy'); - }; - }, [foo]); - React.useLayoutEffect(() => { - Scheduler.unstable_yieldValue('layout foo create'); - return () => { - Scheduler.unstable_yieldValue('layout foo destroy'); - }; - }, [foo]); - Scheduler.unstable_yieldValue('render'); - return null; - } + it( + 'flushes effects serially by flushing old effects before flushing ' + + "new ones, if they haven't already fired", + () => { + function getCommittedText() { + const children = ReactNoop.getChildren(); + if (children === null) { + return null; + } + return children[0].prop; + } + + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue( + `Committed state when effect was fired: ${getCommittedText()}`, + ); + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([0, 'Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span(0)]); + // Before the effects have a chance to flush, schedule another update + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + // The previous effect flushes before the reconciliation + 'Committed state when effect was fired: 0', + 1, + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + expect(Scheduler).toHaveYielded([ + 'Committed state when effect was fired: 1', + ]); + }, ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout bar create', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar create', - 'passive foo create', - ]); - }); - - // This update is exists to test an internal implementation detail: - // Effects without updating dependencies lose their layout/passive tag during an update. - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'render', - 'layout foo destroy', - 'layout foo create', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive foo destroy', - 'passive foo create', - ]); - }); - // Unmount the component and verify that passive destroy functions are deferred until post-commit. - act(() => { - ReactNoop.render(null, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'layout bar destroy', - 'layout foo destroy', - 'Sync effect', - ]); - // Effects are deferred until after the commit - expect(Scheduler).toFlushAndYield([ - 'passive bar destroy', - 'passive foo destroy', - ]); - }); - }); + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + it('defers passive effect destroy functions during unmount', () => { + function Child({bar, foo}) { + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive bar create'); + return () => { + Scheduler.unstable_yieldValue('passive bar destroy'); + }; + }, [bar]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout bar create'); + return () => { + Scheduler.unstable_yieldValue('layout bar destroy'); + }; + }, [bar]); + React.useEffect(() => { + Scheduler.unstable_yieldValue('passive foo create'); + return () => { + Scheduler.unstable_yieldValue('passive foo destroy'); + }; + }, [foo]); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('layout foo create'); + return () => { + Scheduler.unstable_yieldValue('layout foo destroy'); + }; + }, [foo]); + Scheduler.unstable_yieldValue('render'); + return null; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout bar create', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar create', + 'passive foo create', + ]); + }); - it('updates have async priority', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [0]']); - expect(Scheduler).toFlushAndYield(['Count: 0']); - }); + // This update is exists to test an internal implementation detail: + // Effects without updating dependencies lose their layout/passive tag during an update. + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'render', + 'layout foo destroy', + 'layout foo create', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive foo destroy', + 'passive foo create', + ]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [1]']); - expect(Scheduler).toFlushAndYield(['Count: 1']); - }); - }); + // Unmount the component and verify that passive destroy functions are deferred until post-commit. + act(() => { + ReactNoop.render(null, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'layout bar destroy', + 'layout foo destroy', + 'Sync effect', + ]); + // Effects are deferred until after the commit + expect(Scheduler).toFlushAndYield([ + 'passive bar destroy', + 'passive foo destroy', + ]); + }); + }); + } - it('updates have async priority even if effects are flushed early', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + it('updates have async priority', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [0]']); + expect(Scheduler).toFlushAndYield(['Count: 0']); + }); - // Rendering again should flush the previous commit's effects - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Schedule update [0]', - 'Count: 0', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [1]']); + expect(Scheduler).toFlushAndYield(['Count: 1']); + }); + }); - expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Schedule update [1]']); - expect(Scheduler).toFlushAndYield(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - }); + it('updates have async priority even if effects are flushed early', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + // Rendering again should flush the previous commit's effects + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Schedule update [0]', + 'Count: 0', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + + expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Schedule update [1]']); + expect(Scheduler).toFlushAndYield(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + }); - it('flushes passive effects when flushing discrete updates', () => { - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - useEffect(() => { - Scheduler.unstable_yieldValue(`Will set count to 1`); - updateCount(1); - }, []); - return ; - } + it('flushes passive effects when flushing discrete updates', () => { + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + Scheduler.unstable_yieldValue(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } - // we explicitly wait for missing act() warnings here since - // it's a lot harder to simulate this condition inside an act scope - expect(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }).toErrorDev(['An update to Counter ran an effect']); + // we explicitly wait for missing act() warnings here since + // it's a lot harder to simulate this condition inside an act scope + expect(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }).toErrorDev(['An update to Counter ran an effect']); + + // A discrete event forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + ReactNoop.flushDiscreteUpdates(); + ReactNoop.discreteUpdates(() => { + // (use batchedUpdates to silence the act() warning) + ReactNoop.batchedUpdates(() => { + _updateCount(2); + }); + }); + expect(Scheduler).toHaveYielded(['Will set count to 1']); + expect(() => { + expect(Scheduler).toFlushAndYield(['Count: 2']); + }).toErrorDev([ + 'An update to Counter ran an effect', + 'An update to Counter ran an effect', + ]); - // A discrete event forces the passive effect to be flushed -- - // updateCount(1) happens first, so 2 wins. - ReactNoop.flushDiscreteUpdates(); - ReactNoop.discreteUpdates(() => { - // (use batchedUpdates to silence the act() warning) - ReactNoop.batchedUpdates(() => { - _updateCount(2); + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); }); - }); - expect(Scheduler).toHaveYielded(['Will set count to 1']); - expect(() => { - expect(Scheduler).toFlushAndYield(['Count: 2']); - }).toErrorDev([ - 'An update to Counter ran an effect', - 'An update to Counter ran an effect', - ]); - - expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); - }); - it('flushes passive effects when flushing discrete updates (with tracing)', () => { - const onInteractionScheduledWorkCompleted = jest.fn(); - const onWorkCanceled = jest.fn(); - SchedulerTracing.unstable_subscribe({ - onInteractionScheduledWorkCompleted, - onInteractionTraced: jest.fn(), - onWorkCanceled, - onWorkScheduled: jest.fn(), - onWorkStarted: jest.fn(), - onWorkStopped: jest.fn(), - }); + it('flushes passive effects when flushing discrete updates (with tracing)', () => { + const onInteractionScheduledWorkCompleted = jest.fn(); + const onWorkCanceled = jest.fn(); + SchedulerTracing.unstable_subscribe({ + onInteractionScheduledWorkCompleted, + onInteractionTraced: jest.fn(), + onWorkCanceled, + onWorkScheduled: jest.fn(), + onWorkStarted: jest.fn(), + onWorkStopped: jest.fn(), + }); - let _updateCount; - function Counter(props) { - const [count, updateCount] = useState(0); - _updateCount = updateCount; - useEffect(() => { - expect(SchedulerTracing.unstable_getCurrent()).toMatchInteractions([ - tracingEvent, - ]); - Scheduler.unstable_yieldValue(`Will set count to 1`); - updateCount(1); - }, []); - return ; - } + let _updateCount; + function Counter(props) { + const [count, updateCount] = useState(0); + _updateCount = updateCount; + useEffect(() => { + expect( + SchedulerTracing.unstable_getCurrent(), + ).toMatchInteractions([tracingEvent]); + Scheduler.unstable_yieldValue(`Will set count to 1`); + updateCount(1); + }, []); + return ; + } - const tracingEvent = {id: 0, name: 'hello', timestamp: 0}; - // we explicitly wait for missing act() warnings here since - // it's a lot harder to simulate this condition inside an act scope - expect(() => { - SchedulerTracing.unstable_trace( - tracingEvent.name, - tracingEvent.timestamp, - () => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + const tracingEvent = {id: 0, name: 'hello', timestamp: 0}; + // we explicitly wait for missing act() warnings here since + // it's a lot harder to simulate this condition inside an act scope + expect(() => { + SchedulerTracing.unstable_trace( + tracingEvent.name, + tracingEvent.timestamp, + () => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + }, ); - }, - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }).toErrorDev(['An update to Counter ran an effect']); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }).toErrorDev(['An update to Counter ran an effect']); + + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); + + // A discrete event forces the passive effect to be flushed -- + // updateCount(1) happens first, so 2 wins. + ReactNoop.flushDiscreteUpdates(); + ReactNoop.discreteUpdates(() => { + // (use batchedUpdates to silence the act() warning) + ReactNoop.batchedUpdates(() => { + _updateCount(2); + }); + }); + expect(Scheduler).toHaveYielded(['Will set count to 1']); + expect(() => { + expect(Scheduler).toFlushAndYield(['Count: 2']); + }).toErrorDev([ + 'An update to Counter ran an effect', + 'An update to Counter ran an effect', + ]); - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(0); + expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); - // A discrete event forces the passive effect to be flushed -- - // updateCount(1) happens first, so 2 wins. - ReactNoop.flushDiscreteUpdates(); - ReactNoop.discreteUpdates(() => { - // (use batchedUpdates to silence the act() warning) - ReactNoop.batchedUpdates(() => { - _updateCount(2); + expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); + expect(onWorkCanceled).toHaveBeenCalledTimes(0); }); - }); - expect(Scheduler).toHaveYielded(['Will set count to 1']); - expect(() => { - expect(Scheduler).toFlushAndYield(['Count: 2']); - }).toErrorDev([ - 'An update to Counter ran an effect', - 'An update to Counter ran an effect', - ]); - - expect(ReactNoop.getChildren()).toEqual([span('Count: 2')]); - - expect(onInteractionScheduledWorkCompleted).toHaveBeenCalledTimes(1); - expect(onWorkCanceled).toHaveBeenCalledTimes(0); - }); - it( - 'in legacy mode, useEffect is deferred and updates finish synchronously ' + - '(in a single batch)', - () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - // Update multiple times. These should all be batched together in - // a single render. - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - updateCount(props.count); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.renderLegacySyncRoot(); - // Even in legacy mode, effects are deferred until after paint - expect(Scheduler).toFlushAndYieldThrough(['Count: (empty)']); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - }); + it( + 'in legacy mode, useEffect is deferred and updates finish synchronously ' + + '(in a single batch)', + () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + // Update multiple times. These should all be batched together in + // a single render. + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + updateCount(props.count); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.renderLegacySyncRoot(); + // Even in legacy mode, effects are deferred until after paint + expect(Scheduler).toFlushAndYieldThrough(['Count: (empty)']); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + }); - // effects get fored on exiting act() - // There were multiple updates, but there should only be a - // single render - expect(Scheduler).toHaveYielded(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }, - ); - - it('flushSync is not allowed', () => { - function Counter(props) { - const [count, updateCount] = useState('(empty)'); - useEffect(() => { - Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); - ReactNoop.flushSync(() => { - updateCount(props.count); - }); - }, [props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), + // effects get fored on exiting act() + // There were multiple updates, but there should only be a + // single render + expect(Scheduler).toHaveYielded(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }, ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: (empty)', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); - expect(() => { - ReactNoop.flushPassiveEffects(); - }).toThrow('flushSync was called from inside a lifecycle method'); - }); - }); - it('unmounts previous effect', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; + it('flushSync is not allowed', () => { + function Counter(props) { + const [count, updateCount] = useState('(empty)'); + useEffect(() => { + Scheduler.unstable_yieldValue(`Schedule update [${props.count}]`); + ReactNoop.flushSync(() => { + updateCount(props.count); + }); + }, [props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: (empty)', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: (empty)')]); + expect(() => { + ReactNoop.flushPassiveEffects(); + }).toThrow('flushSync was called from inside a lifecycle method'); + }); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - expect(Scheduler).toHaveYielded(['Did create [0]']); + it('unmounts previous effect', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + expect(Scheduler).toHaveYielded(['Did create [0]']); - expect(Scheduler).toHaveYielded(['Did destroy [0]', 'Did create [1]']); - }); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); - it('unmounts on deletion', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; + expect(Scheduler).toHaveYielded([ + 'Did destroy [0]', + 'Did create [1]', + ]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - expect(Scheduler).toHaveYielded(['Did create [0]']); + it('unmounts on deletion', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy [0]']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + expect(Scheduler).toHaveYielded(['Did create [0]']); - it('unmounts on deletion after skipped effect', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); - }; - }, []); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create [0]']); + it('unmounts on deletion after skipped effect', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${props.count}]`); + }; + }, []); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + expect(Scheduler).toHaveYielded(['Did create [0]']); - expect(Scheduler).toHaveYielded([]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy [0]']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + expect(Scheduler).toHaveYielded([]); - it('always fires effects if no dependencies are provided', () => { - function effect() { - Scheduler.unstable_yieldValue(`Did create`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy`); - }; - } - function Counter(props) { - useEffect(effect); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy [0]']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create']); + it('always fires effects if no dependencies are provided', () => { + function effect() { + Scheduler.unstable_yieldValue(`Did create`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy`); + }; + } + function Counter(props) { + useEffect(effect); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + expect(Scheduler).toHaveYielded(['Did create']); - expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); - ReactNoop.render(null); - expect(Scheduler).toFlushAndYield(['Did destroy']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + expect(Scheduler).toHaveYielded(['Did destroy', 'Did create']); - it('skips effect if inputs have not changed', () => { - function Counter(props) { - const text = `${props.label}: ${props.count}`; - useEffect(() => { - Scheduler.unstable_yieldValue(`Did create [${text}]`); - return () => { - Scheduler.unstable_yieldValue(`Did destroy [${text}]`); - }; - }, [props.label, props.count]); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - }); + ReactNoop.render(null); + expect(Scheduler).toFlushAndYield(['Did destroy']); + expect(ReactNoop.getChildren()).toEqual([]); + }); - expect(Scheduler).toHaveYielded(['Did create [Count: 0]']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + it('skips effect if inputs have not changed', () => { + function Counter(props) { + const text = `${props.label}: ${props.count}`; + useEffect(() => { + Scheduler.unstable_yieldValue(`Did create [${text}]`); + return () => { + Scheduler.unstable_yieldValue(`Did destroy [${text}]`); + }; + }, [props.label, props.count]); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Count changed - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); + expect(Scheduler).toHaveYielded(['Did create [Count: 0]']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 0]', - 'Did create [Count: 1]', - ]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Count changed + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Nothing changed, so no effect should have fired - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - }); + expect(Scheduler).toHaveYielded([ + 'Did destroy [Count: 0]', + 'Did create [Count: 1]', + ]); - expect(Scheduler).toHaveYielded([]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Nothing changed, so no effect should have fired + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - // Label changed - expect(Scheduler).toFlushAndYieldThrough(['Total: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); - }); + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(Scheduler).toHaveYielded([ - 'Did destroy [Count: 1]', - 'Did create [Total: 1]', - ]); - }); + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + // Label changed + expect(Scheduler).toFlushAndYieldThrough([ + 'Total: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Total: 1')]); + }); - it('multiple effects', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 1 [${props.count}]`); - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Did commit 2 [${props.count}]`); + expect(Scheduler).toHaveYielded([ + 'Did destroy [Count: 1]', + 'Did create [Total: 1]', + ]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - expect(Scheduler).toHaveYielded(['Did commit 1 [0]', 'Did commit 2 [0]']); + it('multiple effects', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Did commit 1 [${props.count}]`); + }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Did commit 2 [${props.count}]`); + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - expect(Scheduler).toHaveYielded(['Did commit 1 [1]', 'Did commit 2 [1]']); - }); + expect(Scheduler).toHaveYielded([ + 'Did commit 1 [0]', + 'Did commit 2 [0]', + ]); - it('unmounts all previous effects before creating any new ones', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + expect(Scheduler).toHaveYielded([ + 'Did commit 1 [1]', + 'Did commit 2 [1]', + ]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - }); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + it('unmounts all previous effects before creating any new ones', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + }); - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Mount B [1]', - ]); - }); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - it('unmounts all previous effects between siblings before creating any new ones', () => { - function Counter({count, label}) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount ${label} [${count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount ${label} [${count}]`); - }; + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); }); - return ; - } - act(() => { - ReactNoop.render( - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['A 0', 'B 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('A 0'), span('B 0')]); - }); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + if (runAllPassiveEffectDestroysBeforeCreates) { + it('unmounts all previous effects between siblings before creating any new ones', () => { + function Counter({count, label}) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount ${label} [${count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount ${label} [${count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'A 0', + 'B 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('A 0'), + span('B 0'), + ]); + }); - act(() => { - ReactNoop.render( - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['A 1', 'B 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('A 1'), span('B 1')]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Mount B [1]', - ]); - - act(() => { - ReactNoop.render( - - - - , - () => Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['B 2', 'C 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('B 2'), span('C 0')]); - }); - expect(Scheduler).toHaveYielded([ - 'Unmount A [1]', - 'Unmount B [1]', - 'Mount B [2]', - 'Mount C [0]', - ]); - }); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + + act(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'A 1', + 'B 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('A 1'), + span('B 1'), + ]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); + + act(() => { + ReactNoop.render( + + + + , + () => Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'B 2', + 'C 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('B 2'), + span('C 0'), + ]); + }); + expect(Scheduler).toHaveYielded([ + 'Unmount A [1]', + 'Unmount B [1]', + 'Mount B [2]', + 'Mount C [0]', + ]); + }); + } - it('handles errors on mount', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; + it('handles errors in create on mount', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue('Oops!'); + throw new Error('Oops!'); + // eslint-disable-next-line no-unreachable + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); + }); + + expect(Scheduler).toHaveYielded([ + 'Mount A [0]', + 'Oops!', + // Clean up effect A. There's no effect B to clean-up, because it + // never mounted. + 'Unmount A [0]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); }); - useEffect(() => { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - // eslint-disable-next-line no-unreachable - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; + + it('handles errors in create on update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); + }; + }); + useEffect(() => { + if (props.count === 1) { + Scheduler.unstable_yieldValue('Oops!'); + throw new Error('Oops!'); + } + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + }); + + act(() => { + // This update will trigger an error + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); + expect(Scheduler).toHaveYielded( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ? ['Unmount A [0]', 'Unmount B [0]', 'Mount A [1]', 'Oops!'] + : [ + 'Unmount A [0]', + 'Unmount B [0]', + 'Mount A [1]', + 'Oops!', + 'Unmount A [1]', + ], + ); + expect(ReactNoop.getChildren()).toEqual([]); + }); + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + expect(Scheduler).toHaveYielded([ + // Clean up effect A runs passively on unmount. + // There's no effect B to clean-up, because it never mounted. + 'Unmount A [1]', + ]); + } }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - }); - expect(Scheduler).toHaveYielded([ - 'Mount A [0]', - 'Oops!', - // Clean up effect A. There's no effect B to clean-up, because it - // never mounted. - 'Unmount A [0]', - ]); - expect(ReactNoop.getChildren()).toEqual([]); - }); + it('handles errors in destroy on update', () => { + function Counter(props) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue('Oops!'); + if (props.count === 0) { + throw new Error('Oops!'); + } + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); + return () => { + Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); + }; + }); + return ; + } - it('handles errors on update', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount A [${props.count}]`); - }; + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + ReactNoop.flushPassiveEffects(); + expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); + }); + + if ( + deferPassiveEffectCleanupDuringUnmount && + runAllPassiveEffectDestroysBeforeCreates + ) { + act(() => { + // This update will trigger an error during passive effect unmount + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); + + // This branch enables a feature flag that flushes all passive destroys in a + // separate pass before flushing any passive creates. + // A result of this two-pass flush is that an error thrown from unmount does + // not block the subsequent create functions from being run. + expect(Scheduler).toHaveYielded([ + 'Oops!', + 'Unmount B [0]', + 'Mount A [1]', + 'Mount B [1]', + ]); + }); + + // gets unmounted because an error is thrown above. + // The remaining destroy functions are run later on unmount, since they're passive. + // In this case, one of them throws again (because of how the test is written). + expect(Scheduler).toHaveYielded(['Oops!', 'Unmount B [1]']); + expect(ReactNoop.getChildren()).toEqual([]); + } else { + act(() => { + // This update will trigger an error during passive effect unmount + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(() => { + expect(Scheduler).toFlushAndYield(['Count: 1', 'Sync effect']); + }).toThrow('Oops!'); + expect(ReactNoop.getChildren()).toEqual([]); + ReactNoop.flushPassiveEffects(); + }); + } }); - useEffect(() => { - if (props.count === 1) { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - } - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; + + it('works with memo', () => { + function Counter({count}) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Mount: ' + count); + return () => Scheduler.unstable_yieldValue('Unmount: ' + count); + }); + return ; + } + Counter = memo(Counter); + + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 0', + 'Mount: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Count: 1', + 'Unmount: 0', + 'Mount: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + + ReactNoop.render(null); + expect(Scheduler).toFlushAndYieldThrough(['Unmount: 1']); + expect(ReactNoop.getChildren()).toEqual([]); }); - return ; - } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); }); - act(() => { - // This update will trigger an error - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - expect(Scheduler).toHaveYielded([ - 'Unmount A [0]', - 'Unmount B [0]', - 'Mount A [1]', - 'Oops!', - ]); - expect(ReactNoop.getChildren()).toEqual([]); - }); - expect(Scheduler).toHaveYielded([ - // Clean up effect A runs passively on unmount. - // There's no effect B to clean-up, because it never mounted. - 'Unmount A [1]', - ]); - }); + describe('useLayoutEffect', () => { + it('fires layout effects after the host has been mutated', () => { + function getCommittedText() { + const yields = Scheduler.unstable_clearYields(); + const children = ReactNoop.getChildren(); + Scheduler.unstable_yieldValue(yields); + if (children === null) { + return null; + } + return children[0].prop; + } - it('handles errors on unmount', () => { - function Counter(props) { - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount A [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue('Oops!'); - throw new Error('Oops!'); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue(`Mount B [${props.count}]`); - return () => { - Scheduler.unstable_yieldValue(`Unmount B [${props.count}]`); - }; - }); - return ; - } + function Counter(props) { + useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`Current: ${getCommittedText()}`); + }); + return ; + } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 0', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - ReactNoop.flushPassiveEffects(); - expect(Scheduler).toHaveYielded(['Mount A [0]', 'Mount B [0]']); - }); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + [0], + 'Current: 0', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(0)]); - act(() => { - // This update will trigger an error during passive effect unmount - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Count: 1', 'Sync effect']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(() => ReactNoop.flushPassiveEffects()).toThrow('Oops'); - - // This tests enables a feature flag that flushes all passive destroys in a - // separate pass before flushing any passive creates. - // A result of this two-pass flush is that an error thrown from unmount does - // not block the subsequent create functions from being run. - expect(Scheduler).toHaveYielded([ - 'Oops!', - 'Unmount B [0]', - 'Mount A [1]', - 'Mount B [1]', - ]); - }); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + [1], + 'Current: 1', + 'Sync effect', + ]); + expect(ReactNoop.getChildren()).toEqual([span(1)]); + }); - // gets unmounted because an error is thrown above. - // The remaining destroy functions are run later on unmount, since they're passive. - // In this case, one of them throws again (because of how the test is written). - expect(Scheduler).toHaveYielded(['Oops!', 'Unmount B [1]']); - expect(ReactNoop.getChildren()).toEqual([]); - }); + it('force flushes passive effects before firing new layout effects', () => { + let committedText = '(empty)'; + + function Counter(props) { + useLayoutEffect(() => { + // Normally this would go in a mutation effect, but this test + // intentionally omits a mutation effect. + committedText = props.count + ''; + + Scheduler.unstable_yieldValue( + `Mount layout [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount layout [current: ${committedText}]`, + ); + }; + }); + useEffect(() => { + Scheduler.unstable_yieldValue( + `Mount normal [current: ${committedText}]`, + ); + return () => { + Scheduler.unstable_yieldValue( + `Unmount normal [current: ${committedText}]`, + ); + }; + }); + return null; + } + + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Mount layout [current: 0]', + 'Sync effect', + ]); + expect(committedText).toEqual('0'); + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'Mount normal [current: 0]', + 'Unmount layout [current: 0]', + 'Mount layout [current: 1]', + 'Sync effect', + ]); + expect(committedText).toEqual('1'); + }); - it('works with memo', () => { - function Counter({count}) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue('Mount: ' + count); - return () => Scheduler.unstable_yieldValue('Unmount: ' + count); + expect(Scheduler).toHaveYielded([ + 'Unmount normal [current: 1]', + 'Mount normal [current: 1]', + ]); }); - return ; - } - Counter = memo(Counter); - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 0', - 'Mount: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Count: 1', - 'Unmount: 0', - 'Mount: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - - ReactNoop.render(null); - expect(Scheduler).toFlushAndYieldThrough(['Unmount: 1']); - expect(ReactNoop.getChildren()).toEqual([]); - }); - }); + }); - describe('useLayoutEffect', () => { - it('fires layout effects after the host has been mutated', () => { - function getCommittedText() { - const yields = Scheduler.unstable_clearYields(); - const children = ReactNoop.getChildren(); - Scheduler.unstable_yieldValue(yields); - if (children === null) { - return null; - } - return children[0].prop; - } + describe('useCallback', () => { + it('memoizes callback by comparing inputs', () => { + class IncrementButton extends React.PureComponent { + increment = () => { + this.props.increment(); + }; + render() { + return ; + } + } - function Counter(props) { - useLayoutEffect(() => { - Scheduler.unstable_yieldValue(`Current: ${getCommittedText()}`); - }); - return ; - } + function Counter({incrementBy}) { + const [count, updateCount] = useState(0); + const increment = useCallback( + () => updateCount(c => c + incrementBy), + [incrementBy], + ); + return ( + <> + + + + ); + } - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - [0], - 'Current: 0', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(0)]); - - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - [1], - 'Current: 1', - 'Sync effect', - ]); - expect(ReactNoop.getChildren()).toEqual([span(1)]); - }); + const button = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 0'), + ]); - it('force flushes passive effects before firing new layout effects', () => { - let committedText = '(empty)'; + act(button.current.increment); + expect(Scheduler).toHaveYielded([ + // Button should not re-render, because its props haven't changed + // 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); - function Counter(props) { - useLayoutEffect(() => { - // Normally this would go in a mutation effect, but this test - // intentionally omits a mutation effect. - committedText = props.count + ''; + // Increase the increment amount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + // Inputs did change this time + 'Increment', + 'Count: 1', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 1'), + ]); - Scheduler.unstable_yieldValue( - `Mount layout [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount layout [current: ${committedText}]`, - ); - }; - }); - useEffect(() => { - Scheduler.unstable_yieldValue( - `Mount normal [current: ${committedText}]`, - ); - return () => { - Scheduler.unstable_yieldValue( - `Unmount normal [current: ${committedText}]`, - ); - }; + // Callback should have updated + act(button.current.increment); + expect(Scheduler).toHaveYielded(['Count: 11']); + expect(ReactNoop.getChildren()).toEqual([ + span('Increment'), + span('Count: 11'), + ]); }); - return null; - } - - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Mount layout [current: 0]', - 'Sync effect', - ]); - expect(committedText).toEqual('0'); - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough([ - 'Mount normal [current: 0]', - 'Unmount layout [current: 0]', - 'Mount layout [current: 1]', - 'Sync effect', - ]); - expect(committedText).toEqual('1'); }); - expect(Scheduler).toHaveYielded([ - 'Unmount normal [current: 1]', - 'Mount normal [current: 1]', - ]); - }); - }); + describe('useMemo', () => { + it('memoizes value by comparing to previous inputs', () => { + function CapitalizedText(props) { + const text = props.text; + const capitalizedText = useMemo(() => { + Scheduler.unstable_yieldValue(`Capitalize '${text}'`); + return text.toUpperCase(); + }, [text]); + return ; + } - describe('useCallback', () => { - it('memoizes callback by comparing inputs', () => { - class IncrementButton extends React.PureComponent { - increment = () => { - this.props.increment(); - }; - render() { - return ; - } - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(["Capitalize 'hello'", 'HELLO']); + expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); - function Counter({incrementBy}) { - const [count, updateCount] = useState(0); - const increment = useCallback(() => updateCount(c => c + incrementBy), [ - incrementBy, - ]); - return ( - <> - - - - ); - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(["Capitalize 'hi'", 'HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); - const button = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Increment', 'Count: 0']); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 0'), - ]); - - act(button.current.increment); - expect(Scheduler).toHaveYielded([ - // Button should not re-render, because its props haven't changed - // 'Increment', - 'Count: 1', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 1'), - ]); - - // Increase the increment amount - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - // Inputs did change this time - 'Increment', - 'Count: 1', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 1'), - ]); - - // Callback should have updated - act(button.current.increment); - expect(Scheduler).toHaveYielded(['Count: 11']); - expect(ReactNoop.getChildren()).toEqual([ - span('Increment'), - span('Count: 11'), - ]); - }); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['HI']); + expect(ReactNoop.getChildren()).toEqual([span('HI')]); - describe('useMemo', () => { - it('memoizes value by comparing to previous inputs', () => { - function CapitalizedText(props) { - const text = props.text; - const capitalizedText = useMemo(() => { - Scheduler.unstable_yieldValue(`Capitalize '${text}'`); - return text.toUpperCase(); - }, [text]); - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + "Capitalize 'goodbye'", + 'GOODBYE', + ]); + expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'hello'", 'HELLO']); - expect(ReactNoop.getChildren()).toEqual([span('HELLO')]); + it('always re-computes if no inputs are provided', () => { + function LazyCompute(props) { + const computed = useMemo(props.compute); + return ; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'hi'", 'HI']); - expect(ReactNoop.getChildren()).toEqual([span('HI')]); + function computeA() { + Scheduler.unstable_yieldValue('compute A'); + return 'A'; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['HI']); - expect(ReactNoop.getChildren()).toEqual([span('HI')]); + function computeB() { + Scheduler.unstable_yieldValue('compute B'); + return 'B'; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(["Capitalize 'goodbye'", 'GOODBYE']); - expect(ReactNoop.getChildren()).toEqual([span('GOODBYE')]); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); - it('always re-computes if no inputs are provided', () => { - function LazyCompute(props) { - const computed = useMemo(props.compute); - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); - function computeA() { - Scheduler.unstable_yieldValue('compute A'); - return 'A'; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); - function computeB() { - Scheduler.unstable_yieldValue('compute B'); - return 'B'; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute B', 'B']); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); + it('should not invoke memoized function during re-renders unless inputs change', () => { + function LazyCompute(props) { + const computed = useMemo(() => props.compute(props.input), [ + props.input, + ]); + const [count, setCount] = useState(0); + if (count < 3) { + setCount(count + 1); + } + return ; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); + function compute(val) { + Scheduler.unstable_yieldValue('compute ' + val); + return val; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute A', 'A']); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute B', 'B']); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A']); - it('should not invoke memoized function during re-renders unless inputs change', () => { - function LazyCompute(props) { - const computed = useMemo(() => props.compute(props.input), [ - props.input, - ]); - const [count, setCount] = useState(0); - if (count < 3) { - setCount(count + 1); - } - return ; - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['compute B', 'B']); + }); + }); - function compute(val) { - Scheduler.unstable_yieldValue('compute ' + val); - return val; - } + describe('useRef', () => { + it('creates a ref object initialized with the provided value', () => { + jest.useFakeTimers(); + + function useDebouncedCallback(callback, ms, inputs) { + const timeoutID = useRef(-1); + useEffect(() => { + return function unmount() { + clearTimeout(timeoutID.current); + }; + }, []); + const debouncedCallback = useCallback( + (...args) => { + clearTimeout(timeoutID.current); + timeoutID.current = setTimeout(callback, ms, ...args); + }, + [callback, ms], + ); + return useCallback(debouncedCallback, inputs); + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute A', 'A']); + let ping; + function App() { + ping = useDebouncedCallback( + value => { + Scheduler.unstable_yieldValue('ping: ' + value); + }, + 100, + [], + ); + return null; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A']); + act(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([]); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['compute B', 'B']); - }); - }); + ping(1); + ping(2); + ping(3); - describe('useRef', () => { - it('creates a ref object initialized with the provided value', () => { - jest.useFakeTimers(); + expect(Scheduler).toHaveYielded([]); - function useDebouncedCallback(callback, ms, inputs) { - const timeoutID = useRef(-1); - useEffect(() => { - return function unmount() { - clearTimeout(timeoutID.current); - }; - }, []); - const debouncedCallback = useCallback( - (...args) => { - clearTimeout(timeoutID.current); - timeoutID.current = setTimeout(callback, ms, ...args); - }, - [callback, ms], - ); - return useCallback(debouncedCallback, inputs); - } + jest.advanceTimersByTime(100); - let ping; - function App() { - ping = useDebouncedCallback( - value => { - Scheduler.unstable_yieldValue('ping: ' + value); - }, - 100, - [], - ); - return null; - } + expect(Scheduler).toHaveYielded(['ping: 3']); - act(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded([]); + ping(4); + jest.advanceTimersByTime(20); + ping(5); + ping(6); + jest.advanceTimersByTime(80); - ping(1); - ping(2); - ping(3); + expect(Scheduler).toHaveYielded([]); - expect(Scheduler).toHaveYielded([]); + jest.advanceTimersByTime(20); + expect(Scheduler).toHaveYielded(['ping: 6']); + }); - jest.advanceTimersByTime(100); + it('should return the same ref during re-renders', () => { + function Counter() { + const ref = useRef('val'); + const [count, setCount] = useState(0); + const [firstRef] = useState(ref); - expect(Scheduler).toHaveYielded(['ping: 3']); + if (firstRef !== ref) { + throw new Error('should never change'); + } - ping(4); - jest.advanceTimersByTime(20); - ping(5); - ping(6); - jest.advanceTimersByTime(80); + if (count < 3) { + setCount(count + 1); + } - expect(Scheduler).toHaveYielded([]); + return ; + } - jest.advanceTimersByTime(20); - expect(Scheduler).toHaveYielded(['ping: 6']); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['val']); - it('should return the same ref during re-renders', () => { - function Counter() { - const ref = useRef('val'); - const [count, setCount] = useState(0); - const [firstRef] = useState(ref); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['val']); + }); + }); - if (firstRef !== ref) { - throw new Error('should never change'); - } + describe('useImperativeHandle', () => { + it('does not update when deps are the same', () => { + const INCREMENT = 'INCREMENT'; - if (count < 3) { - setCount(count + 1); - } + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - return ; - } + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch}), []); + return ; + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['val']); + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['val']); - }); - }); + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + // Intentionally not updated because of [] deps: + expect(counter.current.count).toBe(0); + }); - describe('useImperativeHandle', () => { - it('does not update when deps are the same', () => { - const INCREMENT = 'INCREMENT'; + // Regression test for https://github.com/facebook/react/issues/14782 + it('automatically updates when deps are not specified', () => { + const INCREMENT = 'INCREMENT'; - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch}), []); - return ; - } + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle(ref, () => ({count, dispatch})); + return ; + } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); - act(() => { - counter.current.dispatch(INCREMENT); - }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - // Intentionally not updated because of [] deps: - expect(counter.current.count).toBe(0); - }); + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + }); - // Regression test for https://github.com/facebook/react/issues/14782 - it('automatically updates when deps are not specified', () => { - const INCREMENT = 'INCREMENT'; + it('updates when deps are different', () => { + const INCREMENT = 'INCREMENT'; - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + function reducer(state, action) { + return action === INCREMENT ? state + 1 : state; + } - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle(ref, () => ({count, dispatch})); - return ; - } + let totalRefUpdates = 0; + function Counter(props, ref) { + const [count, dispatch] = useReducer(reducer, 0); + useImperativeHandle( + ref, + () => { + totalRefUpdates++; + return {count, dispatch}; + }, + [count], + ); + return ; + } - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); + Counter = forwardRef(Counter); + const counter = React.createRef(null); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 0']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); + expect(counter.current.count).toBe(0); + expect(totalRefUpdates).toBe(1); - act(() => { - counter.current.dispatch(INCREMENT); + act(() => { + counter.current.dispatch(INCREMENT); + }); + expect(Scheduler).toHaveYielded(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + expect(totalRefUpdates).toBe(2); + + // Update that doesn't change the ref dependencies + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Count: 1']); + expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); + expect(counter.current.count).toBe(1); + expect(totalRefUpdates).toBe(2); // Should not increase since last time + }); }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - }); + describe('useTransition', () => { + it.experimental( + 'delays showing loading state until after timeout', + async () => { + let transition; + function App() { + const [show, setShow] = useState(false); + const [startTransition, isPending] = useTransition({ + timeoutMs: 1000, + }); + transition = () => { + startTransition(() => { + setShow(true); + }); + }; + return ( + }> + {show ? ( + + ) : ( + + )} + + ); + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Before... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: false'), + ]); + + act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + transition, + ); + }); + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(Scheduler).toHaveYielded([ + 'Before... Pending: true', + 'Suspend! [After... Pending: false]', + 'Loading... Pending: false', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(ReactNoop.getChildren()).toEqual([ + hiddenSpan('Before... Pending: true'), + span('Loading... Pending: false'), + ]); + + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [After... Pending: false]', + ]); + expect(Scheduler).toFlushAndYield(['After... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('After... Pending: false'), + ]); + }, + ); + it.experimental( + 'delays showing loading state until after busyDelayMs + busyMinDurationMs', + async () => { + let transition; + function App() { + const [show, setShow] = useState(false); + const [startTransition, isPending] = useTransition({ + busyDelayMs: 1000, + busyMinDurationMs: 2000, + }); + transition = () => { + startTransition(() => { + setShow(true); + }); + }; + return ( + }> + {show ? ( + + ) : ( + + )} + + ); + } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Before... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: false'), + ]); + + act(() => { + Scheduler.unstable_runWithPriority( + Scheduler.unstable_UserBlockingPriority, + transition, + ); + }); + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Before... Pending: true', + 'Suspend! [After... Pending: false]', + 'Loading... Pending: false', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [After... Pending: false]', + ]); + expect(Scheduler).toFlushAndYield(['After... Pending: false']); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(ReactNoop.getChildren()).toEqual([ + span('Before... Pending: true'), + ]); + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(ReactNoop.getChildren()).toEqual([ + span('After... Pending: false'), + ]); + }, + ); + }); + describe('useDeferredValue', () => { + it.experimental( + 'defers text value until specified timeout', + async () => { + function TextBox({text}) { + return ; + } + + let _setText; + function App() { + const [text, setText] = useState('A'); + const deferredText = useDeferredValue(text, { + timeoutMs: 500, + }); + _setText = setText; + return ( + <> + + }> + + + + ); + } + + act(() => { + ReactNoop.render(); + }); - it('updates when deps are different', () => { - const INCREMENT = 'INCREMENT'; + expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('Loading'), + ]); - function reducer(state, action) { - return action === INCREMENT ? state + 1 : state; - } + Scheduler.unstable_advanceTime(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]); - let totalRefUpdates = 0; - function Counter(props, ref) { - const [count, dispatch] = useReducer(reducer, 0); - useImperativeHandle( - ref, - () => { - totalRefUpdates++; - return {count, dispatch}; + act(() => { + _setText('B'); + }); + expect(Scheduler).toHaveYielded([ + 'B', + 'A', + 'B', + 'Suspend! [B]', + 'Loading', + ]); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); + + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); + + Scheduler.unstable_advanceTime(500); + await advanceTimers(500); + expect(ReactNoop.getChildren()).toEqual([ + span('B'), + hiddenSpan('A'), + span('Loading'), + ]); + + Scheduler.unstable_advanceTime(250); + await advanceTimers(250); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + + act(() => { + expect(Scheduler).toFlushAndYield(['B']); + }); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]); }, - [count], ); - return ; - } - - Counter = forwardRef(Counter); - const counter = React.createRef(null); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 0']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 0')]); - expect(counter.current.count).toBe(0); - expect(totalRefUpdates).toBe(1); - - act(() => { - counter.current.dispatch(INCREMENT); }); - expect(Scheduler).toHaveYielded(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - expect(totalRefUpdates).toBe(2); - - // Update that doesn't change the ref dependencies - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Count: 1']); - expect(ReactNoop.getChildren()).toEqual([span('Count: 1')]); - expect(counter.current.count).toBe(1); - expect(totalRefUpdates).toBe(2); // Should not increase since last time - }); - }); - describe('useTransition', () => { - it.experimental( - 'delays showing loading state until after timeout', - async () => { - let transition; - function App() { - const [show, setShow] = useState(false); - const [startTransition, isPending] = useTransition({ - timeoutMs: 1000, - }); - transition = () => { - startTransition(() => { - setShow(true); - }); - }; - return ( - }> - {show ? ( - - ) : ( - - )} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Before... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: false'), - ]); - act(() => { - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - transition, - ); - }); - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(Scheduler).toHaveYielded([ - 'Before... Pending: true', - 'Suspend! [After... Pending: false]', - 'Loading... Pending: false', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); + describe('progressive enhancement (not supported)', () => { + it('mount additional state', () => { + let updateA; + let updateB; + // let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + useState(0); + } else { + C = '[not loaded]'; + } + + return ; + } - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(ReactNoop.getChildren()).toEqual([ - hiddenSpan('Before... Pending: true'), - span('Loading... Pending: false'), - ]); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 0, B: 0, C: [not loaded]'), + ]); - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(Scheduler).toHaveYielded([ - 'Promise resolved [After... Pending: false]', - ]); - expect(Scheduler).toFlushAndYield(['After... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('After... Pending: false'), - ]); - }, - ); - it.experimental( - 'delays showing loading state until after busyDelayMs + busyMinDurationMs', - async () => { - let transition; - function App() { - const [show, setShow] = useState(false); - const [startTransition, isPending] = useTransition({ - busyDelayMs: 1000, - busyMinDurationMs: 2000, + act(() => { + updateA(2); + updateB(3); }); - transition = () => { - startTransition(() => { - setShow(true); - }); - }; - return ( - }> - {show ? ( - - ) : ( - - )} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Before... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: false'), - ]); - act(() => { - Scheduler.unstable_runWithPriority( - Scheduler.unstable_UserBlockingPriority, - transition, - ); - }); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded([ - 'Before... Pending: true', - 'Suspend! [After... Pending: false]', - 'Loading... Pending: false', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); + expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: [not loaded]']); + expect(ReactNoop.getChildren()).toEqual([ + span('A: 2, B: 3, C: [not loaded]'), + ]); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded([ - 'Promise resolved [After... Pending: false]', - ]); - expect(Scheduler).toFlushAndYield(['After... Pending: false']); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); + ReactNoop.render(); + expect(() => { + expect(() => { + expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 0']); + }).toThrow('Rendered more hooks than during the previous render'); + }).toErrorDev([ + 'Warning: React has detected a change in the order of Hooks called by App. ' + + 'This will lead to bugs and errors if not fixed. For more information, ' + + 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + + ' Previous render Next render\n' + + ' ------------------------------------------------------\n' + + '1. useState useState\n' + + '2. useState useState\n' + + '3. undefined useState\n' + + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', + ]); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(ReactNoop.getChildren()).toEqual([ - span('Before... Pending: true'), - ]); - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(ReactNoop.getChildren()).toEqual([ - span('After... Pending: false'), - ]); - }, - ); - }); - describe('useDeferredValue', () => { - it.experimental('defers text value until specified timeout', async () => { - function TextBox({text}) { - return ; - } + // Uncomment if/when we support this again + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); - let _setText; - function App() { - const [text, setText] = useState('A'); - const deferredText = useDeferredValue(text, { - timeoutMs: 500, + // updateC(4); + // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']); + // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); }); - _setText = setText; - return ( - <> - - }> - - - - ); - } - act(() => { - ReactNoop.render(); - }); + it('unmount state', () => { + let updateA; + let updateB; + let updateC; + + function App(props) { + const [A, _updateA] = useState(0); + const [B, _updateB] = useState(0); + updateA = _updateA; + updateB = _updateB; + + let C; + if (props.loadC) { + const [_C, _updateC] = useState(0); + C = _C; + updateC = _updateC; + } else { + C = '[not loaded]'; + } + + return ; + } - expect(Scheduler).toHaveYielded(['A', 'Suspend! [A]', 'Loading']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading')]); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: 0']); + expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); + act(() => { + updateA(2); + updateB(3); + updateC(4); + }); + expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: 4']); + expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'Rendered fewer hooks than expected. This may be caused by an ' + + 'accidental early return statement.', + ); + }); - Scheduler.unstable_advanceTime(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushAndYield(['A']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('A')]); + it('unmount effects', () => { + function App(props) { + useEffect(() => { + Scheduler.unstable_yieldValue('Mount A'); + return () => { + Scheduler.unstable_yieldValue('Unmount A'); + }; + }, []); + + if (props.showMore) { + useEffect(() => { + Scheduler.unstable_yieldValue('Mount B'); + return () => { + Scheduler.unstable_yieldValue('Unmount B'); + }; + }, []); + } - act(() => { - _setText('B'); - }); - expect(Scheduler).toHaveYielded([ - 'B', - 'A', - 'B', - 'Suspend! [B]', - 'Loading', - ]); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); - - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('A')]); - - Scheduler.unstable_advanceTime(500); - await advanceTimers(500); - expect(ReactNoop.getChildren()).toEqual([ - span('B'), - hiddenSpan('A'), - span('Loading'), - ]); - - Scheduler.unstable_advanceTime(250); - await advanceTimers(250); - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - - act(() => { - expect(Scheduler).toFlushAndYield(['B']); - }); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('B')]); - }); - }); + return null; + } - describe('progressive enhancement (not supported)', () => { - it('mount additional state', () => { - let updateA; - let updateB; - // let updateC; - - function App(props) { - const [A, _updateA] = useState(0); - const [B, _updateB] = useState(0); - updateA = _updateA; - updateB = _updateB; - - let C; - if (props.loadC) { - useState(0); - } else { - C = '[not loaded]'; - } + act(() => { + ReactNoop.render(, () => + Scheduler.unstable_yieldValue('Sync effect'), + ); + expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); + }); - return ; - } + expect(Scheduler).toHaveYielded(['Mount A']); + + act(() => { + ReactNoop.render(); + expect(() => { + expect(() => { + expect(Scheduler).toFlushAndYield([]); + }).toThrow('Rendered more hooks than during the previous render'); + }).toErrorDev([ + 'Warning: React has detected a change in the order of Hooks called by App. ' + + 'This will lead to bugs and errors if not fixed. For more information, ' + + 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + + ' Previous render Next render\n' + + ' ------------------------------------------------------\n' + + '1. useEffect useEffect\n' + + '2. undefined useEffect\n' + + ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', + ]); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: [not loaded]']); - expect(ReactNoop.getChildren()).toEqual([ - span('A: 0, B: 0, C: [not loaded]'), - ]); + // Uncomment if/when we support this again + // ReactNoop.flushPassiveEffects(); + // expect(Scheduler).toHaveYielded(['Mount B']); - act(() => { - updateA(2); - updateB(3); + // ReactNoop.render(); + // expect(Scheduler).toFlushAndThrow( + // 'Rendered fewer hooks than expected. This may be caused by an ' + + // 'accidental early return statement.', + // ); + }); }); - expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: [not loaded]']); - expect(ReactNoop.getChildren()).toEqual([ - span('A: 2, B: 3, C: [not loaded]'), - ]); - - ReactNoop.render(); - expect(() => { - expect(() => { - expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 0']); - }).toThrow('Rendered more hooks than during the previous render'); - }).toErrorDev([ - 'Warning: React has detected a change in the order of Hooks called by App. ' + - 'This will lead to bugs and errors if not fixed. For more information, ' + - 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + - ' Previous render Next render\n' + - ' ------------------------------------------------------\n' + - '1. useState useState\n' + - '2. useState useState\n' + - '3. undefined useState\n' + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', - ]); - - // Uncomment if/when we support this again - // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 0')]); - - // updateC(4); - // expect(Scheduler).toFlushAndYield(['A: 2, B: 3, C: 4']); - // expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); - }); + it('eager bailout optimization should always compare to latest rendered reducer', () => { + // Edge case based on a bug report + let setCounter; + function App() { + const [counter, _setCounter] = useState(1); + setCounter = _setCounter; + return ; + } - it('unmount state', () => { - let updateA; - let updateB; - let updateC; - - function App(props) { - const [A, _updateA] = useState(0); - const [B, _updateB] = useState(0); - updateA = _updateA; - updateB = _updateB; - - let C; - if (props.loadC) { - const [_C, _updateC] = useState(0); - C = _C; - updateC = _updateC; - } else { - C = '[not loaded]'; + function Component({count}) { + const [state, dispatch] = useReducer(() => { + // This reducer closes over a value from props. If the reducer is not + // properly updated, the eager reducer will compare to an old value + // and bail out incorrectly. + Scheduler.unstable_yieldValue('Reducer: ' + count); + return count; + }, -1); + useEffect(() => { + Scheduler.unstable_yieldValue('Effect: ' + count); + dispatch(); + }, [count]); + Scheduler.unstable_yieldValue('Render: ' + state); + return count; } - return ; - } + act(() => { + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Render: -1', + 'Effect: 1', + 'Reducer: 1', + 'Reducer: 1', + 'Render: 1', + ]); + expect(ReactNoop).toMatchRenderedOutput('1'); + }); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A: 0, B: 0, C: 0']); - expect(ReactNoop.getChildren()).toEqual([span('A: 0, B: 0, C: 0')]); - act(() => { - updateA(2); - updateB(3); - updateC(4); + act(() => { + setCounter(2); + }); + expect(Scheduler).toHaveYielded([ + 'Render: 1', + 'Effect: 2', + 'Reducer: 2', + 'Reducer: 2', + 'Render: 2', + ]); + expect(ReactNoop).toMatchRenderedOutput('2'); }); - expect(Scheduler).toHaveYielded(['A: 2, B: 3, C: 4']); - expect(ReactNoop.getChildren()).toEqual([span('A: 2, B: 3, C: 4')]); - ReactNoop.render(); - expect(Scheduler).toFlushAndThrow( - 'Rendered fewer hooks than expected. This may be caused by an ' + - 'accidental early return statement.', - ); - }); - it('unmount effects', () => { - function App(props) { - useEffect(() => { - Scheduler.unstable_yieldValue('Mount A'); - return () => { - Scheduler.unstable_yieldValue('Unmount A'); - }; - }, []); + // Regression test. Covers a case where an internal state variable + // (`didReceiveUpdate`) is not reset properly. + it('state bail out edge case (#16359)', async () => { + let setCounterA; + let setCounterB; - if (props.showMore) { + function CounterA() { + const [counter, setCounter] = useState(0); + setCounterA = setCounter; + Scheduler.unstable_yieldValue('Render A: ' + counter); useEffect(() => { - Scheduler.unstable_yieldValue('Mount B'); - return () => { - Scheduler.unstable_yieldValue('Unmount B'); - }; - }, []); + Scheduler.unstable_yieldValue('Commit A: ' + counter); + }); + return counter; } - return null; - } + function CounterB() { + const [counter, setCounter] = useState(0); + setCounterB = setCounter; + Scheduler.unstable_yieldValue('Render B: ' + counter); + useEffect(() => { + Scheduler.unstable_yieldValue('Commit B: ' + counter); + }); + return counter; + } - act(() => { - ReactNoop.render(, () => - Scheduler.unstable_yieldValue('Sync effect'), - ); - expect(Scheduler).toFlushAndYieldThrough(['Sync effect']); - }); + const root = ReactNoop.createRoot(null); + await ReactNoop.act(async () => { + root.render( + <> + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'Render A: 0', + 'Render B: 0', + 'Commit A: 0', + 'Commit B: 0', + ]); - expect(Scheduler).toHaveYielded(['Mount A']); + await ReactNoop.act(async () => { + setCounterA(1); - act(() => { - ReactNoop.render(); - expect(() => { - expect(() => { - expect(Scheduler).toFlushAndYield([]); - }).toThrow('Rendered more hooks than during the previous render'); - }).toErrorDev([ - 'Warning: React has detected a change in the order of Hooks called by App. ' + - 'This will lead to bugs and errors if not fixed. For more information, ' + - 'read the Rules of Hooks: https://fb.me/rules-of-hooks\n\n' + - ' Previous render Next render\n' + - ' ------------------------------------------------------\n' + - '1. useEffect useEffect\n' + - '2. undefined useEffect\n' + - ' ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\n\n', + // In the same batch, update B twice. To trigger the condition we're + // testing, the first update is necessary to bypass the early + // bailout optimization. + setCounterB(1); + setCounterB(0); + }); + expect(Scheduler).toHaveYielded([ + 'Render A: 1', + 'Render B: 0', + 'Commit A: 1', + // B should not fire an effect because the update bailed out + // 'Commit B: 0', ]); }); - // Uncomment if/when we support this again - // ReactNoop.flushPassiveEffects(); - // expect(Scheduler).toHaveYielded(['Mount B']); + it('should update latest rendered reducer when a preceding state receives a render phase update', () => { + // Similar to previous test, except using a preceding render phase update + // instead of new props. + let dispatch; + function App() { + const [step, setStep] = useState(0); + const [shadow, _dispatch] = useReducer(() => step, step); + dispatch = _dispatch; - // ReactNoop.render(); - // expect(Scheduler).toFlushAndThrow( - // 'Rendered fewer hooks than expected. This may be caused by an ' + - // 'accidental early return statement.', - // ); - }); - }); + if (step < 5) { + setStep(step + 1); + } - it('eager bailout optimization should always compare to latest rendered reducer', () => { - // Edge case based on a bug report - let setCounter; - function App() { - const [counter, _setCounter] = useState(1); - setCounter = _setCounter; - return ; - } - - function Component({count}) { - const [state, dispatch] = useReducer(() => { - // This reducer closes over a value from props. If the reducer is not - // properly updated, the eager reducer will compare to an old value - // and bail out incorrectly. - Scheduler.unstable_yieldValue('Reducer: ' + count); - return count; - }, -1); - useEffect(() => { - Scheduler.unstable_yieldValue('Effect: ' + count); - dispatch(); - }, [count]); - Scheduler.unstable_yieldValue('Render: ' + state); - return count; - } - - act(() => { - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Render: -1', - 'Effect: 1', - 'Reducer: 1', - 'Reducer: 1', - 'Render: 1', - ]); - expect(ReactNoop).toMatchRenderedOutput('1'); - }); + Scheduler.unstable_yieldValue(`Step: ${step}, Shadow: ${shadow}`); + return shadow; + } - act(() => { - setCounter(2); - }); - expect(Scheduler).toHaveYielded([ - 'Render: 1', - 'Effect: 2', - 'Reducer: 2', - 'Reducer: 2', - 'Render: 2', - ]); - expect(ReactNoop).toMatchRenderedOutput('2'); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Step: 0, Shadow: 0', + 'Step: 1, Shadow: 0', + 'Step: 2, Shadow: 0', + 'Step: 3, Shadow: 0', + 'Step: 4, Shadow: 0', + 'Step: 5, Shadow: 0', + ]); + expect(ReactNoop).toMatchRenderedOutput('0'); - // Regression test. Covers a case where an internal state variable - // (`didReceiveUpdate`) is not reset properly. - it('state bail out edge case (#16359)', async () => { - let setCounterA; - let setCounterB; - - function CounterA() { - const [counter, setCounter] = useState(0); - setCounterA = setCounter; - Scheduler.unstable_yieldValue('Render A: ' + counter); - useEffect(() => { - Scheduler.unstable_yieldValue('Commit A: ' + counter); - }); - return counter; - } - - function CounterB() { - const [counter, setCounter] = useState(0); - setCounterB = setCounter; - Scheduler.unstable_yieldValue('Render B: ' + counter); - useEffect(() => { - Scheduler.unstable_yieldValue('Commit B: ' + counter); + act(() => dispatch()); + expect(Scheduler).toHaveYielded(['Step: 5, Shadow: 5']); + expect(ReactNoop).toMatchRenderedOutput('5'); }); - return counter; - } - - const root = ReactNoop.createRoot(null); - await ReactNoop.act(async () => { - root.render( - <> - - - , - ); - }); - expect(Scheduler).toHaveYielded([ - 'Render A: 0', - 'Render B: 0', - 'Commit A: 0', - 'Commit B: 0', - ]); - - await ReactNoop.act(async () => { - setCounterA(1); - - // In the same batch, update B twice. To trigger the condition we're - // testing, the first update is necessary to bypass the early - // bailout optimization. - setCounterB(1); - setCounterB(0); - }); - expect(Scheduler).toHaveYielded([ - 'Render A: 1', - 'Render B: 0', - 'Commit A: 1', - // B should not fire an effect because the update bailed out - // 'Commit B: 0', - ]); - }); - it('should update latest rendered reducer when a preceding state receives a render phase update', () => { - // Similar to previous test, except using a preceding render phase update - // instead of new props. - let dispatch; - function App() { - const [step, setStep] = useState(0); - const [shadow, _dispatch] = useReducer(() => step, step); - dispatch = _dispatch; - - if (step < 5) { - setStep(step + 1); - } + it('should process the rest pending updates after a render phase update', () => { + // Similar to previous test, except using a preceding render phase update + // instead of new props. + let updateA; + let updateC; + function App() { + const [a, setA] = useState(false); + const [b, setB] = useState(false); + if (a !== b) { + setB(a); + } + // Even though we called setB above, + // we should still apply the changes to C, + // during this render pass. + const [c, setC] = useState(false); + updateA = setA; + updateC = setC; + return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`; + } - Scheduler.unstable_yieldValue(`Step: ${step}, Shadow: ${shadow}`); - return shadow; - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Step: 0, Shadow: 0', - 'Step: 1, Shadow: 0', - 'Step: 2, Shadow: 0', - 'Step: 3, Shadow: 0', - 'Step: 4, Shadow: 0', - 'Step: 5, Shadow: 0', - ]); - expect(ReactNoop).toMatchRenderedOutput('0'); - - act(() => dispatch()); - expect(Scheduler).toHaveYielded(['Step: 5, Shadow: 5']); - expect(ReactNoop).toMatchRenderedOutput('5'); - }); + act(() => ReactNoop.render()); + expect(ReactNoop).toMatchRenderedOutput('abc'); - it('should process the rest pending updates after a render phase update', () => { - // Similar to previous test, except using a preceding render phase update - // instead of new props. - let updateA; - let updateC; - function App() { - const [a, setA] = useState(false); - const [b, setB] = useState(false); - if (a !== b) { - setB(a); - } - // Even though we called setB above, - // we should still apply the changes to C, - // during this render pass. - const [c, setC] = useState(false); - updateA = setA; - updateC = setC; - return `${a ? 'A' : 'a'}${b ? 'B' : 'b'}${c ? 'C' : 'c'}`; - } - - act(() => ReactNoop.render()); - expect(ReactNoop).toMatchRenderedOutput('abc'); - - act(() => { - updateA(true); - // This update should not get dropped. - updateC(true); + act(() => { + updateA(true); + // This update should not get dropped. + updateC(true); + }); + expect(ReactNoop).toMatchRenderedOutput('ABC'); + }); }); - expect(ReactNoop).toMatchRenderedOutput('ABC'); }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js index 71dd2c8df71b0..0cca771b1df22 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.internal.js @@ -9,1230 +9,1585 @@ let Suspense; let TextResource; let textResourceShouldFail; -describe('ReactSuspenseWithNoopRenderer', () => { - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - - beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; - ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; - ReactFeatureFlags.flushSuspenseFallbacksInTests = false; - ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = true; - React = require('react'); - Fragment = React.Fragment; - ReactNoop = require('react-noop-renderer'); - Scheduler = require('scheduler'); - ReactCache = require('react-cache'); - Suspense = React.Suspense; - - TextResource = ReactCache.unstable_createResource( - ([text, ms = 0]) => { - return new Promise((resolve, reject) => - setTimeout(() => { - if (textResourceShouldFail) { - Scheduler.unstable_yieldValue(`Promise rejected [${text}]`); - reject(new Error('Failed to load: ' + text)); - } else { - Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); - resolve(text); - } - }, ms), - ); - }, - ([text, ms]) => text, - ); - textResourceShouldFail = false; - }); - - // function div(...children) { - // children = children.map( - // c => (typeof c === 'string' ? {text: c, hidden: false} : c), - // ); - // return {type: 'div', children, prop: undefined, hidden: false}; - // } - - function span(prop) { - return {type: 'span', children: [], prop, hidden: false}; - } - - function hiddenSpan(prop) { - return {type: 'span', children: [], prop, hidden: true}; - } - - function advanceTimers(ms) { - // Note: This advances Jest's virtual time but not React's. Use - // ReactNoop.expire for that. - if (typeof ms !== 'number') { - throw new Error('Must specify ms'); - } - jest.advanceTimersByTime(ms); - // Wait until the end of the current tick - // We cannot use a timer since we're faking them - return Promise.resolve().then(() => {}); - } - - function Text(props) { - Scheduler.unstable_yieldValue(props.text); - return ; - } - - function AsyncText(props) { - const text = props.text; - try { - TextResource.read([props.text, props.ms]); - Scheduler.unstable_yieldValue(text); - return ; - } catch (promise) { - if (typeof promise.then === 'function') { - Scheduler.unstable_yieldValue(`Suspend! [${text}]`); - } else { - Scheduler.unstable_yieldValue(`Error! [${text}]`); - } - throw promise; - } - } - - it('warns if the deprecated maxDuration option is used', () => { - function Foo() { - return ( - -
; - +function loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, +}) { + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactFeatureFlags.flushSuspenseFallbacksInTests = false; + ReactFeatureFlags.deferPassiveEffectCleanupDuringUnmount = deferPassiveEffectCleanupDuringUnmount; + ReactFeatureFlags.runAllPassiveEffectDestroysBeforeCreates = runAllPassiveEffectDestroysBeforeCreates; + React = require('react'); + Fragment = React.Fragment; + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + ReactCache = require('react-cache'); + Suspense = React.Suspense; + + TextResource = ReactCache.unstable_createResource( + ([text, ms = 0]) => { + return new Promise((resolve, reject) => + setTimeout(() => { + if (textResourceShouldFail) { + Scheduler.unstable_yieldValue(`Promise rejected [${text}]`); + reject(new Error('Failed to load: ' + text)); + } else { + Scheduler.unstable_yieldValue(`Promise resolved [${text}]`); + resolve(text); + } + }, ms), ); - } - - ReactNoop.render(); - - expect(() => Scheduler.unstable_flushAll()).toErrorDev([ - 'Warning: maxDuration has been removed from React. ' + - 'Remove the maxDuration prop.' + - '\n in Suspense (at **)' + - '\n in Foo (at **)', - ]); - }); + }, + ([text, ms]) => text, + ); + textResourceShouldFail = false; +} + +[true, false].forEach(deferPassiveEffectCleanupDuringUnmount => { + [true, false].forEach(runAllPassiveEffectDestroysBeforeCreates => { + describe(`ReactSuspenseWithNoopRenderer deferPassiveEffectCleanupDuringUnmount:${deferPassiveEffectCleanupDuringUnmount} runAllPassiveEffectDestroysBeforeCreates:${runAllPassiveEffectDestroysBeforeCreates}`, () => { + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + return; + } - it('does not restart rendering for initial render', async () => { - function Bar(props) { - Scheduler.unstable_yieldValue('Bar'); - return props.children; - } + beforeEach(() => { + jest.resetModules(); - function Foo() { - Scheduler.unstable_yieldValue('Foo'); - return ( - <> - }> - - - - - - - - - ); - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYieldThrough([ - 'Foo', - 'Bar', - // A suspends - 'Suspend! [A]', - // But we keep rendering the siblings - 'B', - 'Loading...', - 'C', - // We leave D incomplete. - ]); - expect(ReactNoop.getChildren()).toEqual([]); - - // Flush the promise completely - Scheduler.unstable_advanceTime(100); - await advanceTimers(100); - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - - // Even though the promise has resolved, we should now flush - // and commit the in progress render instead of restarting. - expect(Scheduler).toFlushAndYield(['D']); - expect(ReactNoop.getChildren()).toEqual([ - span('Loading...'), - span('C'), - span('D'), - ]); - - // Await one micro task to attach the retry listeners. - await null; - - // Next, we'll flush the complete content. - expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']); - - expect(ReactNoop.getChildren()).toEqual([ - span('A'), - span('B'), - span('C'), - span('D'), - ]); - }); + loadModules({ + deferPassiveEffectCleanupDuringUnmount, + runAllPassiveEffectDestroysBeforeCreates, + }); + }); - it('suspends rendering and continues later', async () => { - function Bar(props) { - Scheduler.unstable_yieldValue('Bar'); - return props.children; - } - - function Foo({renderBar}) { - Scheduler.unstable_yieldValue('Foo'); - return ( - }> - {renderBar ? ( - - - - - ) : null} - - ); - } - - // Render empty shell. - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Foo']); - - // The update will suspend. - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Foo', - 'Bar', - // A suspends - 'Suspend! [A]', - // But we keep rendering the siblings - 'B', - 'Loading...', - ]); - expect(ReactNoop.getChildren()).toEqual([]); - - // Flush some of the time - await advanceTimers(50); - // Still nothing... - expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([]); - - // Flush the promise completely - await advanceTimers(50); - // Renders successfully - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'A', 'B']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); - }); + // function div(...children) { + // children = children.map( + // c => (typeof c === 'string' ? {text: c, hidden: false} : c), + // ); + // return {type: 'div', children, prop: undefined, hidden: false}; + // } - it('suspends siblings and later recovers each independently', async () => { - // Render two sibling Suspense components - ReactNoop.render( - - }> - - - }> - - - , - ); - expect(Scheduler).toFlushAndYield([ - 'Suspend! [A]', - 'Loading A...', - 'Suspend! [B]', - 'Loading B...', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Loading A...'), - span('Loading B...'), - ]); - - // Advance time by enough that the first Suspense's promise resolves and - // switches back to the normal view. The second Suspense should still - // show the placeholder - ReactNoop.expire(5000); - await advanceTimers(5000); - - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushAndYield(['A']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('Loading B...')]); - - // Advance time by enough that the second Suspense's promise resolves - // and switches back to the normal view - ReactNoop.expire(1000); - await advanceTimers(1000); - - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushAndYield(['B']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); - }); + function span(prop) { + return {type: 'span', children: [], prop, hidden: false}; + } - it('continues rendering siblings after suspending', async () => { - // A shell is needed. The update cause it to suspend. - ReactNoop.render(} />); - expect(Scheduler).toFlushAndYield([]); - // B suspends. Continue rendering the remaining siblings. - ReactNoop.render( - }> - - - - - , - ); - // B suspends. Continue rendering the remaining siblings. - expect(Scheduler).toFlushAndYield([ - 'A', - 'Suspend! [B]', - 'C', - 'D', - 'Loading...', - ]); - // Did not commit yet. - expect(ReactNoop.getChildren()).toEqual([]); - - // Wait for data to resolve - await advanceTimers(100); - // Renders successfully - expect(Scheduler).toHaveYielded(['Promise resolved [B]']); - expect(Scheduler).toFlushAndYield(['A', 'B', 'C', 'D']); - expect(ReactNoop.getChildren()).toEqual([ - span('A'), - span('B'), - span('C'), - span('D'), - ]); - }); + function hiddenSpan(prop) { + return {type: 'span', children: [], prop, hidden: true}; + } - it('retries on error', async () => { - class ErrorBoundary extends React.Component { - state = {error: null}; - componentDidCatch(error) { - this.setState({error}); + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); } - reset() { - this.setState({error: null}); + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return ; } - render() { - if (this.state.error !== null) { - return ; + + function AsyncText(props) { + const text = props.text; + try { + TextResource.read([props.text, props.ms]); + Scheduler.unstable_yieldValue(text); + return ; + } catch (promise) { + if (typeof promise.then === 'function') { + Scheduler.unstable_yieldValue(`Suspend! [${text}]`); + } else { + Scheduler.unstable_yieldValue(`Error! [${text}]`); + } + throw promise; } - return this.props.children; } - } - - const errorBoundary = React.createRef(); - function App({renderContent}) { - return ( - }> - {renderContent ? ( - - - - ) : null} - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([]); + it('warns if the deprecated maxDuration option is used', () => { + function Foo() { + return ( + +
; + + ); + } + + ReactNoop.render(); + + expect(() => Scheduler.unstable_flushAll()).toErrorDev([ + 'Warning: maxDuration has been removed from React. ' + + 'Remove the maxDuration prop.' + + '\n in Suspense (at **)' + + '\n in Foo (at **)', + ]); + }); + + it('does not restart rendering for initial render', async () => { + function Bar(props) { + Scheduler.unstable_yieldValue('Bar'); + return props.children; + } + + function Foo() { + Scheduler.unstable_yieldValue('Foo'); + return ( + <> + }> + + + + + + + + + ); + } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); + ReactNoop.render(); + expect(Scheduler).toFlushAndYieldThrough([ + 'Foo', + 'Bar', + // A suspends + 'Suspend! [A]', + // But we keep rendering the siblings + 'B', + 'Loading...', + 'C', + // We leave D incomplete. + ]); + expect(ReactNoop.getChildren()).toEqual([]); - textResourceShouldFail = true; - ReactNoop.expire(1000); - await advanceTimers(1000); - textResourceShouldFail = false; + // Flush the promise completely + Scheduler.unstable_advanceTime(100); + await advanceTimers(100); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); + // Even though the promise has resolved, we should now flush + // and commit the in progress render instead of restarting. + expect(Scheduler).toFlushAndYield(['D']); + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('C'), + span('D'), + ]); - expect(Scheduler).toFlushAndYield([ - 'Error! [Result]', + // Await one micro task to attach the retry listeners. + await null; - // React retries one more time - 'Error! [Result]', + // Next, we'll flush the complete content. + expect(Scheduler).toFlushAndYield(['Bar', 'A', 'B']); - // Errored again on retry. Now handle it. - 'Caught error: Failed to load: Result', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Caught error: Failed to load: Result'), - ]); - }); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('B'), + span('C'), + span('D'), + ]); + }); - it('retries on error after falling back to a placeholder', async () => { - class ErrorBoundary extends React.Component { - state = {error: null}; - componentDidCatch(error) { - this.setState({error}); - } - reset() { - this.setState({error: null}); - } - render() { - if (this.state.error !== null) { - return ; + it('suspends rendering and continues later', async () => { + function Bar(props) { + Scheduler.unstable_yieldValue('Bar'); + return props.children; } - return this.props.children; - } - } - - const errorBoundary = React.createRef(); - function App() { - return ( - }> - - - - - ); - } - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + function Foo({renderBar}) { + Scheduler.unstable_yieldValue('Foo'); + return ( + }> + {renderBar ? ( + + + + + ) : null} + + ); + } - textResourceShouldFail = true; - ReactNoop.expire(3000); - await advanceTimers(3000); - textResourceShouldFail = false; + // Render empty shell. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Foo']); + + // The update will suspend. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Foo', + 'Bar', + // A suspends + 'Suspend! [A]', + // But we keep rendering the siblings + 'B', + 'Loading...', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush some of the time + await advanceTimers(50); + // Still nothing... + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Flush the promise completely + await advanceTimers(50); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['Foo', 'Bar', 'A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); - expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); - expect(Scheduler).toFlushAndYield([ - 'Error! [Result]', + it('suspends siblings and later recovers each independently', async () => { + // Render two sibling Suspense components + ReactNoop.render( + + }> + + + }> + + + , + ); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + 'Loading A...', + 'Suspend! [B]', + 'Loading B...', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Loading A...'), + span('Loading B...'), + ]); - // React retries one more time - 'Error! [Result]', + // Advance time by enough that the first Suspense's promise resolves and + // switches back to the normal view. The second Suspense should still + // show the placeholder + ReactNoop.expire(5000); + await advanceTimers(5000); - // Errored again on retry. Now handle it. + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A']); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('Loading B...'), + ]); - 'Caught error: Failed to load: Result', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Caught error: Failed to load: Result'), - ]); - }); + // Advance time by enough that the second Suspense's promise resolves + // and switches back to the normal view + ReactNoop.expire(1000); + await advanceTimers(1000); - it('can update at a higher priority while in a suspended state', async () => { - function App(props) { - return ( - }> - - - - ); - } - - // Initial mount - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['A', 'Suspend! [1]', 'Loading...']); - await advanceTimers(0); - expect(Scheduler).toHaveYielded(['Promise resolved [1]']); - expect(Scheduler).toFlushAndYield(['A', '1']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]); - - // Update the low-pri text - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'A', - // Suspends - 'Suspend! [2]', - 'Loading...', - ]); - - // While we're still waiting for the low-pri update to complete, update the - // high-pri text at high priority. - ReactNoop.flushSync(() => { - ReactNoop.render(); - }); - expect(Scheduler).toHaveYielded(['B', '1']); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); - // Unblock the low-pri text and finish - await advanceTimers(0); - expect(Scheduler).toHaveYielded(['Promise resolved [2]']); - expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); - }); + it('continues rendering siblings after suspending', async () => { + // A shell is needed. The update cause it to suspend. + ReactNoop.render(} />); + expect(Scheduler).toFlushAndYield([]); + // B suspends. Continue rendering the remaining siblings. + ReactNoop.render( + }> + + + + + , + ); + // B suspends. Continue rendering the remaining siblings. + expect(Scheduler).toFlushAndYield([ + 'A', + 'Suspend! [B]', + 'C', + 'D', + 'Loading...', + ]); + // Did not commit yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Wait for data to resolve + await advanceTimers(100); + // Renders successfully + expect(Scheduler).toHaveYielded(['Promise resolved [B]']); + expect(Scheduler).toFlushAndYield(['A', 'B', 'C', 'D']); + expect(ReactNoop.getChildren()).toEqual([ + span('A'), + span('B'), + span('C'), + span('D'), + ]); + }); - it('keeps working on lower priority work after being pinged', async () => { - // Advance the virtual time so that we're close to the edge of a bucket. - ReactNoop.expire(149); + it('retries on error', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + reset() { + this.setState({error: null}); + } + render() { + if (this.state.error !== null) { + return ( + + ); + } + return this.props.children; + } + } - function App(props) { - return ( - }> - {props.showA && } - {props.showB && } - - ); - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([]); - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); - - // Advance React's virtual time by enough to fall into a new async bucket, - // but not enough to expire the suspense timeout. - ReactNoop.expire(120); - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); - - await advanceTimers(0); - expect(Scheduler).toHaveYielded(['Promise resolved [A]']); - expect(Scheduler).toFlushAndYield(['A', 'B']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); - }); + const errorBoundary = React.createRef(); + function App({renderContent}) { + return ( + }> + {renderContent ? ( + + + + ) : null} + + ); + } - it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => { - function App(props) { - if (props.hide) { - return ; - } - return ( - - - - ); - } + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); - // Schedule a high pri update and a low pri update, without rendering in - // between. - ReactNoop.discreteUpdates(() => { - // High pri - ReactNoop.render(); - }); - // Low pri - ReactNoop.render(); - - expect(Scheduler).toFlushAndYield([ - // The first update suspends - 'Suspend! [Async]', - // but we have another pending update that we can work on - '(empty)', - ]); - expect(ReactNoop.getChildren()).toEqual([span('(empty)')]); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); - it('tries each subsequent level after suspending', async () => { - const root = ReactNoop.createRoot(); - - function App({step, shouldSuspend}) { - return ( - - - {shouldSuspend ? ( - - ) : ( - - )} - - ); - } + textResourceShouldFail = true; + ReactNoop.expire(1000); + await advanceTimers(1000); + textResourceShouldFail = false; + + expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); + + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', - function interrupt() { - // React has a heuristic to batch all updates that occur within the same - // event. This is a trick to circumvent that heuristic. - ReactNoop.flushSync(() => { - ReactNoop.renderToRootWithID(null, 'other-root'); + // React retries one more time + 'Error! [Result]', + + // Errored again on retry. Now handle it. + 'Caught error: Failed to load: Result', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Caught error: Failed to load: Result'), + ]); }); - } - // Mount the Suspense boundary without suspending, so that the subsequent - // updates suspend with a delay. - await ReactNoop.act(async () => { - root.render(); - }); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']); - - // Schedule an update at several distinct expiration times - await ReactNoop.act(async () => { - root.render(); - Scheduler.unstable_advanceTime(1000); - expect(Scheduler).toFlushAndYieldThrough(['Sibling']); - interrupt(); - - root.render(); - Scheduler.unstable_advanceTime(1000); - expect(Scheduler).toFlushAndYieldThrough(['Sibling']); - interrupt(); - - root.render(); - Scheduler.unstable_advanceTime(1000); - expect(Scheduler).toFlushAndYieldThrough(['Sibling']); - interrupt(); - - root.render(); - }); + it('retries on error after falling back to a placeholder', async () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + reset() { + this.setState({error: null}); + } + render() { + if (this.state.error !== null) { + return ( + + ); + } + return this.props.children; + } + } - // Should suspend at each distinct level - expect(Scheduler).toHaveYielded([ - 'Sibling', - 'Suspend! [Step 1]', - 'Sibling', - 'Suspend! [Step 2]', - 'Sibling', - 'Suspend! [Step 3]', - 'Sibling', - 'Step 4', - ]); - }); + const errorBoundary = React.createRef(); + function App() { + return ( + }> + + + + + ); + } - it('forces an expiration after an update times out', async () => { - ReactNoop.render( - - } /> - , - ); - expect(Scheduler).toFlushAndYield([]); - - ReactNoop.render( - - }> - - - - , - ); - - expect(Scheduler).toFlushAndYield([ - // The async child suspends - 'Suspend! [Async]', - // Render the placeholder - 'Loading...', - // Continue on the sibling - 'Sync', - ]); - // The update hasn't expired yet, so we commit nothing. - expect(ReactNoop.getChildren()).toEqual([]); - - // Advance both React's virtual time and Jest's timers by enough to expire - // the update, but not by enough to flush the suspending promise. - ReactNoop.expire(10000); - await advanceTimers(10000); - // No additional rendering work is required, since we already prepared - // the placeholder. - expect(Scheduler).toHaveYielded([]); - // Should have committed the placeholder. - expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); - - // Once the promise resolves, we render the suspended view - await advanceTimers(10000); - expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); - expect(Scheduler).toFlushAndYield(['Async']); - expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); - }); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); - it('switches to an inner fallback after suspending for a while', async () => { - // Advance the virtual time so that we're closer to the edge of a bucket. - ReactNoop.expire(200); - - ReactNoop.render( - - - }> - - }> - - - - , - ); - - expect(Scheduler).toFlushAndYield([ - 'Sync', - // The async content suspends - 'Suspend! [Outer content]', - 'Suspend! [Inner content]', - 'Loading inner...', - 'Loading outer...', - ]); - // The outer loading state finishes immediately. - expect(ReactNoop.getChildren()).toEqual([ - span('Sync'), - span('Loading outer...'), - ]); - - // Resolve the outer promise. - ReactNoop.expire(300); - await advanceTimers(300); - expect(Scheduler).toHaveYielded(['Promise resolved [Outer content]']); - expect(Scheduler).toFlushAndYield([ - 'Outer content', - 'Suspend! [Inner content]', - 'Loading inner...', - ]); - // Don't commit the inner placeholder yet. - expect(ReactNoop.getChildren()).toEqual([ - span('Sync'), - span('Loading outer...'), - ]); - - // Expire the inner timeout. - ReactNoop.expire(500); - await advanceTimers(500); - // Now that 750ms have elapsed since the outer placeholder timed out, - // we can timeout the inner placeholder. - expect(ReactNoop.getChildren()).toEqual([ - span('Sync'), - span('Outer content'), - span('Loading inner...'), - ]); - - // Finally, flush the inner promise. We should see the complete screen. - ReactNoop.expire(1000); - await advanceTimers(1000); - expect(Scheduler).toHaveYielded(['Promise resolved [Inner content]']); - expect(Scheduler).toFlushAndYield(['Inner content']); - expect(ReactNoop.getChildren()).toEqual([ - span('Sync'), - span('Outer content'), - span('Inner content'), - ]); - }); + textResourceShouldFail = true; + ReactNoop.expire(3000); + await advanceTimers(3000); + textResourceShouldFail = false; - it('renders an expiration boundary synchronously', async () => { - spyOnDev(console, 'error'); - // Synchronously render a tree that suspends - ReactNoop.flushSync(() => - ReactNoop.render( - - }> - - - - , - ), - ); - expect(Scheduler).toHaveYielded([ - // The async child suspends - 'Suspend! [Async]', - // We immediately render the fallback UI - 'Loading...', - // Continue on the sibling - 'Sync', - ]); - // The tree commits synchronously - expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); - - // Once the promise resolves, we render the suspended view - await advanceTimers(0); - expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); - expect(Scheduler).toFlushAndYield(['Async']); - expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); - }); + expect(Scheduler).toHaveYielded(['Promise rejected [Result]']); + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', + + // React retries one more time + 'Error! [Result]', + + // Errored again on retry. Now handle it. + + 'Caught error: Failed to load: Result', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Caught error: Failed to load: Result'), + ]); + }); - it('suspending inside an expired expiration boundary will bubble to the next one', async () => { - ReactNoop.flushSync(() => - ReactNoop.render( - - }> - }> - + it('can update at a higher priority while in a suspended state', async () => { + function App(props) { + return ( + }> + + + + ); + } + + // Initial mount + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['A', 'Suspend! [1]', 'Loading...']); + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [1]']); + expect(Scheduler).toFlushAndYield(['A', '1']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('1')]); + + // Update the low-pri text + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'A', + // Suspends + 'Suspend! [2]', + 'Loading...', + ]); + + // While we're still waiting for the low-pri update to complete, update the + // high-pri text at high priority. + ReactNoop.flushSync(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded(['B', '1']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + + // Unblock the low-pri text and finish + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [2]']); + expect(ReactNoop.getChildren()).toEqual([span('B'), span('1')]); + }); + + it('keeps working on lower priority work after being pinged', async () => { + // Advance the virtual time so that we're close to the edge of a bucket. + ReactNoop.expire(149); + + function App(props) { + return ( + }> + {props.showA && } + {props.showB && } + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance React's virtual time by enough to fall into a new async bucket, + // but not enough to expire the suspense timeout. + ReactNoop.expire(120); + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'B', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [A]']); + expect(Scheduler).toFlushAndYield(['A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); + + it('tries rendering a lower priority pending update even if a higher priority one suspends', async () => { + function App(props) { + if (props.hide) { + return ; + } + return ( + + + + ); + } + + // Schedule a high pri update and a low pri update, without rendering in + // between. + ReactNoop.discreteUpdates(() => { + // High pri + ReactNoop.render(); + }); + // Low pri + ReactNoop.render(); + + expect(Scheduler).toFlushAndYield([ + // The first update suspends + 'Suspend! [Async]', + // but we have another pending update that we can work on + '(empty)', + ]); + expect(ReactNoop.getChildren()).toEqual([span('(empty)')]); + }); + + it('tries each subsequent level after suspending', async () => { + const root = ReactNoop.createRoot(); + + function App({step, shouldSuspend}) { + return ( + + + {shouldSuspend ? ( + + ) : ( + + )} + + ); + } + + function interrupt() { + // React has a heuristic to batch all updates that occur within the same + // event. This is a trick to circumvent that heuristic. + ReactNoop.flushSync(() => { + ReactNoop.renderToRootWithID(null, 'other-root'); + }); + } + + // Mount the Suspense boundary without suspending, so that the subsequent + // updates suspend with a delay. + await ReactNoop.act(async () => { + root.render(); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Sibling', 'Step 0']); + + // Schedule an update at several distinct expiration times + await ReactNoop.act(async () => { + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + Scheduler.unstable_advanceTime(1000); + expect(Scheduler).toFlushAndYieldThrough(['Sibling']); + interrupt(); + + root.render(); + }); + + // Should suspend at each distinct level + expect(Scheduler).toHaveYielded([ + 'Sibling', + 'Suspend! [Step 1]', + 'Sibling', + 'Suspend! [Step 2]', + 'Sibling', + 'Suspend! [Step 3]', + 'Sibling', + 'Step 4', + ]); + }); + + it('forces an expiration after an update times out', async () => { + ReactNoop.render( + + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( + + }> + - - , - ), - ); - expect(Scheduler).toHaveYielded([ - 'Suspend! [Async]', - 'Suspend! [Loading (inner)...]', - 'Sync', - 'Loading (outer)...', - ]); - // The tree commits synchronously - expect(ReactNoop.getChildren()).toEqual([span('Loading (outer)...')]); - }); + , + ); - it('expires early by default', async () => { - ReactNoop.render( - - } /> - , - ); - expect(Scheduler).toFlushAndYield([]); - - ReactNoop.render( - - }> - - - - , - ); - - expect(Scheduler).toFlushAndYield([ - // The async child suspends - 'Suspend! [Async]', - 'Loading...', - // Continue on the sibling - 'Sync', - ]); - // The update hasn't expired yet, so we commit nothing. - expect(ReactNoop.getChildren()).toEqual([]); - - // Advance both React's virtual time and Jest's timers by enough to trigger - // the timeout, but not by enough to flush the promise or reach the true - // expiration time. - ReactNoop.expire(2000); - await advanceTimers(2000); - expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([span('Loading...'), span('Sync')]); - - // Once the promise resolves, we render the suspended view - await advanceTimers(1000); - expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); - expect(Scheduler).toFlushAndYield(['Async']); - expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); - }); + expect(Scheduler).toFlushAndYield([ + // The async child suspends + 'Suspend! [Async]', + // Render the placeholder + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to expire + // the update, but not by enough to flush the suspending promise. + ReactNoop.expire(10000); + await advanceTimers(10000); + // No additional rendering work is required, since we already prepared + // the placeholder. + expect(Scheduler).toHaveYielded([]); + // Should have committed the placeholder. + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('Sync'), + ]); - it('resolves successfully even if fallback render is pending', async () => { - ReactNoop.render( - <> - } /> - , - ); - expect(Scheduler).toFlushAndYield([]); - expect(ReactNoop.getChildren()).toEqual([]); - ReactNoop.render( - <> - }> - - - , - ); - expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']); - await advanceTimers(1500); - expect(Scheduler).toHaveYielded([]); - expect(ReactNoop.getChildren()).toEqual([]); - // Before we have a chance to flush, the promise resolves. - await advanceTimers(2000); - expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); - expect(Scheduler).toFlushAndYield([ - // We've now pinged the boundary but we don't know if we should restart yet, - // because we haven't completed the suspense boundary. - 'Loading...', - // Once we've completed the boundary we restarted. - 'Async', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Async')]); - }); + // Once the promise resolves, we render the suspended view + await advanceTimers(10000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); - it('throws a helpful error when an update is suspends without a placeholder', () => { - ReactNoop.render(); - expect(Scheduler).toFlushAndThrow( - 'AsyncText suspended while rendering, but no fallback UI was specified.', - ); - }); + it('switches to an inner fallback after suspending for a while', async () => { + // Advance the virtual time so that we're closer to the edge of a bucket. + ReactNoop.expire(200); - it('a Suspense component correctly handles more than one suspended child', async () => { - ReactNoop.render( - }> - - - , - ); - Scheduler.unstable_advanceTime(10000); - expect(Scheduler).toFlushExpired([ - 'Suspend! [A]', - 'Suspend! [B]', - 'Loading...', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); - - await advanceTimers(100); - - expect(Scheduler).toHaveYielded([ - 'Promise resolved [A]', - 'Promise resolved [B]', - ]); - expect(Scheduler).toFlushAndYield(['A', 'B']); - expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); - }); + ReactNoop.render( + + + }> + + }> + + + + , + ); - it('can resume rendering earlier than a timeout', async () => { - ReactNoop.render(} />); - expect(Scheduler).toFlushAndYield([]); - - ReactNoop.render( - }> - - , - ); - expect(Scheduler).toFlushAndYield(['Suspend! [Async]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([]); - - // Advance time by an amount slightly smaller than what's necessary to - // resolve the promise - await advanceTimers(99); - - // Nothing has rendered yet - expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([]); - - // Resolve the promise - await advanceTimers(1); - // We can now resume rendering - expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); - expect(Scheduler).toFlushAndYield(['Async']); - expect(ReactNoop.getChildren()).toEqual([span('Async')]); - }); + expect(Scheduler).toFlushAndYield([ + 'Sync', + // The async content suspends + 'Suspend! [Outer content]', + 'Suspend! [Inner content]', + 'Loading inner...', + 'Loading outer...', + ]); + // The outer loading state finishes immediately. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Loading outer...'), + ]); - it('starts working on an update even if its priority falls between two suspended levels', async () => { - function App(props) { - return ( - }> - {props.text === 'C' || props.text === 'S' ? ( - - ) : ( - - )} - - ); - } - - // First mount without suspending. This ensures we already have content - // showing so that subsequent updates will suspend. - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['S']); - - // Schedule an update, and suspend for up to 5 seconds. - React.unstable_withSuspenseConfig( - () => ReactNoop.render(), - { - timeoutMs: 5000, - }, - ); - // The update should suspend. - expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([span('S')]); - - // Advance time until right before it expires. - await advanceTimers(4999); - ReactNoop.expire(4999); - expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([span('S')]); - - // Schedule another low priority update. - React.unstable_withSuspenseConfig( - () => ReactNoop.render(), - { - timeoutMs: 10000, - }, - ); - // This update should also suspend. - expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); - expect(ReactNoop.getChildren()).toEqual([span('S')]); - - // Schedule a regular update. Its expiration time will fall between - // the expiration times of the previous two updates. - ReactNoop.render(); - expect(Scheduler).toFlushAndYield(['C']); - expect(ReactNoop.getChildren()).toEqual([span('C')]); - - await advanceTimers(10000); - // Flush the remaining work. - expect(Scheduler).toHaveYielded([ - 'Promise resolved [A]', - 'Promise resolved [B]', - ]); - // Nothing else to render. - expect(Scheduler).toFlushWithoutYielding(); - expect(ReactNoop.getChildren()).toEqual([span('C')]); - }); + // Resolve the outer promise. + ReactNoop.expire(300); + await advanceTimers(300); + expect(Scheduler).toHaveYielded(['Promise resolved [Outer content]']); + expect(Scheduler).toFlushAndYield([ + 'Outer content', + 'Suspend! [Inner content]', + 'Loading inner...', + ]); + // Don't commit the inner placeholder yet. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Loading outer...'), + ]); - it('flushes all expired updates in a single batch', async () => { - class Foo extends React.Component { - componentDidUpdate() { - Scheduler.unstable_yieldValue('Commit: ' + this.props.text); - } - componentDidMount() { - Scheduler.unstable_yieldValue('Commit: ' + this.props.text); - } - render() { - return ( + // Expire the inner timeout. + ReactNoop.expire(500); + await advanceTimers(500); + // Now that 750ms have elapsed since the outer placeholder timed out, + // we can timeout the inner placeholder. + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Outer content'), + span('Loading inner...'), + ]); + + // Finally, flush the inner promise. We should see the complete screen. + ReactNoop.expire(1000); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [Inner content]']); + expect(Scheduler).toFlushAndYield(['Inner content']); + expect(ReactNoop.getChildren()).toEqual([ + span('Sync'), + span('Outer content'), + span('Inner content'), + ]); + }); + + it('renders an expiration boundary synchronously', async () => { + spyOnDev(console, 'error'); + // Synchronously render a tree that suspends + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + + + + , + ), + ); + expect(Scheduler).toHaveYielded([ + // The async child suspends + 'Suspend! [Async]', + // We immediately render the fallback UI + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('Sync'), + ]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(0); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('suspending inside an expired expiration boundary will bubble to the next one', async () => { + ReactNoop.flushSync(() => + ReactNoop.render( + + }> + }> + + + + + , + ), + ); + expect(Scheduler).toHaveYielded([ + 'Suspend! [Async]', + 'Suspend! [Loading (inner)...]', + 'Sync', + 'Loading (outer)...', + ]); + // The tree commits synchronously + expect(ReactNoop.getChildren()).toEqual([span('Loading (outer)...')]); + }); + + it('expires early by default', async () => { + ReactNoop.render( + + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( + + }> + + + + , + ); + + expect(Scheduler).toFlushAndYield([ + // The async child suspends + 'Suspend! [Async]', + 'Loading...', + // Continue on the sibling + 'Sync', + ]); + // The update hasn't expired yet, so we commit nothing. + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance both React's virtual time and Jest's timers by enough to trigger + // the timeout, but not by enough to flush the promise or reach the true + // expiration time. + ReactNoop.expire(2000); + await advanceTimers(2000); + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([ + span('Loading...'), + span('Sync'), + ]); + + // Once the promise resolves, we render the suspended view + await advanceTimers(1000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async'), span('Sync')]); + }); + + it('resolves successfully even if fallback render is pending', async () => { + ReactNoop.render( + <> + } /> + , + ); + expect(Scheduler).toFlushAndYield([]); + expect(ReactNoop.getChildren()).toEqual([]); + ReactNoop.render( + <> + }> + + + , + ); + expect(ReactNoop.flushNextYield()).toEqual(['Suspend! [Async]']); + await advanceTimers(1500); + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop.getChildren()).toEqual([]); + // Before we have a chance to flush, the promise resolves. + await advanceTimers(2000); + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield([ + // We've now pinged the boundary but we don't know if we should restart yet, + // because we haven't completed the suspense boundary. + 'Loading...', + // Once we've completed the boundary we restarted. + 'Async', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + }); + + it('throws a helpful error when an update is suspends without a placeholder', () => { + ReactNoop.render(); + expect(Scheduler).toFlushAndThrow( + 'AsyncText suspended while rendering, but no fallback UI was specified.', + ); + }); + + it('a Suspense component correctly handles more than one suspended child', async () => { + ReactNoop.render( }> - - + + + , ); - } - } - - ReactNoop.render(); - ReactNoop.expire(1000); - jest.advanceTimersByTime(1000); - ReactNoop.render(); - ReactNoop.expire(1000); - jest.advanceTimersByTime(1000); - ReactNoop.render(); - ReactNoop.expire(1000); - jest.advanceTimersByTime(1000); - ReactNoop.render(); - - Scheduler.unstable_advanceTime(10000); - jest.advanceTimersByTime(10000); - - expect(Scheduler).toFlushExpired([ - 'Suspend! [goodbye]', - 'Loading...', - 'Commit: goodbye', - ]); - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); - - Scheduler.unstable_advanceTime(20000); - await advanceTimers(20000); - expect(Scheduler).toHaveYielded(['Promise resolved [goodbye]']); - expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); - - expect(Scheduler).toFlushAndYield(['goodbye']); - expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); - }); + Scheduler.unstable_advanceTime(10000); + expect(Scheduler).toFlushExpired([ + 'Suspend! [A]', + 'Suspend! [B]', + 'Loading...', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); - it('a suspended update that expires', async () => { - // Regression test. This test used to fall into an infinite loop. - function ExpensiveText({text}) { - // This causes the update to expire. - Scheduler.unstable_advanceTime(10000); - // Then something suspends. - return ; - } - - function App() { - return ( - - - - - - ); - } - - ReactNoop.render(); - expect(Scheduler).toFlushAndYield([ - 'Suspend! [A]', - 'Suspend! [B]', - 'Suspend! [C]', - ]); - expect(ReactNoop).toMatchRenderedOutput('Loading...'); - - await advanceTimers(200000); - expect(Scheduler).toHaveYielded([ - 'Promise resolved [A]', - 'Promise resolved [B]', - 'Promise resolved [C]', - ]); - - expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); - expect(ReactNoop).toMatchRenderedOutput( - <> - - - - , - ); - }); + await advanceTimers(100); + + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + ]); + expect(Scheduler).toFlushAndYield(['A', 'B']); + expect(ReactNoop.getChildren()).toEqual([span('A'), span('B')]); + }); - describe('legacy mode mode', () => { - it('times out immediately', async () => { - function App() { - return ( + it('can resume rendering earlier than a timeout', async () => { + ReactNoop.render(} />); + expect(Scheduler).toFlushAndYield([]); + + ReactNoop.render( }> - - + + , ); - } + expect(Scheduler).toFlushAndYield(['Suspend! [Async]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([]); + + // Advance time by an amount slightly smaller than what's necessary to + // resolve the promise + await advanceTimers(99); + + // Nothing has rendered yet + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([]); + + // Resolve the promise + await advanceTimers(1); + // We can now resume rendering + expect(Scheduler).toHaveYielded(['Promise resolved [Async]']); + expect(Scheduler).toFlushAndYield(['Async']); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + }); + + it('starts working on an update even if its priority falls between two suspended levels', async () => { + function App(props) { + return ( + }> + {props.text === 'C' || props.text === 'S' ? ( + + ) : ( + + )} + + ); + } + + // First mount without suspending. This ensures we already have content + // showing so that subsequent updates will suspend. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['S']); + + // Schedule an update, and suspend for up to 5 seconds. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 5000, + }, + ); + // The update should suspend. + expect(Scheduler).toFlushAndYield(['Suspend! [A]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Advance time until right before it expires. + await advanceTimers(4999); + ReactNoop.expire(4999); + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Schedule another low priority update. + React.unstable_withSuspenseConfig( + () => ReactNoop.render(), + { + timeoutMs: 10000, + }, + ); + // This update should also suspend. + expect(Scheduler).toFlushAndYield(['Suspend! [B]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('S')]); + + // Schedule a regular update. Its expiration time will fall between + // the expiration times of the previous two updates. + ReactNoop.render(); + expect(Scheduler).toFlushAndYield(['C']); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + + await advanceTimers(10000); + // Flush the remaining work. + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + ]); + // Nothing else to render. + expect(Scheduler).toFlushWithoutYielding(); + expect(ReactNoop.getChildren()).toEqual([span('C')]); + }); + + it('flushes all expired updates in a single batch', async () => { + class Foo extends React.Component { + componentDidUpdate() { + Scheduler.unstable_yieldValue('Commit: ' + this.props.text); + } + componentDidMount() { + Scheduler.unstable_yieldValue('Commit: ' + this.props.text); + } + render() { + return ( + }> + + + ); + } + } + + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + ReactNoop.expire(1000); + jest.advanceTimersByTime(1000); + ReactNoop.render(); + + Scheduler.unstable_advanceTime(10000); + jest.advanceTimersByTime(10000); + + expect(Scheduler).toFlushExpired([ + 'Suspend! [goodbye]', + 'Loading...', + 'Commit: goodbye', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + Scheduler.unstable_advanceTime(20000); + await advanceTimers(20000); + expect(Scheduler).toHaveYielded(['Promise resolved [goodbye]']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + expect(Scheduler).toFlushAndYield(['goodbye']); + expect(ReactNoop.getChildren()).toEqual([span('goodbye')]); + }); + + it('a suspended update that expires', async () => { + // Regression test. This test used to fall into an infinite loop. + function ExpensiveText({text}) { + // This causes the update to expire. + Scheduler.unstable_advanceTime(10000); + // Then something suspends. + return ; + } + + function App() { + return ( + + + + + + ); + } + + ReactNoop.render(); + expect(Scheduler).toFlushAndYield([ + 'Suspend! [A]', + 'Suspend! [B]', + 'Suspend! [C]', + ]); + expect(ReactNoop).toMatchRenderedOutput('Loading...'); + + await advanceTimers(200000); + expect(Scheduler).toHaveYielded([ + 'Promise resolved [A]', + 'Promise resolved [B]', + 'Promise resolved [C]', + ]); + + expect(Scheduler).toFlushAndYield(['A', 'B', 'C']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + + , + ); + }); + + describe('legacy mode mode', () => { + it('times out immediately', async () => { + function App() { + return ( + }> + + + ); + } + + // Times out immediately, ignoring the specified threshold. + ReactNoop.renderLegacySyncRoot(); + expect(Scheduler).toHaveYielded(['Suspend! [Result]', 'Loading...']); + expect(ReactNoop.getChildren()).toEqual([span('Loading...')]); + + ReactNoop.expire(100); + await advanceTimers(100); + + expect(Scheduler).toHaveYielded(['Promise resolved [Result]']); + expect(Scheduler).toFlushExpired(['Result']); + expect(ReactNoop.getChildren()).toEqual([span('Result')]); + }); + + it('times out immediately when Suspense is in legacy mode', async () => { + class UpdatingText extends React.Component { + state = {step: 1}; + render() { + return ; + } + } + + function Spinner() { + return ( + + + + + + ); + } + + const text = React.createRef(null); + function App() { + return ( + }> + + + + ); + } + + // Initial mount. + ReactNoop.renderLegacySyncRoot(); + await advanceTimers(100); + expect(Scheduler).toHaveYielded([ + 'Suspend! [Step: 1]', + 'Sibling', + 'Loading (1)', + 'Loading (2)', + 'Loading (3)', + 'Promise resolved [Step: 1]', + ]); + expect(Scheduler).toFlushExpired(['Step: 1']); + expect(ReactNoop).toMatchRenderedOutput( + <> + + + , + ); + + // Update. + text.current.setState({step: 2}, () => + Scheduler.unstable_yieldValue('Update did commit'), + ); + + expect(ReactNoop.flushNextYield()).toEqual([ + 'Suspend! [Step: 2]', + 'Loading (1)', + 'Loading (2)', + 'Loading (3)', + 'Update did commit', + ]); + expect(ReactNoop).toMatchRenderedOutput( + <> +