diff --git a/.changeset/fix-fragment-children.md b/.changeset/fix-fragment-children.md new file mode 100644 index 0000000..16d51cb --- /dev/null +++ b/.changeset/fix-fragment-children.md @@ -0,0 +1,7 @@ +--- +"eslint-plugin-react-render-types": patch +--- + +fix: validate children inside JSX fragments in `valid-render-prop` rule + +Previously, components wrapped in JSX fragments (`<>...`) were silently skipped during children validation. This meant neither valid nor invalid children inside fragments were checked against `@renders` annotations. Now fragments are recursively unwrapped in both `validateJSXChildren` and `extractChildElementNames`. diff --git a/src/rules/valid-render-prop.ts b/src/rules/valid-render-prop.ts index 601b5c3..8c5198e 100644 --- a/src/rules/valid-render-prop.ts +++ b/src/rules/valid-render-prop.ts @@ -360,43 +360,54 @@ export default createRule<[], MessageIds>({ return; } + const resolvedAnnotation = annotation; + // Use pre-resolved type IDs from source context if available (external annotations), // otherwise resolve from the current file's scope (local annotations) - const resolved = annotation as ResolvedRendersAnnotation; + const resolved = resolvedAnnotation as ResolvedRendersAnnotation; const expectedTypeId = resolved.targetTypeId ?? crossFileResolver.getComponentTypeId(annotation.componentName) ?? undefined; const expectedTypeIds = resolved.targetTypeIds ?? getExpectedTypeIds(annotation); - // Validate each child - for (const child of node.children) { - let extractedNames: string[] = []; + // Validate each child, recursively unwrapping fragments + function validateChildren(children: typeof node.children): void { + for (const child of children) { + if (child.type === "JSXFragment") { + validateChildren(child.children); + continue; + } - if (child.type === "JSXElement") { - const childName = getJSXElementName(child); - if (childName && transparentComponents.has(childName)) { - extractedNames = extractChildElementNames(child, transparentComponents); - } else if (childName) { - extractedNames = [childName]; + let extractedNames: string[] = []; + + if (child.type === "JSXElement") { + const childName = getJSXElementName(child); + if (childName && transparentComponents.has(childName)) { + extractedNames = extractChildElementNames(child, transparentComponents); + } else if (childName) { + extractedNames = [childName]; + } + } else if (child.type === "JSXExpressionContainer" && child.expression.type !== "JSXEmptyExpression") { + extractedNames = extractJSXFromExpression(child.expression); } - } else if (child.type === "JSXExpressionContainer" && child.expression.type !== "JSXEmptyExpression") { - extractedNames = extractJSXFromExpression(child.expression); - } - for (const name of extractedNames) { - const actualTypeId = crossFileResolver.getComponentTypeId(name) ?? undefined; - if (!isValidValue(name, annotation, renderMap, actualTypeId, expectedTypeId, expectedTypeIds.length > 0 ? expectedTypeIds : undefined)) { - context.report({ - node: child, - messageId: "invalidRenderChildren", - data: { - expected: formatExpected(annotation), - actual: name, - }, - }); + for (const name of extractedNames) { + const actualTypeId = crossFileResolver.getComponentTypeId(name) ?? undefined; + if (!isValidValue(name, resolvedAnnotation, renderMap, actualTypeId, expectedTypeId, expectedTypeIds.length > 0 ? expectedTypeIds : undefined)) { + context.report({ + node: child, + messageId: "invalidRenderChildren", + data: { + expected: formatExpected(resolvedAnnotation), + actual: name, + }, + }); + } } } } + + validateChildren(node.children); } /** diff --git a/src/utils/jsx-extraction.ts b/src/utils/jsx-extraction.ts index 204406d..e77bff0 100644 --- a/src/utils/jsx-extraction.ts +++ b/src/utils/jsx-extraction.ts @@ -222,25 +222,7 @@ function extractFromTransparentElement( // Extract from children if "children" is in propNames if (propNames.has("children")) { - for (const child of jsxElement.children) { - if (child.type === "JSXElement") { - results.push( - ...extractFromJSXElement( - child, - transparentComponents, - new Set(visited), - maxDepth - 1 - ) - ); - } else if ( - child.type === "JSXExpressionContainer" && - child.expression.type !== "JSXEmptyExpression" - ) { - results.push( - ...extractJSXFromExpression(child.expression, maxDepth - 1) - ); - } - } + extractFromChildren(jsxElement.children, transparentComponents, visited, maxDepth, results); } // Extract from named prop attributes @@ -304,8 +286,25 @@ export function extractChildElementNames( const results: string[] = []; - for (const child of jsxElement.children) { - if (child.type === "JSXElement") { + extractFromChildren(jsxElement.children, transparentComponents, visited, maxDepth, results); + + return results; +} + +/** + * Extract component names from JSX children, recursively unwrapping fragments. + */ +function extractFromChildren( + children: TSESTree.JSXElement["children"], + transparentComponents: Map>, + visited: Set, + maxDepth: number, + results: string[] +): void { + for (const child of children) { + if (child.type === "JSXFragment") { + extractFromChildren(child.children, transparentComponents, visited, maxDepth, results); + } else if (child.type === "JSXElement") { results.push( ...extractFromJSXElement( child, @@ -323,6 +322,4 @@ export function extractChildElementNames( ); } } - - return results; } diff --git a/tests/rules/valid-render-prop.test.ts b/tests/rules/valid-render-prop.test.ts index e48aac5..bd170d3 100644 --- a/tests/rules/valid-render-prop.test.ts +++ b/tests/rules/valid-render-prop.test.ts @@ -517,6 +517,37 @@ ruleTester.run("valid-render-prop", rule, { }, }, }, + { + name: "nested fragments and React.Suspense transparent in children", + code: withComponents( + ` + interface TabsProps { + /** @renders {Tab} */ + children: React.ReactNode; + } + + declare const test: boolean; + + + <> + + <> + + {test && } + + + + ; + `, + ["Tab", "Tabs"] + ), + filename: "test.tsx", + settings: { + "react-render-types": { + additionalTransparentComponents: ["React.Suspense"], + }, + }, + }, // Component with @renders* used as child of union @renders* parent { @@ -1078,6 +1109,48 @@ ruleTester.run("valid-render-prop", rule, { ], }, + { + name: "nested fragments and React.Suspense transparent with invalid child", + code: withComponents( + ` + interface TabsProps { + /** @renders {Tab} */ + children: React.ReactNode; + } + + declare const test: boolean; + + + <> + + <> + + {test &&