diff --git a/CHANGELOG.md b/CHANGELOG.md index 348c3389ac..f62ad8605e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange ## Unreleased +### Fixed +* [`destructuring-assignment`]: fix false negative when using `typeof props.a` ([#3835][] @golopot) + +### Changed +* [Refactor] [`destructuring-assignment`]: use `getParentStatelessComponent` ([#3835][] @golopot) + +[#3835]: https://github.com/jsx-eslint/eslint-plugin-react/pull/3835 + ## [7.37.1] - 2024.10.01 ### Fixed 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/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' }, + }, + ], + } )), });