diff --git a/lib/util/Components.js b/lib/util/Components.js index c0621b645c..909bdbd34e 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,93 @@ 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 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.reduce( + (acc, specifier) => { + acc[specifier.local.name] = specifier.imported.name; + return acc; + }, + {} + ) + : 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 + && scope.references.find( + (reference) => reference.identifier.name === defaultReactImportName + ).resolved.defs; + const potentialHookReference = isPotentialHookCall + && scope.references + && scope.references.find( + (reference) => reactHookImportNames[reference.identifier.name] + ); + const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs; + + const hookName = (isPotentialReactHookCall && node.callee.property.name) + || (isPotentialHookCall && potentialHookReference && node.callee.name); + const normalizedHookName = (reactHookImportNames && reactHookImportNames[hookName]) || hookName; + + 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 && hookName && !isHookShadowed); + return !!(isHookCall + && (!expectedHookNames || arrayIncludes(expectedHookNames, normalizedHookName))); + }, }; // Component detection instructions diff --git a/tests/util/Components.js b/tests/util/Components.js index 858954af7c..38627db95f 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,161 @@ 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 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 }]); + }); + }); + }); + }); }); });