Repository: yannickcr/eslint-plugin-react
Pull request #3156 (2021-12-13 14:32:58)

[New] Component detection: add `util.isReactHookCall`
* Rename Components test suite filename to match sibling lib/util/Components filename.
* Extend Components `testComponentsDetect` function to accept custom instructions, and to accumulate the results of processing those instructions.
* Add utility to check whether a `CallExpression` is a React hook call.

### 🗣 Discussion

This work extracts the somewhat complicated guard clause from #2921
It also makes hooks-usage detection generic, so the util can be used to check for:
* call to any React hook
* call to one or more specific React hooks; this will be used to check for `useState` calls in #2921

I converted this PR to a Draft while I work to extract this in a way that leaves the `hook-use-state` rule clean and easy to process.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b3d7ec9..23af7ac3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
 
 ### Added
 * [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit)
+* component detection: add `util.isReactHookCall` ([#3156][] @duncanbeevers)
 
 ### Fixed
 * [`jsx-indent-props`]: Reset `line.isUsingOperator` correctly after ternary ([#3146][] @tobiaswaltl)
@@ -16,6 +17,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel
 * [Tests] component detection: Add testing scaffolding ([#3149][] @duncanbeevers)
 * [New] component detection: track React imports ([#3149][] @duncanbeevers)
 
+[#3156]: https://github.com/yannickcr/eslint-plugin-react/pull/3156
 [#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149
 [#3146]: https://github.com/yannickcr/eslint-plugin-react/pull/3146
 [#3129]: https://github.com/yannickcr/eslint-plugin-react/pull/3129
diff --git a/lib/util/Components.js b/lib/util/Components.js
index c0621b64..3d9221d1 100644
--- a/lib/util/Components.js
+++ b/lib/util/Components.js
@@ -7,6 +7,7 @@
 
 const doctrine = require('doctrine');
 const arrayIncludes = require('array-includes');
+const fromEntries = require('object.fromentries');
 const values = require('object.values');
 
 const variableUtil = require('./variable');
@@ -46,6 +47,8 @@ function mergeUsedPropTypes(propsList, newPropsList) {
   return propsList.concat(propsToAdd);
 }
 
+const USE_HOOK_PREFIX_REGEX = /^use[A-Z]/;
+
 const Lists = new WeakMap();
 const ReactImports = new WeakMap();
 
@@ -787,6 +790,84 @@ function componentRule(rule, context) {
         && !!(node.params || []).length
       );
     },
+
+    /**
+     * Identify whether a node (CallExpression) is a call to a React hook
+     *
+     * @param {ASTNode} node The AST node being searched. (expects CallExpression)
+     * @param {('useCallback'|'useContext'|'useDebugValue'|'useEffect'|'useImperativeHandle'|'useLayoutEffect'|'useMemo'|'useReducer'|'useRef'|'useState')[]} [expectedHookNames] React hook names to which search is limited.
+     * @returns {Boolean} True if the node is a call to a React hook
+     */
+    isReactHookCall(node, expectedHookNames) {
+      if (node.type !== 'CallExpression') {
+        return false;
+      }
+
+      const defaultReactImports = components.getDefaultReactImports();
+      const namedReactImports = components.getNamedReactImports();
+
+      const defaultReactImportName = defaultReactImports
+        && defaultReactImports[0]
+        && defaultReactImports[0].local.name;
+      const reactHookImportSpecifiers = namedReactImports
+        && namedReactImports.filter((specifier) => USE_HOOK_PREFIX_REGEX.test(specifier.imported.name));
+      const reactHookImportNames = reactHookImportSpecifiers
+        && fromEntries(reactHookImportSpecifiers.map((specifier) => [specifier.local.name, specifier.imported.name]));
+
+      const isPotentialReactHookCall = defaultReactImportName
+        && node.callee.type === 'MemberExpression'
+        && node.callee.object.type === 'Identifier'
+        && node.callee.object.name === defaultReactImportName
+        && node.callee.property.type === 'Identifier'
+        && node.callee.property.name.match(USE_HOOK_PREFIX_REGEX);
+
+      const isPotentialHookCall = reactHookImportNames
+        && node.callee.type === 'Identifier'
+        && node.callee.name.match(USE_HOOK_PREFIX_REGEX);
+
+      const scope = (isPotentialReactHookCall || isPotentialHookCall) && context.getScope();
+
+      const reactResolvedDefs = isPotentialReactHookCall
+        && scope.references
+        && scope.references.find(
+          (reference) => reference.identifier.name === defaultReactImportName
+        ).resolved.defs;
+
+      const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs
+        && reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding');
+
+      const potentialHookReference = isPotentialHookCall
+        && scope.references
+        && scope.references.find(
+          (reference) => reactHookImportNames[reference.identifier.name]
+        );
+
+      const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs;
+      const localHookName = (isPotentialReactHookCall && node.callee.property.name)
+        || (isPotentialHookCall && potentialHookReference && node.callee.name);
+      const isHookShadowed = isPotentialHookCall
+        && hookResolvedDefs
+        && hookResolvedDefs.some(
+          (hookDef) => hookDef.name.name === localHookName
+          && hookDef.type !== 'ImportBinding'
+        );
+
+      const isHookCall = (isPotentialReactHookCall && !isReactShadowed)
+        || (isPotentialHookCall && localHookName && !isHookShadowed);
+
+      if (!isHookCall) {
+        return false;
+      }
+
+      if (!expectedHookNames) {
+        return true;
+      }
+
+      return arrayIncludes(
+        expectedHookNames,
+        (reactHookImportNames && reactHookImportNames[localHookName]) || localHookName
+      );
+    },
   };
 
   // Component detection instructions


Test patch:
diff --git a/tests/util/Components.js b/tests/util/Components.js
index 858954af..694168fb 100644
--- a/tests/util/Components.js
+++ b/tests/util/Components.js
@@ -1,7 +1,9 @@
 'use strict';
 
 const assert = require('assert');
+const entries = require('object.entries');
 const eslint = require('eslint');
+const fromEntries = require('object.fromentries');
 const values = require('object.values');
 
 const Components = require('../../lib/util/Components');
@@ -19,12 +21,32 @@ const ruleTester = new eslint.RuleTester({
 
 describe('Components', () => {
   describe('static detect', () => {
-    function testComponentsDetect(test, done) {
-      const rule = Components.detect((context, components, util) => ({
-        'Program:exit'() {
-          done(context, components, util);
-        },
-      }));
+    function testComponentsDetect(test, instructionsOrDone, orDone) {
+      const done = orDone || instructionsOrDone;
+      const instructions = orDone ? instructionsOrDone : instructionsOrDone;
+
+      const rule = Components.detect((_context, components, util) => {
+        const instructionResults = [];
+
+        const augmentedInstructions = fromEntries(
+          entries(instructions || {}).map((nodeTypeAndHandler) => {
+            const nodeType = nodeTypeAndHandler[0];
+            const handler = nodeTypeAndHandler[1];
+            return [nodeType, (node) => {
+              instructionResults.push({ type: nodeType, result: handler(node, context, components, util) });
+            }];
+          })
+        );
+
+        return Object.assign({}, augmentedInstructions, {
+          'Program:exit'(node) {
+            if (augmentedInstructions['Program:exit']) {
+              augmentedInstructions['Program:exit'](node, context, components, util);
+            }
+            done(components, instructionResults);
+          },
+        });
+      });
 
       const tests = {
         valid: parsers.all([Object.assign({}, test, {
@@ -36,6 +58,7 @@ describe('Components', () => {
         })]),
         invalid: [],
       };
+
       ruleTester.run(test.code, rule, tests);
     }
 
@@ -45,7 +68,7 @@ describe('Components', () => {
           function MyStatelessComponent() {
             return <React.Fragment />;
           }`,
-      }, (_context, components) => {
+      }, (components) => {
         assert.equal(components.length(), 1, 'MyStatelessComponent should be detected component');
         values(components.list()).forEach((component) => {
           assert.equal(
@@ -65,7 +88,7 @@ describe('Components', () => {
             return <React.Fragment />;
           }
         }`,
-      }, (_context, components) => {
+      }, (components) => {
         assert(components.length() === 1, 'MyClassComponent should be detected component');
         values(components.list()).forEach((component) => {
           assert.equal(
@@ -80,7 +103,7 @@ describe('Components', () => {
     it('should detect React Imports', () => {
       testComponentsDetect({
         code: 'import React, { useCallback, useState } from \'react\'',
-      }, (_context, components) => {
+      }, (components) => {
         assert.deepEqual(
           components.getDefaultReactImports().map((specifier) => specifier.local.name),
           ['React'],
@@ -94,5 +117,186 @@ describe('Components', () => {
         );
       });
     });
+
+    describe('utils', () => {
+      describe('isReactHookCall', () => {
+        it('should not identify hook-like call', () => {
+          testComponentsDetect({
+            code: `import { useRef } from 'react'
+            function useColor() {
+              return useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
+          });
+        });
+
+        it('should identify hook call', () => {
+          testComponentsDetect({
+            code: `import { useState } from 'react'
+            function useColor() {
+              return useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should identify aliased hook call', () => {
+          testComponentsDetect({
+            code: `import { useState as useStateAlternative } from 'react'
+            function useColor() {
+              return useStateAlternative()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should identify aliased present named hook call', () => {
+          testComponentsDetect({
+            code: `import { useState as useStateAlternative } from 'react'
+            function useColor() {
+              return useStateAlternative()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should not identify shadowed hook call', () => {
+          testComponentsDetect({
+            code: `import { useState } from 'react'
+            function useColor() {
+              function useState() {
+                return null
+              }
+              return useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
+          });
+        });
+
+        it('should not identify shadowed aliased present named hook call', () => {
+          testComponentsDetect({
+            code: `import { useState as useStateAlternative } from 'react'
+            function useColor() {
+              function useStateAlternative() {
+                return null
+              }
+              return useStateAlternative()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
+          });
+        });
+
+        it('should identify React hook call', () => {
+          testComponentsDetect({
+            code: `import React from 'react'
+            function useColor() {
+              return React.useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should identify aliased React hook call', () => {
+          testComponentsDetect({
+            code: `import ReactAlternative from 'react'
+            function useColor() {
+              return ReactAlternative.useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should not identify shadowed React hook call', () => {
+          testComponentsDetect({
+            code: `import React from 'react'
+            function useColor() {
+              const React = {
+                useState: () => null
+              }
+              return React.useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
+          });
+        });
+
+        it('should identify present named hook call', () => {
+          testComponentsDetect({
+            code: `import { useState } from 'react'
+            function useColor() {
+              return useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should identify present named React hook call', () => {
+          testComponentsDetect({
+            code: `import React from 'react'
+            function useColor() {
+              return React.useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]);
+          });
+        });
+
+        it('should not identify missing named hook call', () => {
+          testComponentsDetect({
+            code: `import { useState } from 'react'
+            function useColor() {
+              return useState()
+            }`,
+          }, {
+            CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useRef']),
+          }, (_components, instructionResults) => {
+            assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]);
+          });
+        });
+      });
+    });
+
+    describe('testComponentsDetect', () => {
+      it('should log Program:exit instruction', () => {
+        testComponentsDetect({
+          code: '',
+        }, {
+          'Program:exit': () => true,
+        }, (_components, instructionResults) => {
+          assert.deepEqual(instructionResults, [{ type: 'Program:exit', result: true }]);
+        });
+      });
+    });
   });
 });