From 32f2e24e2c268d08f8ed76a5b2fdbe8a9663579e Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Wed, 24 Nov 2021 20:57:11 -0800 Subject: [PATCH 01/11] [Refactor] `no-arrow-function-lifecycle`, `no-unused-class-component-methods`: use report/messages convention --- CHANGELOG.md | 3 ++ lib/rules/no-arrow-function-lifecycle.js | 50 +++++++++++-------- .../no-unused-class-component-methods.js | 24 +++++++-- 3 files changed, 53 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53ffc2513b..fafa423b35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,9 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ## Unreleased +### Changed +* [Refactor] [`no-arrow-function-lifecycle`], [`no-unused-class-component-methods`]: use report/messages convention (@ljharb) + ## [7.27.1] - 2021.11.18 ### Fixed diff --git a/lib/rules/no-arrow-function-lifecycle.js b/lib/rules/no-arrow-function-lifecycle.js index 90ae4de1a1..e2a69f3d68 100644 --- a/lib/rules/no-arrow-function-lifecycle.js +++ b/lib/rules/no-arrow-function-lifecycle.js @@ -11,6 +11,7 @@ const Components = require('../util/Components'); const astUtil = require('../util/ast'); const docsUrl = require('../util/docsUrl'); const lifecycleMethods = require('../util/lifecycleMethods'); +const report = require('../util/report'); function getText(node) { const params = node.value.params.map((p) => p.name); @@ -26,6 +27,10 @@ function getText(node) { return null; } +const messages = { + lifecycle: '{{propertyName}} is a React lifecycle method, and should not be an arrow function or in a class field. Use an instance method instead.', +}; + module.exports = { meta: { docs: { @@ -34,6 +39,7 @@ module.exports = { recommended: false, url: docsUrl('no-arrow-function-lifecycle'), }, + messages, schema: [], fixable: 'code', }, @@ -95,26 +101,30 @@ module.exports = { (previousComment.length > 0 ? previousComment[0] : body).range[0], ]; - context.report({ - node, - message: '{{propertyName}} is a React lifecycle method, and should not be an arrow function or in a class field. Use an instance method instead.', - data: { - propertyName, - }, - fix(fixer) { - if (!sourceCode.getCommentsAfter) { - // eslint 3.x - return isBlockBody && fixer.replaceTextRange(headRange, getText(node)); - } - return [].concat( - fixer.replaceTextRange(headRange, getText(node)), - isBlockBody ? [] : fixer.replaceTextRange( - bodyRange, - `{ return ${previousComment.map((x) => sourceCode.getText(x)).join('')}${sourceCode.getText(body)}${nextComment.map((x) => sourceCode.getText(x)).join('')}; }` - ) - ); - }, - }); + report( + context, + messages.lifecycle, + 'lifecycle', + { + node, + data: { + propertyName, + }, + fix(fixer) { + if (!sourceCode.getCommentsAfter) { + // eslint 3.x + return isBlockBody && fixer.replaceTextRange(headRange, getText(node)); + } + return [].concat( + fixer.replaceTextRange(headRange, getText(node)), + isBlockBody ? [] : fixer.replaceTextRange( + bodyRange, + `{ return ${previousComment.map((x) => sourceCode.getText(x)).join('')}${sourceCode.getText(body)}${nextComment.map((x) => sourceCode.getText(x)).join('')}; }` + ) + ); + }, + } + ); } }); } diff --git a/lib/rules/no-unused-class-component-methods.js b/lib/rules/no-unused-class-component-methods.js index 442d29d999..3bcadd1d53 100644 --- a/lib/rules/no-unused-class-component-methods.js +++ b/lib/rules/no-unused-class-component-methods.js @@ -7,6 +7,7 @@ const Components = require('../util/Components'); const docsUrl = require('../util/docsUrl'); +const report = require('../util/report'); // ------------------------------------------------------------------------------ // Rule Definition @@ -92,6 +93,11 @@ function getInitialClassInfo(node, isClass) { }; } +const messages = { + unused: 'Unused method or property "{{name}}"', + unusedWithClass: 'Unused method or property "{{name}}" of class "{{className}}"', +}; + module.exports = { meta: { docs: { @@ -100,6 +106,7 @@ module.exports = { recommended: false, url: docsUrl('no-unused-class-component-methods'), }, + messages, schema: [ { type: 'object', @@ -137,10 +144,19 @@ module.exports = { ) { const className = (classInfo.classNode.id && classInfo.classNode.id.name) || ''; - context.report({ - node, - message: `Unused method or property "${name}"${className ? ` of class "${className}"` : ''}`, - }); + const messageID = className ? 'unusedWithClass' : 'unused'; + report( + context, + messages[messageID], + messageID, + { + node, + data: { + name, + className, + }, + } + ); } } } From 8b98e604724bccb1133c474975307a13b210f21d Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Fri, 26 Nov 2021 10:26:10 -0800 Subject: [PATCH 02/11] [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 03/11] [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"' + ); + }); + }); }); }); From d5bf8d96696b64aa53425e31bb1bd3d87e206db4 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Sun, 28 Nov 2021 15:28:19 -0800 Subject: [PATCH 04/11] [Dev Deps] update `eslint-plugin-import` --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 8bfd98b980..f52789d94a 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8", "eslint-config-airbnb-base": "^15.0.0", "eslint-plugin-eslint-plugin": "^2.3.0 || ^3.5.3 || ^4.0.1", - "eslint-plugin-import": "^2.25.2", + "eslint-plugin-import": "^2.25.3", "eslint-remote-tester": "^2.0.1", "eslint-remote-tester-repositories": "^0.0.3", "eslint-scope": "^3.7.3", From e3d3525bf9d2ddbb312e31edc0837293e1b391f5 Mon Sep 17 00:00:00 2001 From: David Petersen Date: Thu, 11 Nov 2021 11:24:36 -0600 Subject: [PATCH 05/11] [New] `function-component-definition`: support namedComponents option being an array This adds support to the `function-component-definition` rule to have the `namedComponents` rule be an array. --- CHANGELOG.md | 4 + docs/rules/function-component-definition.md | 8 +- lib/rules/function-component-definition.js | 59 +++++++---- .../rules/function-component-definition.js | 98 ++++++++++++++++++- 4 files changed, 143 insertions(+), 26 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6c5dd7b6dd..d4e7f5783d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,12 +5,16 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ## Unreleased +### Added +* [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit) + ### 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 +[#3129]: https://github.com/yannickcr/eslint-plugin-react/pull/3129 ## [7.27.1] - 2021.11.18 diff --git a/docs/rules/function-component-definition.md b/docs/rules/function-component-definition.md index 5851d3baeb..b869373d20 100644 --- a/docs/rules/function-component-definition.md +++ b/docs/rules/function-component-definition.md @@ -31,13 +31,15 @@ function getComponent() { ## Rule Options -This rule takes an options object as a second parameter where the preferred function type for components can be specified. The first property of the options object is `"namedComponents"` which can be `"function-declaration"`, `"function-expression"`, or `"arrow-function"` and has `'function-declaration'` as its default. The second property is `"unnamedComponents"` that can be either `"function-expression"` or `"arrow-function"`, and has `'function-expression'` as its default. +This rule takes an options object as a second parameter where the preferred function type for components can be specified. +The first property of the options object is `"namedComponents"` which can be `"function-declaration"`, `"function-expression"`, `"arrow-function"`, or an array containing any of those, and has `'function-declaration'` as its default. +The second property is `"unnamedComponents"` that can be either `"function-expression"`, `"arrow-function"`, or an array containing any of those, and has `'function-expression'` as its default. ```js ... "react/function-component-definition": [, { - "namedComponents": "function-declaration" | "function-expression" | "arrow-function", - "unnamedComponents": "function-expression" | "arrow-function" + "namedComponents": "function-declaration" | "function-expression" | "arrow-function" | Array<"function-declaration" | "function-expression" | "arrow-function">, + "unnamedComponents": "function-expression" | "arrow-function" | Array<"function-expression" | "arrow-function"> }] ... ``` diff --git a/lib/rules/function-component-definition.js b/lib/rules/function-component-definition.js index 6842b25bf2..a20e1e3134 100644 --- a/lib/rules/function-component-definition.js +++ b/lib/rules/function-component-definition.js @@ -5,6 +5,7 @@ 'use strict'; +const arrayIncludes = require('array-includes'); const Components = require('../util/Components'); const docsUrl = require('../util/docsUrl'); const reportC = require('../util/report'); @@ -109,24 +110,44 @@ module.exports = { messages, - schema: [{ - type: 'object', - properties: { - namedComponents: { - enum: ['function-declaration', 'arrow-function', 'function-expression'], - }, - unnamedComponents: { - enum: ['arrow-function', 'function-expression'], + schema: [ + { + type: 'object', + properties: { + namedComponents: { + oneOf: [ + { enum: ['function-declaration', 'arrow-function', 'function-expression'] }, + { + type: 'array', + items: { + type: 'string', + enum: ['function-declaration', 'arrow-function', 'function-expression'], + }, + }, + ], + }, + unnamedComponents: { + oneOf: [ + { enum: ['arrow-function', 'function-expression'] }, + { + type: 'array', + items: { + type: 'string', + enum: ['arrow-function', 'function-expression'], + }, + }, + ], + }, }, }, - }], + ], }, create: Components.detect((context, components) => { const configuration = context.options[0] || {}; - const namedConfig = configuration.namedComponents || 'function-declaration'; - const unnamedConfig = configuration.unnamedComponents || 'function-expression'; + const namedConfig = [].concat(configuration.namedComponents || 'function-declaration'); + const unnamedConfig = [].concat(configuration.unnamedComponents || 'function-expression'); function getFixer(node, options) { const sourceCode = context.getSourceCode(); @@ -161,24 +182,24 @@ module.exports = { if (node.parent && node.parent.type === 'Property') return; - if (hasName(node) && namedConfig !== functionType) { + if (hasName(node) && !arrayIncludes(namedConfig, functionType)) { report(node, { - messageId: namedConfig, + messageId: namedConfig[0], fixerOptions: { - type: namedConfig, - template: NAMED_FUNCTION_TEMPLATES[namedConfig], + type: namedConfig[0], + template: NAMED_FUNCTION_TEMPLATES[namedConfig[0]], range: node.type === 'FunctionDeclaration' ? node.range : node.parent.parent.range, }, }); } - if (!hasName(node) && unnamedConfig !== functionType) { + if (!hasName(node) && !arrayIncludes(unnamedConfig, functionType)) { report(node, { - messageId: unnamedConfig, + messageId: unnamedConfig[0], fixerOptions: { - type: unnamedConfig, - template: UNNAMED_FUNCTION_TEMPLATES[unnamedConfig], + type: unnamedConfig[0], + template: UNNAMED_FUNCTION_TEMPLATES[unnamedConfig[0]], range: node.range, }, }); diff --git a/tests/lib/rules/function-component-definition.js b/tests/lib/rules/function-component-definition.js index 47f94e0b72..b5d02dfd33 100644 --- a/tests/lib/rules/function-component-definition.js +++ b/tests/lib/rules/function-component-definition.js @@ -78,8 +78,8 @@ ruleTester.run('function-component-definition', rule, { options: [{ namedComponents: 'function-declaration' }], }, { - // shouldn't trigger this rule since functions stating with a lowercase - // letter are not considered components + // shouldn't trigger this rule since functions stating with a lowercase + // letter are not considered components code: ` const selectAvatarByUserId = (state, id) => { const user = selectUserById(state, id) @@ -89,8 +89,8 @@ ruleTester.run('function-component-definition', rule, { options: [{ namedComponents: 'function-declaration' }], }, { - // shouldn't trigger this rule since functions stating with a lowercase - // letter are not considered components + // shouldn't trigger this rule since functions stating with a lowercase + // letter are not considered components code: ` function ensureValidSourceType(sourceType: string) { switch (sourceType) { @@ -346,6 +346,54 @@ ruleTester.run('function-component-definition', rule, { `, options: [{ unnamedComponents: 'function-expression' }], }, + + { + code: 'function Hello(props) { return
}', + options: [{ namedComponents: ['function-declaration', 'function-expression'] }], + }, + { + code: 'var Hello = function(props) { return
}', + options: [{ namedComponents: ['function-declaration', 'function-expression'] }], + }, + { + code: 'var Foo = React.memo(function Foo() { return

})', + options: [{ namedComponents: ['function-declaration', 'function-expression'] }], + }, + { + code: 'function Hello(props: Test) { return

}', + options: [{ namedComponents: ['function-declaration', 'function-expression'] }], + features: ['types'], + }, + { + code: 'var Hello = function(props: Test) { return

}', + options: [{ namedComponents: ['function-expression', 'function-expression'] }], + features: ['types'], + }, + { + code: 'var Hello = (props: Test) => { return

}', + options: [{ namedComponents: ['arrow-function', 'function-expression'] }], + features: ['types'], + }, + { + code: ` + function wrap(Component) { + return function(props) { + return

