diff --git a/CHANGELOG.md b/CHANGELOG.md index 52755a8a9d..e8f51603b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This change log adheres to standards from [Keep a CHANGELOG](http://keepachangel ### Added * add [`hook-use-state`] rule to enforce symmetric useState hook variable names ([#2921][] @duncanbeevers) +### Fixed +* [`prop-types`], `propTypes`: add support for exported type inference ([#3163][] @vedadeepta) + +[#3163]: https://github.com/yannickcr/eslint-plugin-react/pull/3163 [#2921]: https://github.com/yannickcr/eslint-plugin-react/pull/2921 ## [7.28.0] - 2021.12.22 diff --git a/lib/util/ast.js b/lib/util/ast.js index cea5682247..d8172289d8 100644 --- a/lib/util/ast.js +++ b/lib/util/ast.js @@ -320,13 +320,31 @@ function isTSInterfaceHeritage(node) { function isTSInterfaceDeclaration(node) { if (!node) return false; - const nodeType = node.type; + let nodeType = node.type; + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + nodeType = node.declaration.type; + } return nodeType === 'TSInterfaceDeclaration'; } +function isTSTypeDeclaration(node) { + if (!node) return false; + let nodeType = node.type; + let nodeKind = node.kind; + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + nodeType = node.declaration.type; + nodeKind = node.declaration.kind; + } + return nodeType === 'VariableDeclaration' && nodeKind === 'type'; +} + function isTSTypeAliasDeclaration(node) { if (!node) return false; - const nodeType = node.type; + let nodeType = node.type; + if (node.type === 'ExportNamedDeclaration' && node.declaration) { + nodeType = node.declaration.type; + return nodeType === 'TSTypeAliasDeclaration' && node.exportKind === 'type'; + } return nodeType === 'TSTypeAliasDeclaration'; } @@ -380,4 +398,5 @@ module.exports = { isTSFunctionType, isTSTypeQuery, isTSTypeParameterInstantiation, + isTSTypeDeclaration, }; diff --git a/lib/util/propTypes.js b/lib/util/propTypes.js index 7cd4e2ab7b..de1e01421a 100644 --- a/lib/util/propTypes.js +++ b/lib/util/propTypes.js @@ -547,6 +547,30 @@ module.exports = function propTypesInstructions(context, components, utils) { if (node.right) return getRightMostTypeName(node.right); } + /** + * Returns true if the node is either a interface or type alias declaration + * @param {ASTNode} node + * @return {boolean} + */ + function filterInterfaceOrTypeAlias(node) { + return ( + astUtil.isTSInterfaceDeclaration(node) || astUtil.isTSTypeAliasDeclaration(node) + ); + } + + /** + * Returns true if the interface or type alias declaration node name matches the type-name str + * @param {ASTNode} node + * @param {string} typeName + * @return {boolean} + */ + function filterInterfaceOrAliasByName(node, typeName) { + return ( + (node.id && node.id.name === typeName) + || (node.declaration && node.declaration.id && node.declaration.id.name === typeName) + ); + } + class DeclarePropTypesForTSTypeAnnotation { constructor(propTypes, declaredPropTypes) { this.propTypes = propTypes; @@ -644,19 +668,22 @@ module.exports = function propTypesInstructions(context, components, utils) { * From line 577 to line 581, and line 588 to line 590 are trying to handle typescript-eslint-parser * Need to be deprecated after remove typescript-eslint-parser support. */ - const candidateTypes = this.sourceCode.ast.body.filter((item) => item.type === 'VariableDeclaration' && item.kind === 'type'); - const declarations = flatMap(candidateTypes, (type) => type.declarations); + const candidateTypes = this.sourceCode.ast.body.filter((item) => astUtil.isTSTypeDeclaration(item)); + + const declarations = flatMap( + candidateTypes, + (type) => type.declarations || (type.declaration && type.declaration.declarations) || type.declaration); // we tried to find either an interface or a type with the TypeReference name const typeDeclaration = declarations.filter((dec) => dec.id.name === typeName); const interfaceDeclarations = this.sourceCode.ast.body - .filter( - (item) => (astUtil.isTSInterfaceDeclaration(item) - || astUtil.isTSTypeAliasDeclaration(item)) - && item.id.name === typeName); + .filter(filterInterfaceOrTypeAlias) + .filter((item) => filterInterfaceOrAliasByName(item, typeName)) + .map((item) => (item.declaration || item)); + if (typeDeclaration.length !== 0) { - typeDeclaration.map((t) => t.init).forEach(this.visitTSNode, this); + typeDeclaration.map((t) => t.init || t.typeAnnotation).forEach(this.visitTSNode, this); } else if (interfaceDeclarations.length !== 0) { interfaceDeclarations.forEach(this.traverseDeclaredInterfaceOrTypeAlias, this); } else { diff --git a/tests/lib/rules/prop-types.js b/tests/lib/rules/prop-types.js index 9e39620178..f06bfcaf83 100644 --- a/tests/lib/rules/prop-types.js +++ b/tests/lib/rules/prop-types.js @@ -3248,6 +3248,19 @@ ruleTester.run('prop-types', rule, { `, features: ['ts', 'no-babel'], }, + { + code: ` + import React from 'react'; + + export interface PersonProps { + username: string; + } + const Person: React.FC = (props): React.ReactElement => ( +
{props.username}
+ ); + `, + features: ['ts', 'no-babel'], + }, { code: ` import React from 'react'; @@ -3411,6 +3424,21 @@ ruleTester.run('prop-types', rule, { `, features: ['ts', 'no-babel'], }, + { + code: ` + import React from 'react' + + export interface Props { + age: number + } + const Hello: React.VoidFunctionComponent = function Hello(props) { + const { age } = props; + + return
Hello {age}
; + } + `, + features: ['ts', 'no-babel'], + }, { code: ` import React, { ForwardRefRenderFunction as X } from 'react' @@ -3423,6 +3451,18 @@ ruleTester.run('prop-types', rule, { `, features: ['ts', 'no-babel'], }, + { + code: ` + import React, { ForwardRefRenderFunction as X } from 'react' + + export type IfooProps = { e: string }; + const Foo: X = function Foo (props, ref) { + const { e } = props; + return
hello
; + }; + `, + features: ['ts', 'no-babel'], + }, { code: ` import React, { ForwardRefRenderFunction } from 'react' @@ -3564,6 +3604,72 @@ ruleTester.run('prop-types', rule, { }), }; `, + }, + { + code: ` + import React, { forwardRef } from "react"; + + export type Props = { children: React.ReactNode; type: "submit" | "button" }; + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { forwardRef } from "react"; + + export type X = { num: number }; + export type Props = { children: React.ReactNode; type: "submit" | "button" } & X; + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { forwardRef } from "react"; + + export interface IProps { + children: React.ReactNode; + type: "submit" | "button" + } + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { forwardRef } from "react"; + + export interface X { + num: number + } + export interface IProps extends X { + children: React.ReactNode; + type: "submit" | "button" + } + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + features: ['ts', 'no-babel'], } )), @@ -7336,6 +7442,140 @@ ruleTester.run('prop-types', rule, { }, ], features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { forwardRef } from "react"; + + export type Props = { children: React.ReactNode; type: "submit" | "button" }; + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { forwardRef } from "react"; + + export interface IProps { children: React.ReactNode; type: "submit" | "button" }; + + export const FancyButton = forwardRef((props, ref) => ( + + )); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React from 'react'; + + export interface PersonProps { + username: string; + } + const Person: React.FC = (props): React.ReactElement => ( +
{props.nonExistent}
+ ); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { FC } from 'react'; + + export interface PersonProps { + username: string; + } + const Person: FC = (props): React.ReactElement => ( +
{props.nonExistent}
+ ); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { FC as X } from 'react'; + + export interface PersonProps { + username: string; + } + const Person: X = (props): React.ReactElement => ( +
{props.nonExistent}
+ ); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React, { ForwardRefRenderFunction as X } from 'react' + + export type IfooProps = { e: string }; + const Foo: X = function Foo (props, ref) { + const { nonExistent } = props; + return
hello
; + }; + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], + }, + { + code: ` + import React from 'react'; + + export interface PersonProps { + username: string; + } + const Person: React.VoidFunctionComponent = (props): React.ReactElement => ( +
{props.nonExistent}
+ ); + `, + errors: [ + { + messageId: 'missingPropType', + data: { name: 'nonExistent' }, + }, + ], + features: ['ts', 'no-babel'], } )), });