From 5a253803c2b91d1ddcfe4a07c30f0a3a8ba26ded Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Mon, 13 Dec 2021 04:57:42 -0800 Subject: [PATCH] [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. --- CHANGELOG.md | 2 + lib/util/Components.js | 81 ++++++++++++++ tests/util/Components.js | 222 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 296 insertions(+), 9 deletions(-) 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 }]); + }); + }); + }); }); });