diff --git a/CHANGELOG.md b/CHANGELOG.md
index fafa423b35..6c5dd7b6dd 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,10 @@ 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
## [7.27.1] - 2021.11.18
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
new file mode 100644
index 0000000000..858954af7c
--- /dev/null
+++ b/tests/util/Component.js
@@ -0,0 +1,98 @@
+'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"'
+ );
+ });
+ });
+ });
+});