diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0b3d7ec9c2..23af7ac386 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 c0621b645c..3d9221d13b 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
diff --git a/tests/util/Components.js b/tests/util/Components.js
index 858954af7c..694168fb6f 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 ;
}`,
- }, (_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 ;
}
}`,
- }, (_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 }]);
+ });
+ });
+ });
});
});