diff --git a/CHANGELOG.md b/CHANGELOG.md index 348c3389ac..26389bca1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +## [7.37.2] - 2024.10.22 + +### Fixed +* [`destructuring-assignment`]: fix false negative when using `typeof props.a` ([#3835][] @golopot) + +### Changed +* [Refactor] [`destructuring-assignment`]: use `getParentStatelessComponent` ([#3835][] @golopot) + +[7.37.2]: https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.37.1...v7.37.2 +[#3835]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3835 + ## [7.37.1] - 2024.10.01 ### Fixed @@ -14,6 +25,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Changed * [readme] Fix shared settings link ([#3834][] @MgenGlder) +[7.37.1]: https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.37.0...v7.37.1 [#3836]: https://github.com/jsx-eslint/eslint-plugin-react/issues/3836 [#3834]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3834 @@ -28,7 +40,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ### Changed * [readme] flat config example for react 17+ ([#3824][] @GabenGar) -[7.36.2]: https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.36.1...v7.36.2 +[7.37.0]: https://github.com/jsx-eslint/eslint-plugin-react/compare/v7.36.1...v7.37.0 [#3831]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3831 [#3830]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3830 [#3826]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3826 diff --git a/index.js b/index.js index d1b2c120aa..8426993e5b 100644 --- a/index.js +++ b/index.js @@ -11,98 +11,108 @@ function filterRules(rules, predicate) { /** * @param {object} rules - rules object mapping rule name to rule module - * @returns {Record} + * @returns {Record} */ function configureAsError(rules) { return fromEntries(Object.keys(rules).map((key) => [`react/${key}`, 2])); } +/** @type {Partial} */ const activeRules = filterRules(allRules, (rule) => !rule.meta.deprecated); +/** @type {Record} */ const activeRulesConfig = configureAsError(activeRules); +/** @type {Partial} */ const deprecatedRules = filterRules(allRules, (rule) => rule.meta.deprecated); +/** @type {['react']} */ // for legacy config system const plugins = [ 'react', ]; -const plugin = { - deprecatedRules, - rules: allRules, - configs: { - recommended: { - plugins, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - rules: { - 'react/display-name': 2, - 'react/jsx-key': 2, - 'react/jsx-no-comment-textnodes': 2, - 'react/jsx-no-duplicate-props': 2, - 'react/jsx-no-target-blank': 2, - 'react/jsx-no-undef': 2, - 'react/jsx-uses-react': 2, - 'react/jsx-uses-vars': 2, - 'react/no-children-prop': 2, - 'react/no-danger-with-children': 2, - 'react/no-deprecated': 2, - 'react/no-direct-mutation-state': 2, - 'react/no-find-dom-node': 2, - 'react/no-is-mounted': 2, - 'react/no-render-return-value': 2, - 'react/no-string-refs': 2, - 'react/no-unescaped-entities': 2, - 'react/no-unknown-property': 2, - 'react/no-unsafe': 0, - 'react/prop-types': 2, - 'react/react-in-jsx-scope': 2, - 'react/require-render-return': 2, +const configs = { + recommended: { + plugins, + parserOptions: { + ecmaFeatures: { + jsx: true, }, }, - all: { - plugins, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - }, - rules: activeRulesConfig, + rules: { + 'react/display-name': 2, + 'react/jsx-key': 2, + 'react/jsx-no-comment-textnodes': 2, + 'react/jsx-no-duplicate-props': 2, + 'react/jsx-no-target-blank': 2, + 'react/jsx-no-undef': 2, + 'react/jsx-uses-react': 2, + 'react/jsx-uses-vars': 2, + 'react/no-children-prop': 2, + 'react/no-danger-with-children': 2, + 'react/no-deprecated': 2, + 'react/no-direct-mutation-state': 2, + 'react/no-find-dom-node': 2, + 'react/no-is-mounted': 2, + 'react/no-render-return-value': 2, + 'react/no-string-refs': 2, + 'react/no-unescaped-entities': 2, + 'react/no-unknown-property': 2, + 'react/no-unsafe': 0, + 'react/prop-types': 2, + 'react/react-in-jsx-scope': 2, + 'react/require-render-return': 2, }, - 'jsx-runtime': { - plugins, - parserOptions: { - ecmaFeatures: { - jsx: true, - }, - jsxPragma: null, // for @typescript/eslint-parser + }, + all: { + plugins, + parserOptions: { + ecmaFeatures: { + jsx: true, }, - rules: { - 'react/react-in-jsx-scope': 0, - 'react/jsx-uses-react': 0, + }, + rules: activeRulesConfig, + }, + 'jsx-runtime': { + plugins, + parserOptions: { + ecmaFeatures: { + jsx: true, }, + jsxPragma: null, // for @typescript/eslint-parser + }, + rules: { + 'react/react-in-jsx-scope': 0, + 'react/jsx-uses-react': 0, }, }, }; -plugin.configs.flat = { +/** @typedef {{ plugins: { react: typeof plugin }, rules: import('eslint').Linter.RulesRecord, languageOptions: { parserOptions: import('eslint').Linter.ParserOptions } }} ReactFlatConfig */ + +/** @type {{ deprecatedRules: typeof deprecatedRules, rules: typeof allRules, configs: typeof configs & { flat?: Record }}} */ +const plugin = { + deprecatedRules, + rules: allRules, + configs, +}; + +/** @type {Record} */ +configs.flat = { recommended: { plugins: { react: plugin }, - rules: plugin.configs.recommended.rules, - languageOptions: { parserOptions: plugin.configs.recommended.parserOptions }, + rules: configs.recommended.rules, + languageOptions: { parserOptions: configs.recommended.parserOptions }, }, all: { plugins: { react: plugin }, - rules: plugin.configs.all.rules, - languageOptions: { parserOptions: plugin.configs.all.parserOptions }, + rules: configs.all.rules, + languageOptions: { parserOptions: configs.all.parserOptions }, }, 'jsx-runtime': { plugins: { react: plugin }, - rules: plugin.configs['jsx-runtime'].rules, - languageOptions: { parserOptions: plugin.configs['jsx-runtime'].parserOptions }, + rules: configs['jsx-runtime'].rules, + languageOptions: { parserOptions: configs['jsx-runtime'].parserOptions }, }, }; diff --git a/lib/rules/destructuring-assignment.js b/lib/rules/destructuring-assignment.js index b4aa3da159..6e254f772f 100644 --- a/lib/rules/destructuring-assignment.js +++ b/lib/rules/destructuring-assignment.js @@ -181,6 +181,25 @@ module.exports = { } } + // valid-jsdoc cannot read function types + // eslint-disable-next-line valid-jsdoc + /** + * Find a parent that satisfy the given predicate + * @param {ASTNode} node + * @param {(node: ASTNode) => boolean} predicate + * @returns {ASTNode | undefined} + */ + function findParent(node, predicate) { + let n = node; + while (n) { + if (predicate(n)) { + return n; + } + n = n.parent; + } + return undefined; + } + return { FunctionDeclaration: handleStatelessComponent, @@ -196,12 +215,7 @@ module.exports = { 'FunctionExpression:exit': handleStatelessComponentExit, MemberExpression(node) { - let scope = getScope(context, node); - let SFCComponent = components.get(scope.block); - while (!SFCComponent && scope.upper && scope.upper !== scope) { - SFCComponent = components.get(scope.upper.block); - scope = scope.upper; - } + const SFCComponent = utils.getParentStatelessComponent(node); if (SFCComponent) { handleSFCUsage(node); } @@ -212,6 +226,25 @@ module.exports = { } }, + TSQualifiedName(node) { + if (configuration !== 'always') { + return; + } + // handle `typeof props.a.b` + if (node.left.type === 'Identifier' + && node.left.name === sfcParams.propsName() + && findParent(node, (n) => n.type === 'TSTypeQuery') + && utils.getParentStatelessComponent(node) + ) { + report(context, messages.useDestructAssignment, 'useDestructAssignment', { + node, + data: { + type: 'props', + }, + }); + } + }, + VariableDeclarator(node) { const classComponent = utils.getParentComponent(node); const SFCComponent = components.get(getScope(context, node).block); @@ -257,6 +290,7 @@ module.exports = { if (!propsRefs) { return; } + // Skip if props is used elsewhere if (propsRefs.length > 1) { return; diff --git a/package.json b/package.json index 7e0be6c9b0..510549d6a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react", - "version": "7.37.1", + "version": "7.37.2", "author": "Yannick Croissant ", "description": "React specific linting rules for ESLint", "main": "index.js", @@ -34,7 +34,7 @@ "array.prototype.flatmap": "^1.3.2", "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.19", + "es-iterator-helpers": "^1.1.0", "estraverse": "^5.3.0", "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", @@ -49,12 +49,12 @@ "string.prototype.repeat": "^1.0.0" }, "devDependencies": { - "@babel/core": "^7.25.2", - "@babel/eslint-parser": "^7.25.1", - "@babel/plugin-syntax-decorators": "^7.24.7", - "@babel/plugin-syntax-do-expressions": "^7.24.7", - "@babel/plugin-syntax-function-bind": "^7.24.7", - "@babel/preset-react": "^7.24.7", + "@babel/core": "^7.25.9", + "@babel/eslint-parser": "^7.25.9", + "@babel/plugin-syntax-decorators": "^7.25.9", + "@babel/plugin-syntax-do-expressions": "^7.25.9", + "@babel/plugin-syntax-function-bind": "^7.25.9", + "@babel/preset-react": "^7.25.9", "@types/eslint": "=7.2.10", "@types/estree": "0.0.52", "@types/node": "^4.9.5", @@ -64,7 +64,7 @@ "eslint-config-airbnb-base": "^15.0.0", "eslint-doc-generator": "^1.7.1", "eslint-plugin-eslint-plugin": "^2.3.0 || ^3.5.3 || ^4.0.1 || ^5.0.5", - "eslint-plugin-import": "^2.30.0", + "eslint-plugin-import": "^2.31.0", "eslint-remote-tester": "^3.0.1", "eslint-remote-tester-repositories": "^1.0.1", "eslint-scope": "^3.7.3", diff --git a/tests/lib/rules/destructuring-assignment.js b/tests/lib/rules/destructuring-assignment.js index e1f8e85b08..13d0c10ac7 100644 --- a/tests/lib/rules/destructuring-assignment.js +++ b/tests/lib/rules/destructuring-assignment.js @@ -872,6 +872,69 @@ ${' '} `, features: ['ts', 'no-babel'], }, - ] : [] + ] : [], + { + code: ` + type Props = { text: string }; + export const MyComponent: React.FC = (props) => { + type MyType = typeof props.text; + return
{props.text as MyType}
; + }; + `, + options: ['always', { destructureInSignature: 'always' }], + features: ['types', 'no-babel'], + errors: [ + { + messageId: 'useDestructAssignment', + type: 'TSQualifiedName', + data: { type: 'props' }, + }, + { + messageId: 'useDestructAssignment', + type: 'MemberExpression', + data: { type: 'props' }, + }, + ], + }, + { + code: ` + type Props = { text: string }; + export const MyOtherComponent: React.FC = (props) => { + const { text } = props; + type MyType = typeof props.text; + return
{text as MyType}
; + }; + `, + options: ['always', { destructureInSignature: 'always' }], + features: ['types', 'no-babel'], + errors: [ + { + messageId: 'useDestructAssignment', + type: 'TSQualifiedName', + data: { type: 'props' }, + }, + ], + }, + { + code: ` + function C(props: Props) { + void props.a + typeof props.b + return
+ } + `, + options: ['always'], + features: ['types'], + errors: [ + { + messageId: 'useDestructAssignment', + data: { type: 'props' }, + }, + { + messageId: 'useDestructAssignment', + data: { type: 'props' }, + }, + ], + } )), });