Skip to content
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

[New] component detection: track React imports #3149

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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
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
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
@@ -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, {
ljharb marked this conversation as resolved.
Show resolved Hide resolved
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"'
);
});
});
});
});