From 8b98e604724bccb1133c474975307a13b210f21d Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Fri, 26 Nov 2021 10:26:10 -0800 Subject: [PATCH 1/2] [Tests] component detection: Add testing scaffolding Test detection of Class Components and Stateless Function Components Lay scaffolding for other flavors of tests including further component types, pragma detection, and utils functions --- CHANGELOG.md | 3 ++ tests/util/Component.js | 80 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) create mode 100644 tests/util/Component.js diff --git a/CHANGELOG.md b/CHANGELOG.md index fafa423b35..31bd0bd0e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Changed * [Refactor] [`no-arrow-function-lifecycle`], [`no-unused-class-component-methods`]: use report/messages convention (@ljharb) +* [Tests] component detection: Add testing scaffolding ([#3149][] @duncanbeevers) + +[#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149 ## [7.27.1] - 2021.11.18 diff --git a/tests/util/Component.js b/tests/util/Component.js new file mode 100644 index 0000000000..55bf97017d --- /dev/null +++ b/tests/util/Component.js @@ -0,0 +1,80 @@ +'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' + ); + }); + }); + }); + }); +}); From a09debf0ac11e4d1a36750605e0a0137f2fe4728 Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Fri, 26 Nov 2021 01:08:07 -0800 Subject: [PATCH 2/2] [New] components detection: track React imports The default React import and named React import specifiers are tracked during a Components.detect rules definition. Rules using Components.detect can access the default import specifier using `components.getDefaultReactImport()` and an array any named import specifiers using `components.getNamedReactImports()` Within a rule, these specifier nodes can be checked to ensure identifiers in scope correspond with the imported identifiers. Not treating this as semver-minor since it's not part of the documented API. --- CHANGELOG.md | 1 + lib/util/Components.js | 73 ++++++++++++++++++++++++++++++++++++++++- tests/util/Component.js | 18 ++++++++++ 3 files changed, 91 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31bd0bd0e1..6c5dd7b6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Changed * [Refactor] [`no-arrow-function-lifecycle`], [`no-unused-class-component-methods`]: use report/messages convention (@ljharb) * [Tests] component detection: Add testing scaffolding ([#3149][] @duncanbeevers) +* [New] component detection: track React imports ([#3149][] @duncanbeevers) [#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149 diff --git a/lib/util/Components.js b/lib/util/Components.js index ab2f219d35..c0621b645c 100644 --- a/lib/util/Components.js +++ b/lib/util/Components.js @@ -47,6 +47,7 @@ function mergeUsedPropTypes(propsList, newPropsList) { } const Lists = new WeakMap(); +const ReactImports = new WeakMap(); /** * Components @@ -54,6 +55,7 @@ const Lists = new WeakMap(); class Components { constructor() { Lists.set(this, {}); + ReactImports.set(this, {}); } /** @@ -179,6 +181,52 @@ class Components { const list = Lists.get(this); return Object.keys(list).filter((i) => list[i].confidence >= 2).length; } + + /** + * Return the node naming the default React import + * It can be used to determine the local name of import, even if it's imported + * with an unusual name. + * + * @returns {ASTNode} React default import node + */ + getDefaultReactImports() { + return ReactImports.get(this).defaultReactImports; + } + + /** + * Return the nodes of all React named imports + * + * @returns {Object} The list of React named imports + */ + getNamedReactImports() { + return ReactImports.get(this).namedReactImports; + } + + /** + * Add the default React import specifier to the scope + * + * @param {ASTNode} specifier The AST Node of the default React import + * @returns {void} + */ + addDefaultReactImport(specifier) { + const info = ReactImports.get(this); + ReactImports.set(this, Object.assign({}, info, { + defaultReactImports: (info.defaultReactImports || []).concat(specifier), + })); + } + + /** + * Add a named React import specifier to the scope + * + * @param {ASTNode} specifier The AST Node of a named React import + * @returns {void} + */ + addNamedReactImport(specifier) { + const info = ReactImports.get(this); + ReactImports.set(this, Object.assign({}, info, { + namedReactImports: (info.namedReactImports || []).concat(specifier), + })); + } } function getWrapperFunctions(context, pragma) { @@ -857,6 +905,25 @@ function componentRule(rule, context) { }, }; + // Detect React import specifiers + const reactImportInstructions = { + ImportDeclaration(node) { + const isReactImported = node.source.type === 'Literal' && node.source.value === 'react'; + if (!isReactImported) { + return; + } + + node.specifiers.forEach((specifier) => { + if (specifier.type === 'ImportDefaultSpecifier') { + components.addDefaultReactImport(specifier); + } + if (specifier.type === 'ImportSpecifier') { + components.addNamedReactImport(specifier); + } + }); + }, + }; + // Update the provided rule instructions to add the component detection const ruleInstructions = rule(context, components, utils); const updatedRuleInstructions = Object.assign({}, ruleInstructions); @@ -866,7 +933,8 @@ function componentRule(rule, context) { const allKeys = new Set(Object.keys(detectionInstructions).concat( Object.keys(propTypesInstructions), Object.keys(usedPropTypesInstructions), - Object.keys(defaultPropsInstructions) + Object.keys(defaultPropsInstructions), + Object.keys(reactImportInstructions) )); allKeys.forEach((instruction) => { @@ -883,6 +951,9 @@ function componentRule(rule, context) { if (instruction in defaultPropsInstructions) { defaultPropsInstructions[instruction](node); } + if (instruction in reactImportInstructions) { + reactImportInstructions[instruction](node); + } if (ruleInstructions[instruction]) { return ruleInstructions[instruction](node); } diff --git a/tests/util/Component.js b/tests/util/Component.js index 55bf97017d..858954af7c 100644 --- a/tests/util/Component.js +++ b/tests/util/Component.js @@ -76,5 +76,23 @@ describe('Components', () => { }); }); }); + + 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"' + ); + }); + }); }); });