From 3c09cb979c6da4352c8b2564db4eb32226144855 Mon Sep 17 00:00:00 2001 From: GertSallaerts <1267900+GertSallaerts@users.noreply.github.com> Date: Tue, 7 Apr 2026 12:27:12 +0200 Subject: [PATCH] [New] `prop-types`: add `skipUnexported` option --- docs/rules/prop-types.md | 3 +- lib/rules/prop-types.js | 108 +++++++++++++++++ tests/lib/rules/prop-types.js | 212 ++++++++++++++++++++++++++++++++++ 3 files changed, 322 insertions(+), 1 deletion(-) diff --git a/docs/rules/prop-types.md b/docs/rules/prop-types.md index ee3d46fa42..adb36e9337 100644 --- a/docs/rules/prop-types.md +++ b/docs/rules/prop-types.md @@ -144,7 +144,7 @@ This rule can take one argument to ignore some specific props during validation. ```js ... -"react/prop-types": [, { ignore: , customValidators: , skipUndeclared: }] +"react/prop-types": [, { ignore: , customValidators: , skipUndeclared: , skipUnexported: }] ... ``` @@ -152,6 +152,7 @@ This rule can take one argument to ignore some specific props during validation. - `ignore`: optional array of props name to ignore during validation. - `customValidators`: optional array of validators used for propTypes validation. - `skipUndeclared`: optional boolean to only error on components that have a propTypes block declared. +- `skipUnexported`: optional boolean to skip validation for components that are not exported from the module. This is useful when you have helper components or render functions that are only used internally within a file and do not need their own prop types. ### As for "exceptions" diff --git a/lib/rules/prop-types.js b/lib/rules/prop-types.js index dabe5b0e7b..9f84005afc 100644 --- a/lib/rules/prop-types.js +++ b/lib/rules/prop-types.js @@ -52,6 +52,9 @@ module.exports = { skipUndeclared: { type: 'boolean', }, + skipUnexported: { + type: 'boolean', + }, }, additionalProperties: false, }], @@ -61,6 +64,7 @@ module.exports = { const configuration = context.options[0] || {}; const ignored = configuration.ignore || []; const skipUndeclared = configuration.skipUndeclared || false; + const skipUnexported = configuration.skipUnexported || false; /** * Checks if the prop is ignored @@ -71,6 +75,71 @@ module.exports = { return ignored.indexOf(name) !== -1; } + const exportedIdentifiers = new Set(); + + /** + * Get the name of a component from its node + * @param {Object} component The component to get the name for + * @returns {string | null} The component name, or null if unnamed + */ + function getComponentName(component) { + const node = component.node; + if (node.id) { + return node.id.name; + } + if (node.parent && node.parent.type === 'VariableDeclarator' && node.parent.id) { + return node.parent.id.name; + } + return null; + } + + /** + * Checks if a component node is directly in an export declaration + * @param {ASTNode} node The component AST node + * @returns {boolean} True if the component is directly exported + */ + function isDirectlyExported(node) { + let current = node; + while (current && current.parent) { + const parentType = current.parent.type; + if (parentType === 'ExportDefaultDeclaration' || parentType === 'ExportNamedDeclaration') { + return true; + } + if (parentType === 'AssignmentExpression' && current.parent.right === current) { + const left = current.parent.left; + if ( + left.type === 'MemberExpression' + && left.object.type === 'Identifier' + && ( + (left.object.name === 'module' && left.property.name === 'exports') + || left.object.name === 'exports' + ) + ) { + return true; + } + } + if (parentType === 'VariableDeclarator' || parentType === 'VariableDeclaration') { + current = current.parent; + continue; // eslint-disable-line no-continue + } + break; + } + return false; + } + + /** + * Checks if a component is exported from the module + * @param {Object} component The component to check + * @returns {boolean} True if the component is exported + */ + function isComponentExported(component) { + if (isDirectlyExported(component.node)) { + return true; + } + const name = getComponentName(component); + return name != null && exportedIdentifiers.has(name); + } + /** * Checks if the component must be validated * @param {Object} component The component to process @@ -78,11 +147,13 @@ module.exports = { */ function mustBeValidated(component) { const isSkippedByConfig = skipUndeclared && typeof component.declaredPropTypes === 'undefined'; + const isSkippedAsUnexported = skipUnexported && !isComponentExported(component); return !!( component && component.usedPropTypes && !component.ignorePropsValidation && !isSkippedByConfig + && !isSkippedAsUnexported ); } @@ -210,6 +281,43 @@ module.exports = { } return { + ExportDefaultDeclaration(node) { + if (node.declaration && node.declaration.type === 'Identifier') { + exportedIdentifiers.add(node.declaration.name); + } + }, + + ExportNamedDeclaration(node) { + if (node.specifiers) { + node.specifiers.forEach((specifier) => { + if (specifier.local) { + exportedIdentifiers.add(specifier.local.name); + } + }); + } + }, + + AssignmentExpression(node) { + if ( + node.left.type === 'MemberExpression' + && node.left.object.type === 'Identifier' + ) { + if ( + node.left.object.name === 'module' + && node.left.property.name === 'exports' + && node.right.type === 'Identifier' + ) { + exportedIdentifiers.add(node.right.name); + } + if ( + node.left.object.name === 'exports' + && node.right.type === 'Identifier' + ) { + exportedIdentifiers.add(node.right.name); + } + } + }, + 'Program:exit'() { const list = components.list(); // Report undeclared proptypes for all classes diff --git a/tests/lib/rules/prop-types.js b/tests/lib/rules/prop-types.js index a3e09a3072..adb41c06d9 100644 --- a/tests/lib/rules/prop-types.js +++ b/tests/lib/rules/prop-types.js @@ -1570,6 +1570,102 @@ ruleTester.run('prop-types', rule, { `, options: [{ skipUndeclared: false }], }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + const Hello = (props) => { + return
{props.name}
; + }; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + class Hello extends React.Component { + render() { + return
{this.props.name}
; + } + } + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + export function Hello(props) { + return
{props.name}
; + } + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + export default function Hello(props) { + return
{props.name}
; + } + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + export default Hello; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + const Hello = (props) => { + return
{props.name}
; + }; + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + export { Hello }; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + module.exports = Hello; + `, + options: [{ skipUnexported: true }], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + Hello.propTypes = { + name: PropTypes.string.isRequired + }; + exports.Hello = Hello; + `, + options: [{ skipUnexported: true }], + }, { // Async generator functions can't be components. code: ` @@ -6727,6 +6823,122 @@ ruleTester.run('prop-types', rule, { }, ], }, + { + code: ` + export function Hello(props) { + return
{props.name}
; + } + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + export default function Hello(props) { + return
{props.name}
; + } + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + export const Hello = (props) => { + return
{props.name}
; + }; + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + export default Hello; + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + const Hello = (props) => { + return
{props.name}
; + }; + export { Hello }; + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + module.exports = Hello; + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + exports.Hello = Hello; + `, + options: [{ skipUnexported: true }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, + { + code: ` + function Hello(props) { + return
{props.name}
; + } + `, + options: [{ skipUnexported: false }], + errors: [ + { + messageId: 'missingPropType', + data: { name: 'name' }, + }, + ], + }, { code: ` type MyComponentProps = {