From 8232873a3e837fa712f8ab9469d1ef7a0d7a9a96 Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Mon, 13 Dec 2021 04:57:42 -0800 Subject: [PATCH] [Test] Component 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. --- lib/util/Components.js | 71 +++++++++++++ tests/util/Component.js | 98 ------------------ tests/util/Components.js | 219 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 290 insertions(+), 98 deletions(-) delete mode 100644 tests/util/Component.js create mode 100644 tests/util/Components.js 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..f4a924f37a --- /dev/null +++ b/tests/util/Components.js @@ -0,0 +1,219 @@ +'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) }); + }]; + }) + ); + + 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, { + 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 }]); + }); + }); + }); + }); + }); +});