Repository: facebook/react
Pull request: [eslint-plugin-react-hooks] add experimental_autoDependenciesHooks option


Changed files:
filename: packages/eslint-plugin-react-hooks/__tests__/ESLintRuleExhaustiveDeps-test.js
patch: @@ -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<number>(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<number>(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<number>(0);
+
+                useSpecialEffect(() => {
+                  const someNumber: typeof state = 2;
+                  setState(prevState => prevState + someNumber + state);
+                }, [state])
+              }
+              `,
+            },
+          ],
+        },
+      ],
+    },
+    {
+      code: normalizeIndent`
+        function MyComponent() {
+          const [state, setState] = React.useState<number>(0);
+
           useMemo(() => {
             const someNumber: typeof state = 2;
             console.log(someNumber);

filename: packages/eslint-plugin-react-hooks/src/rules/ExhaustiveDeps.ts
patch: @@ -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<string> =
+      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<DeclaredDependency> = [];
       const externalDependencies = new Set<string>();
@@ -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
               }