Repository: yannickcr/eslint-plugin-react
Pull request #3149 (2021-11-26 18:32:09)

[New] component detection: track React imports
This PR adds a couple of basic tests to the existing `Components.detect` functionality.
Next it adds a facility for detecting the imported React identifiers during a parse, and adds a simple test for that detection.

This is an extraction of work initiated in https://github.com/yannickcr/eslint-plugin-react/pull/2921 and supports that PR

I think having a test runner for the `Components.detect` utility was worthwhile in itself, and separated that test-runner functionality into its own commit.


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index fafa423b..6c5dd7b6 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 ab2f219d..c0621b64 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);
       }


Test patch:
diff --git a/tests/util/Component.js b/tests/util/Component.js
new file mode 100644
index 00000000..858954af
--- /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 <React.Fragment />;
+          }`,
+      }, (_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 <React.Fragment />;
+          }
+        }`,
+      }, (_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"'
+        );
+      });
+    });
+  });
+});