diff --git a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js index b699a5a8f2076..8c5eeb004593a 100644 --- a/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js +++ b/packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js @@ -515,6 +515,22 @@ const tests = { `, options: [{additionalHooks: 'useCustomEffect'}], }, + { + // behaves like no deps + code: normalizeIndent` + function MyComponent(props) { + useSpecialEffect(() => { + console.log(props.foo); + }, null); + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + }, { code: normalizeIndent` function MyComponent(props) { @@ -1470,6 +1486,38 @@ const tests = { }, ], invalid: [ + { + code: normalizeIndent` + function MyComponent(props) { + useSpecialEffect(() => { + console.log(props.foo); + }, null); + } + `, + options: [{additionalHooks: 'useSpecialEffect'}], + errors: [ + { + message: + "React Hook useSpecialEffect 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 useSpecialEffect 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) { + useSpecialEffect(() => { + console.log(props.foo); + }, [props.foo]); + } + `, + }, + ], + }, + ], + }, { code: normalizeIndent` function MyComponent(props) { @@ -7821,6 +7869,24 @@ const testsTypescript = { } `, }, + { + code: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber); + }) + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + }, { code: normalizeIndent` function App() { @@ -8176,6 +8242,48 @@ const testsTypescript = { function MyComponent() { const [state, setState] = React.useState(0); + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber + state); + }, []) + } + `, + options: [ + { + additionalHooks: 'useSpecialEffect', + experimental_autoDependenciesHooks: ['useSpecialEffect'], + }, + ], + errors: [ + { + message: + "React Hook useSpecialEffect 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 [state, setState] = React.useState(0); + + useSpecialEffect(() => { + const someNumber: typeof state = 2; + setState(prevState => prevState + someNumber + state); + }, [state]) + } + `, + }, + ], + }, + ], + }, + { + code: normalizeIndent` + function MyComponent() { + const [state, setState] = React.useState(0); + useMemo(() => { const someNumber: typeof state = 2; console.log(someNumber); diff --git a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts index d33e0430139a3..624d28e3b332c 100644 --- a/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts +++ b/packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts @@ -61,27 +61,38 @@ const rule = { enableDangerousAutofixThisMayCauseInfiniteLoops: { type: 'boolean', }, + experimental_autoDependenciesHooks: { + type: 'array', + items: { + type: 'string', + }, + }, }, }, ], }, create(context: Rule.RuleContext) { + const rawOptions = context.options && context.options[0]; + // Parse the `additionalHooks` regex. const additionalHooks = - context.options && - context.options[0] && - context.options[0].additionalHooks - ? new RegExp(context.options[0].additionalHooks) + rawOptions && rawOptions.additionalHooks + ? new RegExp(rawOptions.additionalHooks) : undefined; const enableDangerousAutofixThisMayCauseInfiniteLoops: boolean = - (context.options && - context.options[0] && - context.options[0].enableDangerousAutofixThisMayCauseInfiniteLoops) || + (rawOptions && + rawOptions.enableDangerousAutofixThisMayCauseInfiniteLoops) || false; + const experimental_autoDependenciesHooks: ReadonlyArray = + rawOptions && Array.isArray(rawOptions.experimental_autoDependenciesHooks) + ? rawOptions.experimental_autoDependenciesHooks + : []; + const options = { additionalHooks, + experimental_autoDependenciesHooks, enableDangerousAutofixThisMayCauseInfiniteLoops, }; @@ -162,6 +173,7 @@ const rule = { reactiveHook: Node, reactiveHookName: string, isEffect: boolean, + isAutoDepsHook: boolean, ): void { if (isEffect && node.async) { reportProblem({ @@ -649,6 +661,9 @@ const rule = { } if (!declaredDependenciesNode) { + if (isAutoDepsHook) { + return; + } // Check if there are any top-level setState() calls. // Those tend to lead to infinite loops. let setStateInsideEffectWithoutDeps: string | null = null; @@ -711,6 +726,13 @@ const rule = { } return; } + if ( + isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null + ) { + return; + } const declaredDependencies: Array = []; const externalDependencies = new Set(); @@ -1318,10 +1340,19 @@ const rule = { return; } + const isAutoDepsHook = + options.experimental_autoDependenciesHooks.includes(reactiveHookName); + // Check the declared dependencies for this reactive hook. If there is no // second argument then the reactive callback will re-run on every render. // So no need to check for dependency inclusion. - if (!declaredDependenciesNode && !isEffect) { + if ( + (!declaredDependenciesNode || + (isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null)) && + !isEffect + ) { // These are only used for optimization. if ( reactiveHookName === 'useMemo' || @@ -1355,11 +1386,17 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled case 'Identifier': - if (!declaredDependenciesNode) { - // No deps, no problems. + if ( + !declaredDependenciesNode || + (isAutoDepsHook && + declaredDependenciesNode.type === 'Literal' && + declaredDependenciesNode.value === null) + ) { + // Always runs, no problems. return; // Handled } // The function passed as a callback is not written inline. @@ -1408,6 +1445,7 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled case 'VariableDeclarator': @@ -1427,6 +1465,7 @@ const rule = { reactiveHook, reactiveHookName, isEffect, + isAutoDepsHook, ); return; // Handled }