diff --git a/lib/util/Components.js b/lib/util/Components.js
index c0621b645c..177480d790 100644
--- a/lib/util/Components.js
+++ b/lib/util/Components.js
@@ -46,6 +46,8 @@ function mergeUsedPropTypes(propsList, newPropsList) {
return propsList.concat(propsToAdd);
}
+const USE_HOOK_PREFIX_REGEX = /^use/i;
+
const Lists = new WeakMap();
const ReactImports = new WeakMap();
@@ -787,6 +789,75 @@ function componentRule(rule, context) {
&& !!(node.params || []).length
);
},
+
+ isReactHookCall(node, expectedHookNames) {
+ if (node.type !== 'CallExpression') {
+ return false;
+ }
+
+ const defaultReactImports = components.getDefaultReactImports();
+ const namedReactImports = components.getNamedReactImports();
+
+ const defaultReactImportSpecifier = defaultReactImports
+ ? defaultReactImports[0]
+ : undefined;
+
+ const defaultReactImportName = defaultReactImportSpecifier
+ ? defaultReactImportSpecifier.local.name
+ : undefined;
+
+ const reactHookImportSpecifiers = namedReactImports
+ ? namedReactImports.filter((specifier) => specifier.imported.name.match(USE_HOOK_PREFIX_REGEX))
+ : undefined;
+ const reactHookImportNames = reactHookImportSpecifiers
+ ? reactHookImportSpecifiers.map((specifier) => specifier.local.name)
+ : undefined;
+
+ 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()
+ : undefined;
+
+ const reactResolvedDefs = isPotentialReactHookCall && scope.references.find(
+ (reference) => reference.identifier.name === defaultReactImportName
+ ).resolved.defs;
+ const hookResolvedDefs = isPotentialHookCall && scope.references.find(
+ (reference) => reactHookImportNames.includes(reference.identifier.name)
+ ).resolved.defs;
+
+ const hookName = (isPotentialReactHookCall && node.callee.property.name)
+ || (isPotentialHookCall && node.callee.name);
+
+ const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs
+ && reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding');
+
+ const isHookShadowed = isPotentialHookCall
+ && hookResolvedDefs
+ && hookResolvedDefs.some(
+ (hookDef) => hookDef.name.name === hookName
+ && hookDef.type !== 'ImportBinding'
+ );
+
+ const isHookCall = (isPotentialReactHookCall && !isReactShadowed)
+ || (isPotentialHookCall && !isHookShadowed);
+
+ return isHookCall
+ && (!expectedHookNames || expectedHookNames.includes(hookName));
+ },
};
// Component detection instructions
diff --git a/tests/util/Component.js b/tests/util/Component.js
deleted file mode 100644
index 858954af7c..0000000000
--- a/tests/util/Component.js
+++ /dev/null
@@ -1,98 +0,0 @@
-'use strict';
-
-const assert = require('assert');
-const eslint = require('eslint');
-const values = require('object.values');
-
-const Components = require('../../lib/util/Components');
-const parsers = require('../helpers/parsers');
-
-const ruleTester = new eslint.RuleTester({
- parserOptions: {
- ecmaVersion: 2018,
- sourceType: 'module',
- ecmaFeatures: {
- jsx: true,
- },
- },
-});
-
-describe('Components', () => {
- describe('static detect', () => {
- function testComponentsDetect(test, done) {
- const rule = Components.detect((context, components, util) => ({
- 'Program:exit'() {
- done(context, components, util);
- },
- }));
-
- const tests = {
- valid: parsers.all([Object.assign({}, test, {
- settings: {
- react: {
- version: 'detect',
- },
- },
- })]),
- invalid: [],
- };
- ruleTester.run(test.code, rule, tests);
- }
-
- it('should detect Stateless Function Component', () => {
- testComponentsDetect({
- code: `import React from 'react'
- function MyStatelessComponent() {
- return ;
- }`,
- }, (_context, components) => {
- assert.equal(components.length(), 1, 'MyStatelessComponent should be detected component');
- values(components.list()).forEach((component) => {
- assert.equal(
- component.node.id.name,
- 'MyStatelessComponent',
- 'MyStatelessComponent should be detected component'
- );
- });
- });
- });
-
- it('should detect Class Components', () => {
- testComponentsDetect({
- code: `import React from 'react'
- class MyClassComponent extends React.Component {
- render() {
- return ;
- }
- }`,
- }, (_context, components) => {
- assert(components.length() === 1, 'MyClassComponent should be detected component');
- values(components.list()).forEach((component) => {
- assert.equal(
- component.node.id.name,
- 'MyClassComponent',
- 'MyClassComponent should be detected component'
- );
- });
- });
- });
-
- it('should detect React Imports', () => {
- testComponentsDetect({
- code: 'import React, { useCallback, useState } from \'react\'',
- }, (_context, components) => {
- assert.deepEqual(
- components.getDefaultReactImports().map((specifier) => specifier.local.name),
- ['React'],
- 'default React import identifier should be "React"'
- );
-
- assert.deepEqual(
- components.getNamedReactImports().map((specifier) => specifier.local.name),
- ['useCallback', 'useState'],
- 'named React import identifiers should be "useCallback" and "useState"'
- );
- });
- });
- });
-});
diff --git a/tests/util/Components.js b/tests/util/Components.js
new file mode 100644
index 0000000000..853ff290c3
--- /dev/null
+++ b/tests/util/Components.js
@@ -0,0 +1,221 @@
+'use strict';
+
+const assert = require('assert');
+const eslint = require('eslint');
+const values = require('object.values');
+
+const Components = require('../../lib/util/Components');
+const parsers = require('../helpers/parsers');
+
+const ruleTester = new eslint.RuleTester({
+ parserOptions: {
+ ecmaVersion: 2018,
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true,
+ },
+ },
+});
+
+describe('Components', () => {
+ describe('static detect', () => {
+ 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 = Object.fromEntries(
+ Object.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) });
+ }];
+ })
+ );
+
+ const result = Object.assign({}, augmentedInstructions, {
+ 'Program:exit'(node) {
+ if (augmentedInstructions['Program:exit']) {
+ augmentedInstructions['Program:exit'](node, context, components, util);
+ }
+ done(components, instructionResults);
+ },
+ });
+
+ return result;
+ });
+
+ const tests = {
+ valid: parsers.all([Object.assign({}, test, {
+ settings: {
+ react: {
+ version: 'detect',
+ },
+ },
+ })]),
+ invalid: [],
+ };
+
+ ruleTester.run(test.code, rule, tests);
+ }
+
+ it('should detect Stateless Function Component', () => {
+ testComponentsDetect({
+ code: `import React from 'react'
+ function MyStatelessComponent() {
+ return ;
+ }`,
+ }, (components) => {
+ assert.equal(components.length(), 1, 'MyStatelessComponent should be detected component');
+ values(components.list()).forEach((component) => {
+ assert.equal(
+ component.node.id.name,
+ 'MyStatelessComponent',
+ 'MyStatelessComponent should be detected component'
+ );
+ });
+ });
+ });
+
+ it('should detect Class Components', () => {
+ testComponentsDetect({
+ code: `import React from 'react'
+ class MyClassComponent extends React.Component {
+ render() {
+ return ;
+ }
+ }`,
+ }, (components) => {
+ assert(components.length() === 1, 'MyClassComponent should be detected component');
+ values(components.list()).forEach((component) => {
+ assert.equal(
+ component.node.id.name,
+ 'MyClassComponent',
+ 'MyClassComponent should be detected component'
+ );
+ });
+ });
+ });
+
+ it('should detect React Imports', () => {
+ testComponentsDetect({
+ code: 'import React, { useCallback, useState } from \'react\'',
+ }, (components) => {
+ assert.deepEqual(
+ components.getDefaultReactImports().map((specifier) => specifier.local.name),
+ ['React'],
+ 'default React import identifier should be "React"'
+ );
+
+ assert.deepEqual(
+ components.getNamedReactImports().map((specifier) => specifier.local.name),
+ ['useCallback', 'useState'],
+ 'named React import identifiers should be "useCallback" and "useState"'
+ );
+ });
+ });
+
+ describe('utils', () => {
+ describe('isReactHookCall', () => {
+ it('should not identify hook-like call', () => {
+ testComponentsDetect({
+ code: `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 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 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 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 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 }]);
+ });
+ });
+ });
+ });
+ });
+});