; + }; + } + `, + options: [{ unnamedComponents: ['arrow-function', 'function-expression'] }], + }, + { + code: ` + function wrap(Component) { + return (props) => { + return
; + }; + } + `, + options: [{ unnamedComponents: ['arrow-function', 'function-expression'] }], + }, ]), invalid: parsers.all([ @@ -879,5 +927,47 @@ ruleTester.run('function-component-definition', rule, { options: [{ unnamedComponents: 'arrow-function' }], errors: [{ messageId: 'arrow-function' }], }, + { + code: ` + function Hello(props) { + return
; + } + `, + output: ` + var Hello = (props) => { + return
; + } + `, + options: [{ namedComponents: ['arrow-function', 'function-expression'] }], + errors: [{ messageId: 'arrow-function' }], + }, + { + code: ` + var Hello = (props) => { + return
; + }; + `, + output: ` + function Hello(props) { + return
; + } + `, + options: [{ namedComponents: ['function-declaration', 'function-expression'] }], + errors: [{ messageId: 'function-declaration' }], + }, + { + code: ` + var Hello = (props) => { + return
; + }; + `, + output: ` + var Hello = function(props) { + return
; + } + `, + options: [{ namedComponents: ['function-expression', 'function-declaration'] }], + errors: [{ messageId: 'function-expression' }], + }, ]), }); From f7943d5f77d47dba913e5de01242d4fbae3e2af1 Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Fri, 10 Dec 2021 21:01:32 -0800 Subject: [PATCH 06/11] [Test] parsers.all augments suggestion code output parsers.all generates an extraComment which is appended to test case examples and their expected output. Eslint's suggestions API also allows recommended code changes, and RuleTester compares expected changes against generated changes. However, parsers.all didn't augment these expected outputs with the extraComment. --- tests/helpers/parsers.js | 44 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/tests/helpers/parsers.js b/tests/helpers/parsers.js index 038705ba49..971844d8ec 100644 --- a/tests/helpers/parsers.js +++ b/tests/helpers/parsers.js @@ -66,12 +66,52 @@ const parsers = { testObject.parserOptions ? `parserOptions: ${JSON.stringify(testObject.parserOptions)}` : [], testObject.options ? `options: ${JSON.stringify(testObject.options)}` : [] ); + const extraComment = `\n// ${extras.join(', ')}`; + + // Augment expected fix code output with extraComment + const nextCode = { code: testObject.code + extraComment }; + const nextOutput = testObject.output && { output: testObject.output + extraComment }; + + // Augment expected suggestion outputs with extraComment + // `errors` may be a number (expected number of errors) or an array of + // error objects. + const nextErrors = testObject.errors + && typeof testObject.errors !== 'number' + && { + errors: testObject.errors.map( + (errorObject) => { + const nextSuggestions = errorObject.suggestions && { + suggestions: errorObject.suggestions.map( + (suggestion) => { + const nextSuggestion = Object.assign( + {}, + suggestion, + { output: suggestion.output + extraComment } + ); + + return nextSuggestion; + } + ), + }; + + const nextErrorObject = Object.assign( + {}, + errorObject, + nextSuggestions + ); + + return nextErrorObject; + } + ), + }; + return Object.assign( {}, testObject, - { code: testObject.code + extraComment }, - testObject.output && { output: testObject.output + extraComment } + nextCode, + nextOutput, + nextErrors ); } From ac4e311f07413f87163b352ad96fadb2b0be7902 Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Sat, 11 Dec 2021 09:20:49 -0800 Subject: [PATCH 07/11] [Tests] tiny cleanup --- tests/helpers/parsers.js | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/helpers/parsers.js b/tests/helpers/parsers.js index 971844d8ec..cc6a29f21f 100644 --- a/tests/helpers/parsers.js +++ b/tests/helpers/parsers.js @@ -82,26 +82,12 @@ const parsers = { errors: testObject.errors.map( (errorObject) => { const nextSuggestions = errorObject.suggestions && { - suggestions: errorObject.suggestions.map( - (suggestion) => { - const nextSuggestion = Object.assign( - {}, - suggestion, - { output: suggestion.output + extraComment } - ); - - return nextSuggestion; - } - ), + suggestions: errorObject.suggestions.map((suggestion) => Object.assign({}, suggestion, { + output: suggestion.output + extraComment, + })), }; - const nextErrorObject = Object.assign( - {}, - errorObject, - nextSuggestions - ); - - return nextErrorObject; + return Object.assign({}, errorObject, nextSuggestions); } ), }; From d56fdb8054103ce4f02e14d5df2fd83d9b4e180a Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Mon, 13 Dec 2021 09:25:58 -0800 Subject: [PATCH 08/11] [Tests] rename test file to match production file --- tests/util/{Component.js => Components.js} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/util/{Component.js => Components.js} (100%) diff --git a/tests/util/Component.js b/tests/util/Components.js similarity index 100% rename from tests/util/Component.js rename to tests/util/Components.js From 3db52859132d1477f3ad38dc915a9b4870356890 Mon Sep 17 00:00:00 2001 From: Tobias Waltl Date: Tue, 14 Dec 2021 09:51:53 +0100 Subject: [PATCH 09/11] [Fix] `jsx-indent-props`: Reset `line.isUsingOperator` correctly after ternary --- CHANGELOG.md | 4 + lib/rules/jsx-indent-props.js | 2 +- tests/lib/rules/jsx-indent-props.js | 264 ++++++++++++++++++++++++++++ 3 files changed, 269 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4e7f5783d..0b3d7ec9c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,12 +8,16 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Added * [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit) +### Fixed +* [`jsx-indent-props`]: Reset `line.isUsingOperator` correctly after ternary ([#3146][] @tobiaswaltl) + ### 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 +[#3146]: https://github.com/yannickcr/eslint-plugin-react/pull/3146 [#3129]: https://github.com/yannickcr/eslint-plugin-react/pull/3129 ## [7.27.1] - 2021.11.18 diff --git a/lib/rules/jsx-indent-props.js b/lib/rules/jsx-indent-props.js index 8935616988..5d33ded173 100644 --- a/lib/rules/jsx-indent-props.js +++ b/lib/rules/jsx-indent-props.js @@ -153,7 +153,7 @@ module.exports = { const indent = regExp.exec(src); const useOperator = /^([ ]|[\t])*[:]/.test(src) || /^([ ]|[\t])*[?]/.test(src); - const useBracket = /^([ ]|[\t])*[<]/.test(src); + const useBracket = /[<]/.test(src); line.currentOperator = false; if (useOperator) { diff --git a/tests/lib/rules/jsx-indent-props.js b/tests/lib/rules/jsx-indent-props.js index bfa5f20d9d..adf6fd1387 100644 --- a/tests/lib/rules/jsx-indent-props.js +++ b/tests/lib/rules/jsx-indent-props.js @@ -195,6 +195,90 @@ ruleTester.run('jsx-indent-props', rule, { }, ], }, + { + code: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + options: [ + { + indentMode: 2, + ignoreTernaryOperator: false, + }, + ], + }, + { + code: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + options: [ + { + indentMode: 2, + ignoreTernaryOperator: true, + }, + ], + }, + { + code: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + options: [ + { + indentMode: 'tab', + ignoreTernaryOperator: false, + }, + ], + }, + { + code: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + options: [ + { + indentMode: 'tab', + ignoreTernaryOperator: true, + }, + ], + }, { code: ` {this.props.ignoreTernaryOperatorTrue @@ -607,5 +691,185 @@ ruleTester.run('jsx-indent-props', rule, { }, ], }, + { + code: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + output: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + options: [ + { + indentMode: 2, + ignoreTernaryOperator: false, + }, + ], + errors: [ + { + messageId: 'wrongIndent', + data: { + needed: 12, + type: 'space', + characters: 'characters', + gotten: 14, + }, + }, + ], + }, + { + code: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + output: ` + const F = () => { + const foo = true + ?
test
+ : false; + + return
+ test +
+ } + `, + options: [ + { + indentMode: 2, + ignoreTernaryOperator: true, + }, + ], + errors: [ + { + messageId: 'wrongIndent', + data: { + needed: 12, + type: 'space', + characters: 'characters', + gotten: 14, + }, + }, + ], + }, + { + code: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + output: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + options: [ + { + indentMode: 'tab', + ignoreTernaryOperator: false, + }, + ], + errors: [ + { + messageId: 'wrongIndent', + data: { + needed: 6, + type: 'tab', + characters: 'characters', + gotten: 7, + }, + }, + ], + }, + { + code: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + output: ` +\t\t\t\tconst F = () => { +\t\t\t\t\tconst foo = true +\t\t\t\t\t\t?
test
+\t\t\t\t\t\t: false; + +\t\t\t\t\treturn
+\t\t\t\t\t\ttest +\t\t\t\t\t
+\t\t\t\t} +`, + options: [ + { + indentMode: 'tab', + ignoreTernaryOperator: true, + }, + ], + errors: [ + { + messageId: 'wrongIndent', + data: { + needed: 6, + type: 'tab', + characters: 'characters', + gotten: 7, + }, + }, + ], + }, ]), }); From 5a253803c2b91d1ddcfe4a07c30f0a3a8ba26ded Mon Sep 17 00:00:00 2001 From: Duncan Beevers Date: Mon, 13 Dec 2021 04:57:42 -0800 Subject: [PATCH 10/11] [New] component detection: add `util.isReactHookCall` Rename Components test suite filename to match sibling lib/util/Components filename. Extend Components testComponentsDetect function to accept custom instructions, and to accumulate the results of processing those instructions. Add utility to check whether a CallExpression is a React hook call. --- CHANGELOG.md | 2 + lib/util/Components.js | 81 ++++++++++++++ tests/util/Components.js | 222 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 296 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b3d7ec9c2..23af7ac386 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Added * [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit) +* component detection: add `util.isReactHookCall` ([#3156][] @duncanbeevers) ### Fixed * [`jsx-indent-props`]: Reset `line.isUsingOperator` correctly after ternary ([#3146][] @tobiaswaltl) @@ -16,6 +17,7 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel * [Tests] component detection: Add testing scaffolding ([#3149][] @duncanbeevers) * [New] component detection: track React imports ([#3149][] @duncanbeevers) +[#3156]: https://github.com/yannickcr/eslint-plugin-react/pull/3156 [#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149 [#3146]: https://github.com/yannickcr/eslint-plugin-react/pull/3146 [#3129]: https://github.com/yannickcr/eslint-plugin-react/pull/3129 diff --git a/lib/util/Components.js b/lib/util/Components.js index c0621b645c..3d9221d13b 100644 --- a/lib/util/Components.js +++ b/lib/util/Components.js @@ -7,6 +7,7 @@ const doctrine = require('doctrine'); const arrayIncludes = require('array-includes'); +const fromEntries = require('object.fromentries'); const values = require('object.values'); const variableUtil = require('./variable'); @@ -46,6 +47,8 @@ function mergeUsedPropTypes(propsList, newPropsList) { return propsList.concat(propsToAdd); } +const USE_HOOK_PREFIX_REGEX = /^use[A-Z]/; + const Lists = new WeakMap(); const ReactImports = new WeakMap(); @@ -787,6 +790,84 @@ function componentRule(rule, context) { && !!(node.params || []).length ); }, + + /** + * Identify whether a node (CallExpression) is a call to a React hook + * + * @param {ASTNode} node The AST node being searched. (expects CallExpression) + * @param {('useCallback'|'useContext'|'useDebugValue'|'useEffect'|'useImperativeHandle'|'useLayoutEffect'|'useMemo'|'useReducer'|'useRef'|'useState')[]} [expectedHookNames] React hook names to which search is limited. + * @returns {Boolean} True if the node is a call to a React hook + */ + isReactHookCall(node, expectedHookNames) { + if (node.type !== 'CallExpression') { + return false; + } + + const defaultReactImports = components.getDefaultReactImports(); + const namedReactImports = components.getNamedReactImports(); + + const defaultReactImportName = defaultReactImports + && defaultReactImports[0] + && defaultReactImports[0].local.name; + const reactHookImportSpecifiers = namedReactImports + && namedReactImports.filter((specifier) => USE_HOOK_PREFIX_REGEX.test(specifier.imported.name)); + const reactHookImportNames = reactHookImportSpecifiers + && fromEntries(reactHookImportSpecifiers.map((specifier) => [specifier.local.name, specifier.imported.name])); + + const isPotentialReactHookCall = defaultReactImportName + && node.callee.type === 'MemberExpression' + && node.callee.object.type === 'Identifier' + && node.callee.object.name === defaultReactImportName + && node.callee.property.type === 'Identifier' + && node.callee.property.name.match(USE_HOOK_PREFIX_REGEX); + + const isPotentialHookCall = reactHookImportNames + && node.callee.type === 'Identifier' + && node.callee.name.match(USE_HOOK_PREFIX_REGEX); + + const scope = (isPotentialReactHookCall || isPotentialHookCall) && context.getScope(); + + const reactResolvedDefs = isPotentialReactHookCall + && scope.references + && scope.references.find( + (reference) => reference.identifier.name === defaultReactImportName + ).resolved.defs; + + const isReactShadowed = isPotentialReactHookCall && reactResolvedDefs + && reactResolvedDefs.some((reactDef) => reactDef.type !== 'ImportBinding'); + + const potentialHookReference = isPotentialHookCall + && scope.references + && scope.references.find( + (reference) => reactHookImportNames[reference.identifier.name] + ); + + const hookResolvedDefs = potentialHookReference && potentialHookReference.resolved.defs; + const localHookName = (isPotentialReactHookCall && node.callee.property.name) + || (isPotentialHookCall && potentialHookReference && node.callee.name); + const isHookShadowed = isPotentialHookCall + && hookResolvedDefs + && hookResolvedDefs.some( + (hookDef) => hookDef.name.name === localHookName + && hookDef.type !== 'ImportBinding' + ); + + const isHookCall = (isPotentialReactHookCall && !isReactShadowed) + || (isPotentialHookCall && localHookName && !isHookShadowed); + + if (!isHookCall) { + return false; + } + + if (!expectedHookNames) { + return true; + } + + return arrayIncludes( + expectedHookNames, + (reactHookImportNames && reactHookImportNames[localHookName]) || localHookName + ); + }, }; // Component detection instructions diff --git a/tests/util/Components.js b/tests/util/Components.js index 858954af7c..694168fb6f 100644 --- a/tests/util/Components.js +++ b/tests/util/Components.js @@ -1,7 +1,9 @@ 'use strict'; const assert = require('assert'); +const entries = require('object.entries'); const eslint = require('eslint'); +const fromEntries = require('object.fromentries'); const values = require('object.values'); const Components = require('../../lib/util/Components'); @@ -19,12 +21,32 @@ const ruleTester = new eslint.RuleTester({ describe('Components', () => { describe('static detect', () => { - function testComponentsDetect(test, done) { - const rule = Components.detect((context, components, util) => ({ - 'Program:exit'() { - done(context, components, util); - }, - })); + function testComponentsDetect(test, instructionsOrDone, orDone) { + const done = orDone || instructionsOrDone; + const instructions = orDone ? instructionsOrDone : instructionsOrDone; + + const rule = Components.detect((_context, components, util) => { + const instructionResults = []; + + const augmentedInstructions = fromEntries( + entries(instructions || {}).map((nodeTypeAndHandler) => { + const nodeType = nodeTypeAndHandler[0]; + const handler = nodeTypeAndHandler[1]; + return [nodeType, (node) => { + instructionResults.push({ type: nodeType, result: handler(node, context, components, util) }); + }]; + }) + ); + + return Object.assign({}, augmentedInstructions, { + 'Program:exit'(node) { + if (augmentedInstructions['Program:exit']) { + augmentedInstructions['Program:exit'](node, context, components, util); + } + done(components, instructionResults); + }, + }); + }); const tests = { valid: parsers.all([Object.assign({}, test, { @@ -36,6 +58,7 @@ describe('Components', () => { })]), invalid: [], }; + ruleTester.run(test.code, rule, tests); } @@ -45,7 +68,7 @@ describe('Components', () => { function MyStatelessComponent() { return ; }`, - }, (_context, components) => { + }, (components) => { assert.equal(components.length(), 1, 'MyStatelessComponent should be detected component'); values(components.list()).forEach((component) => { assert.equal( @@ -65,7 +88,7 @@ describe('Components', () => { return ; } }`, - }, (_context, components) => { + }, (components) => { assert(components.length() === 1, 'MyClassComponent should be detected component'); values(components.list()).forEach((component) => { assert.equal( @@ -80,7 +103,7 @@ describe('Components', () => { it('should detect React Imports', () => { testComponentsDetect({ code: 'import React, { useCallback, useState } from \'react\'', - }, (_context, components) => { + }, (components) => { assert.deepEqual( components.getDefaultReactImports().map((specifier) => specifier.local.name), ['React'], @@ -94,5 +117,186 @@ describe('Components', () => { ); }); }); + + describe('utils', () => { + describe('isReactHookCall', () => { + it('should not identify hook-like call', () => { + testComponentsDetect({ + code: `import { useRef } from 'react' + function useColor() { + return useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]); + }); + }); + + it('should identify hook call', () => { + testComponentsDetect({ + code: `import { useState } from 'react' + function useColor() { + return useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should identify aliased hook call', () => { + testComponentsDetect({ + code: `import { useState as useStateAlternative } from 'react' + function useColor() { + return useStateAlternative() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should identify aliased present named hook call', () => { + testComponentsDetect({ + code: `import { useState as useStateAlternative } from 'react' + function useColor() { + return useStateAlternative() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should not identify shadowed hook call', () => { + testComponentsDetect({ + code: `import { useState } from 'react' + function useColor() { + function useState() { + return null + } + return useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]); + }); + }); + + it('should not identify shadowed aliased present named hook call', () => { + testComponentsDetect({ + code: `import { useState as useStateAlternative } from 'react' + function useColor() { + function useStateAlternative() { + return null + } + return useStateAlternative() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]); + }); + }); + + it('should identify React hook call', () => { + testComponentsDetect({ + code: `import React from 'react' + function useColor() { + return React.useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should identify aliased React hook call', () => { + testComponentsDetect({ + code: `import ReactAlternative from 'react' + function useColor() { + return ReactAlternative.useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should not identify shadowed React hook call', () => { + testComponentsDetect({ + code: `import React from 'react' + function useColor() { + const React = { + useState: () => null + } + return React.useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]); + }); + }); + + it('should identify present named hook call', () => { + testComponentsDetect({ + code: `import { useState } from 'react' + function useColor() { + return useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should identify present named React hook call', () => { + testComponentsDetect({ + code: `import React from 'react' + function useColor() { + return React.useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useState']), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: true }]); + }); + }); + + it('should not identify missing named hook call', () => { + testComponentsDetect({ + code: `import { useState } from 'react' + function useColor() { + return useState() + }`, + }, { + CallExpression: (node, _context, _components, util) => util.isReactHookCall(node, ['useRef']), + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'CallExpression', result: false }]); + }); + }); + }); + }); + + describe('testComponentsDetect', () => { + it('should log Program:exit instruction', () => { + testComponentsDetect({ + code: '', + }, { + 'Program:exit': () => true, + }, (_components, instructionResults) => { + assert.deepEqual(instructionResults, [{ type: 'Program:exit', result: true }]); + }); + }); + }); }); }); From 9be55ed7f2417104ecaae9771fa9e603f847706e Mon Sep 17 00:00:00 2001 From: Jordan Harband Date: Wed, 22 Dec 2021 15:00:54 -0800 Subject: [PATCH 11/11] Update CHANGELOG and bump version --- CHANGELOG.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 23af7ac386..0ab1bc8583 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ## Unreleased +## [7.28.0] - 2021.12.22 + ### Added * [`function-component-definition`]: support namedComponents option being an array ([#3129][] @petersendidit) -* component detection: add `util.isReactHookCall` ([#3156][] @duncanbeevers) ### Fixed * [`jsx-indent-props`]: Reset `line.isUsingOperator` correctly after ternary ([#3146][] @tobiaswaltl) @@ -16,7 +17,9 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel * [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) +* [New] component detection: add `util.isReactHookCall` ([#3156][] @duncanbeevers) +[7.28.0]: https://github.com/yannickcr/eslint-plugin-react/compare/v7.27.1...v7.28.0 [#3156]: https://github.com/yannickcr/eslint-plugin-react/pull/3156 [#3149]: https://github.com/yannickcr/eslint-plugin-react/pull/3149 [#3146]: https://github.com/yannickcr/eslint-plugin-react/pull/3146 diff --git a/package.json b/package.json index f52789d94a..85094c9cbb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react", - "version": "7.27.1", + "version": "7.28.0", "author": "Yannick Croissant ", "description": "React specific linting rules for ESLint", "main": "index.js",