Skip to content

[New] component detection: track React imports #3149

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 2 commits into from
Nov 29, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
73 changes: 72 additions & 1 deletion lib/util/Components.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,15 @@ function mergeUsedPropTypes(propsList, newPropsList) {
}

const Lists = new WeakMap();
const ReactImports = new WeakMap();

/**
* Components
*/
class Components {
constructor() {
Lists.set(this, {});
ReactImports.set(this, {});
}

/**
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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) => {
Expand All @@ -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);
}
Expand Down
98 changes: 98 additions & 0 deletions tests/util/Component.js
Original file line number Diff line number Diff line change
@@ -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"'
);
});
});
});
});