From eb67fb4e6b6f3aecced82e25aa6166fb655ea77e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:26:58 +0000 Subject: [PATCH 1/6] fix: rn-no-raw-text false positives for nested Text wrappers and test files (#788) Co-Authored-By: Aiden Bai --- .../rn-no-raw-text-wrapper-false-positive.md | 6 ++ .../rules/react-native/rn-no-raw-text.test.ts | 46 +++++++++++ .../rules/react-native/rn-no-raw-text.ts | 6 +- .../utils/collect-text-wrapper-components.ts | 76 ++++++++++++++----- 4 files changed, 113 insertions(+), 21 deletions(-) create mode 100644 .changeset/rn-no-raw-text-wrapper-false-positive.md diff --git a/.changeset/rn-no-raw-text-wrapper-false-positive.md b/.changeset/rn-no-raw-text-wrapper-false-positive.md new file mode 100644 index 000000000..59b18f58b --- /dev/null +++ b/.changeset/rn-no-raw-text-wrapper-false-positive.md @@ -0,0 +1,6 @@ +--- +"oxlint-plugin-react-doctor": patch +"react-doctor": patch +--- + +Fix false positives in `rn-no-raw-text` (#788) for custom components that forward their children into a ``: the in-file wrapper detection now recognizes components that render `{children}` (or `{props.children}`) inside a nested `` (the `{children}` shape), not just components whose returned root is a ``, and unwraps parenthesized `return (...)` bodies. The rule is also tagged `test-noise`, so it no longer fires in test/story files — raw text rendered through React Native Testing Library never ships to users, and cross-file wrappers (an imported `Test Chip` in a `.test.tsx`) were the main source of unfixable noise there. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts index 37650e73d..4253f2223 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts @@ -78,6 +78,52 @@ describe("react-native/rn-no-raw-text", () => { const App = () => ; `); }); + + it("suppresses a wrapper forwarding children into a nested Text", () => { + expectPass(` + function Chip({ children }) { + return ( + + {children} + + ); + } + const App = () => Test Chip; + `); + }); + + it("suppresses an arrow wrapper forwarding props.children into a nested Text", () => { + expectPass(` + const Badge = (props) => ( + + {props.children} + + ); + const App = () => New; + `); + }); + + it("still fires when the nested Text receives something other than children", () => { + expectFail(` + const Card = ({ title, children }) => ( + + {title} + {children} + + ); + const App = () => Body copy; + `); + }); + }); + + describe("test-noise suppression", () => { + it("does not fire in testlike files", () => { + const result = runRule(rnNoRawText, `const App = () => Hello;`, { + filename: "Chip.test.tsx", + }); + expect(result.parseErrors).toEqual([]); + expect(result.diagnostics).toHaveLength(0); + }); }); describe("expo universal ui ListItem", () => { diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.ts index 80c9f5449..9523e89ed 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.ts @@ -96,6 +96,7 @@ export const rnNoRawText = defineRule({ title: "Raw text outside a Text component", requires: ["react-native"], severity: "error", + tags: ["test-noise"], recommendation: "Text outside a `` component crashes on React Native. Wrap it like `{value}`.", create: (context: RuleContext) => { @@ -108,8 +109,9 @@ export const rnNoRawText = defineRule({ // in a WebView as DOM rather than on React Native primitives. let isDomComponentFile = false; - // Auto-detected in-file text wrappers — components whose returned root is - // a real `` (so they forward children into text). Populated from the + // Auto-detected in-file text wrappers — components that forward their + // children into a real `` (either as the returned root or nested + // inside the returned markup). Populated from the // program on first visit so usage anywhere in the file (declared before or // after) is seen. Manual `textComponents` / `rawTextWrapperComponents` // overrides are applied separately in the core diagnostic pipeline diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts index f23b92055..76524823c 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts @@ -2,6 +2,7 @@ import type { EsTreeNode } from "../../../utils/es-tree-node.js"; import type { EsTreeNodeOfType } from "../../../utils/es-tree-node-of-type.js"; import { isNodeOfType } from "../../../utils/is-node-of-type.js"; import { isReactComponentName } from "../../../utils/is-react-component-name.js"; +import { stripParenExpression } from "../../../utils/strip-paren-expression.js"; import { walkAst } from "../../../utils/walk-ast.js"; import { resolveJsxElementName } from "./resolve-jsx-element-name.js"; @@ -15,35 +16,64 @@ const isFunctionNode = (node: EsTreeNode): node is FunctionNode => isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "FunctionDeclaration"); -// Resolves the tag name of the single JSX element a component returns at the -// top level of its body, or `null` when the component returns a fragment, -// conditional markup, or no JSX. Only the *root* element matters here: a -// wrapper like `({ children }) => {children}` forwards its -// children into that root, so the root's identity decides whether the wrapper -// is text-handling. We deliberately stay shallow (expression body, or a +// Resolves the single JSX element a component returns at the top level of its +// body, or `null` when the component returns a fragment, conditional markup, +// or no JSX. We deliberately stay shallow (expression body, or a // `ReturnStatement` at the block's top level) to keep detection high-confidence. -const resolveReturnedRootElementName = (functionNode: FunctionNode): string | null => { +const resolveReturnedRootElement = ( + functionNode: FunctionNode, +): EsTreeNodeOfType<"JSXElement"> | null => { const { body } = functionNode; if (!body) return null; if (!isNodeOfType(body, "BlockStatement")) { - return isNodeOfType(body, "JSXElement") ? resolveJsxElementName(body.openingElement) : null; + const expressionBody = stripParenExpression(body); + return isNodeOfType(expressionBody, "JSXElement") ? expressionBody : null; } for (const statement of body.body) { if (!isNodeOfType(statement, "ReturnStatement")) continue; - const argument = statement.argument; - if (argument && isNodeOfType(argument, "JSXElement")) { - return resolveJsxElementName(argument.openingElement); - } + if (!statement.argument) continue; + const argument = stripParenExpression(statement.argument); + if (isNodeOfType(argument, "JSXElement")) return argument; } return null; }; -// Records a component declaration when its name is PascalCase and its returned -// root element is text-handling. Both `const Label = (...) => ` -// (variable declarator) and `function Label(...) { return }` -// (declaration) are covered. +const isChildrenForwardingExpression = (child: EsTreeNode): boolean => { + if (!isNodeOfType(child, "JSXExpressionContainer") || !child.expression) return false; + const expression = child.expression; + if (isNodeOfType(expression, "Identifier")) return expression.name === "children"; + return ( + isNodeOfType(expression, "MemberExpression") && + isNodeOfType(expression.property, "Identifier") && + expression.property.name === "children" + ); +}; + +// True when somewhere in the returned JSX a text-handling element directly +// receives `{children}` (or `{props.children}`) — the `{children} +// ` shape, where the wrapper's raw string children still land +// inside a `` even though the root element isn't one. +const elementForwardsChildrenIntoText = ( + rootElement: EsTreeNodeOfType<"JSXElement">, + isTextHandlingRoot: (elementName: string) => boolean, +): boolean => { + let didForwardIntoText = false; + walkAst(rootElement, (node) => { + if (didForwardIntoText || !isNodeOfType(node, "JSXElement")) return; + const elementName = resolveJsxElementName(node.openingElement); + if (!elementName || !isTextHandlingRoot(elementName)) return; + didForwardIntoText = (node.children ?? []).some(isChildrenForwardingExpression); + }); + return didForwardIntoText; +}; + +// Records a component declaration when its name is PascalCase and it forwards +// its children into a text-handling element — either the returned root itself +// (`const Label = (...) => `) or a nested `{children}` +// inside the returned markup. Both variable declarators and function +// declarations are covered. const recordWrapperFromDeclaration = ( componentName: string | null, functionNode: EsTreeNode | null | undefined, @@ -52,12 +82,20 @@ const recordWrapperFromDeclaration = ( ): void => { if (!componentName || !isReactComponentName(componentName)) return; if (!functionNode || !isFunctionNode(functionNode)) return; - const rootName = resolveReturnedRootElementName(functionNode); - if (rootName && isTextHandlingRoot(rootName)) wrappers.add(componentName); + const rootElement = resolveReturnedRootElement(functionNode); + if (!rootElement) return; + const rootName = resolveJsxElementName(rootElement.openingElement); + if (rootName && isTextHandlingRoot(rootName)) { + wrappers.add(componentName); + return; + } + if (elementForwardsChildrenIntoText(rootElement, isTextHandlingRoot)) { + wrappers.add(componentName); + } }; // Walks a program once and returns the names of in-file components that -// forward their children into a text-handling root element. These behave like +// forward their children into a text-handling element. These behave like // configured `rawTextWrapperComponents`: raw text inside them is safe only when // the children are string-only (mixed children still get reported), since the // wrapper is assumed to forward `children` into a single ``. From bf8ec47090519f372f74d6839fddb9aabae105b3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:32:55 +0000 Subject: [PATCH 2/6] test: rn-no-raw-text now authors the test-noise tag Co-Authored-By: Aiden Bai --- .../react-doctor/tests/rule-tag-registration.test.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/react-doctor/tests/rule-tag-registration.test.ts b/packages/react-doctor/tests/rule-tag-registration.test.ts index 829707d74..32c8a5f14 100644 --- a/packages/react-doctor/tests/rule-tag-registration.test.ts +++ b/packages/react-doctor/tests/rule-tag-registration.test.ts @@ -48,11 +48,13 @@ describe("rule tag registration", () => { }); it("preserves rule-authored tags alongside bucket auto-tags (e.g. test-noise stays on react-native rules that opted in)", () => { - // `rn-no-raw-text` is in the react-native bucket; its only auto-tag - // is "react-native". `no-react19-deprecated-apis` is in architecture - // and authors both "test-noise" and "migration-hint" — no auto-tag - // overwrites those. - expect(getRuleTags("rn-no-raw-text")).toEqual(["react-native"]); + // `rn-no-raw-text` is in the react-native bucket and authors + // "test-noise"; the bucket auto-tag is added alongside it. + // `no-react19-deprecated-apis` is in architecture and authors both + // "test-noise" and "migration-hint" — no auto-tag overwrites those. + const rnNoRawTextTags = getRuleTags("rn-no-raw-text"); + expect(rnNoRawTextTags).toContain("react-native"); + expect(rnNoRawTextTags).toContain("test-noise"); const migrationHintTags = getRuleTags("no-react19-deprecated-apis"); expect(migrationHintTags).toContain("test-noise"); expect(migrationHintTags).toContain("migration-hint"); From 3707de2e10b5879675ab1d9fdb0ca072dab607a3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 22:49:10 +0000 Subject: [PATCH 3/6] fix: detect more text-wrapper shapes (memo/forwardRef, fragments, conditional returns, renamed children, children prop, transitive wrappers) Co-Authored-By: Aiden Bai --- .../rn-no-raw-text-wrapper-false-positive.md | 2 +- .../rules/react-native/rn-no-raw-text.test.ts | 100 ++++++++ .../utils/collect-text-wrapper-components.ts | 221 +++++++++++++----- 3 files changed, 261 insertions(+), 62 deletions(-) diff --git a/.changeset/rn-no-raw-text-wrapper-false-positive.md b/.changeset/rn-no-raw-text-wrapper-false-positive.md index 59b18f58b..ef0c13958 100644 --- a/.changeset/rn-no-raw-text-wrapper-false-positive.md +++ b/.changeset/rn-no-raw-text-wrapper-false-positive.md @@ -3,4 +3,4 @@ "react-doctor": patch --- -Fix false positives in `rn-no-raw-text` (#788) for custom components that forward their children into a ``: the in-file wrapper detection now recognizes components that render `{children}` (or `{props.children}`) inside a nested `` (the `{children}` shape), not just components whose returned root is a ``, and unwraps parenthesized `return (...)` bodies. The rule is also tagged `test-noise`, so it no longer fires in test/story files — raw text rendered through React Native Testing Library never ships to users, and cross-file wrappers (an imported `Test Chip` in a `.test.tsx`) were the main source of unfixable noise there. +Fix false positives in `rn-no-raw-text` (#788) for custom components that forward their children into a ``: the in-file wrapper detection now recognizes components that render `{children}` (or `{props.children}`) inside a nested `` (the `{children}` shape), not just components whose returned root is a ``. Detection also handles parenthesized `return (...)` bodies, `memo`/`forwardRef`-wrapped components, fragment roots, conditional and logical returns, early returns inside `if` branches, renamed destructured children (`{ children: content }`), the `` prop form, and wrappers that forward through another in-file wrapper. The rule is also tagged `test-noise`, so it no longer fires in test/story files — raw text rendered through React Native Testing Library never ships to users, and cross-file wrappers (an imported `Test Chip` in a `.test.tsx`) were the main source of unfixable noise there. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts index 4253f2223..c6e7a95ad 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts @@ -103,6 +103,106 @@ describe("react-native/rn-no-raw-text", () => { `); }); + it("suppresses a forwardRef/memo wrapper forwarding children into a nested Text", () => { + expectPass(` + import { forwardRef, memo } from "react"; + const Chip = memo( + forwardRef(({ children }, ref) => ( + + {children} + + )), + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper with a conditional return", () => { + expectPass(` + const Chip = ({ children, isLoading }) => + isLoading ? : ( + + {children} + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper returning a fragment with a nested Text", () => { + expectPass(` + const Chip = ({ children }) => ( + <> + + {children} + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper with renamed destructured children", () => { + expectPass(` + const Chip = ({ children: content }) => ( + + {content} + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper using the children prop form on Text", () => { + expectPass(` + const Chip = ({ children }) => ( + + + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper with a return inside an if branch", () => { + expectPass(` + function Chip({ children, compact }) { + if (compact) { + return {children}; + } + return ( + + {children} + + ); + } + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper that forwards children through another in-file wrapper", () => { + expectPass(` + const Chip = ({ children }) => ( + + {children} + + ); + const Badge = ({ children }) => {children}; + const App = () => New; + `); + }); + + it("does not treat a render-prop's Text as the wrapper's own markup", () => { + expectFail(` + const Box = ({ children, renderLabel }) => ( + + {() => {children}} + {children} + + ); + const App = () => Hello; + `); + }); + it("still fires when the nested Text receives something other than children", () => { expectFail(` const Card = ({ title, children }) => ( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts index 76524823c..c897acaa3 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts @@ -16,34 +16,97 @@ const isFunctionNode = (node: EsTreeNode): node is FunctionNode => isNodeOfType(node, "FunctionExpression") || isNodeOfType(node, "FunctionDeclaration"); -// Resolves the single JSX element a component returns at the top level of its -// body, or `null` when the component returns a fragment, conditional markup, -// or no JSX. We deliberately stay shallow (expression body, or a -// `ReturnStatement` at the block's top level) to keep detection high-confidence. -const resolveReturnedRootElement = ( - functionNode: FunctionNode, -): EsTreeNodeOfType<"JSXElement"> | null => { +const COMPONENT_WRAPPER_CALLEE_NAMES = new Set(["memo", "forwardRef"]); + +const resolveCalleeName = (callee: EsTreeNode): string | null => { + if (isNodeOfType(callee, "Identifier")) return callee.name; + if (isNodeOfType(callee, "MemberExpression") && isNodeOfType(callee.property, "Identifier")) { + return callee.property.name; + } + return null; +}; + +// Peels `memo(...)` / `forwardRef(...)` / `React.memo(React.forwardRef(...))` +// down to the render function so those wrapped components are analyzed too. +const unwrapComponentDefinition = (node: EsTreeNode): EsTreeNode => { + let current = stripParenExpression(node); + while (isNodeOfType(current, "CallExpression")) { + const calleeName = resolveCalleeName(current.callee); + const firstArgument = current.arguments?.[0]; + if (!calleeName || !COMPONENT_WRAPPER_CALLEE_NAMES.has(calleeName) || !firstArgument) break; + current = stripParenExpression(firstArgument); + } + return current; +}; + +// The local identifier the component's children are bound to: `children` for +// `({ children })` and props-object params, or the rename in +// `({ children: content })`. +const resolveChildrenLocalName = (functionNode: FunctionNode): string => { + const firstParam = functionNode.params?.[0]; + if (!firstParam || !isNodeOfType(firstParam, "ObjectPattern")) return "children"; + for (const property of firstParam.properties ?? []) { + if (!isNodeOfType(property, "Property")) continue; + if (!isNodeOfType(property.key, "Identifier") || property.key.name !== "children") continue; + const value = property.value; + if (isNodeOfType(value, "Identifier")) return value.name; + if (isNodeOfType(value, "AssignmentPattern") && isNodeOfType(value.left, "Identifier")) { + return value.left.name; + } + } + return "children"; +}; + +// Collects the JSX roots a value can evaluate to, looking through parentheses, +// ternaries, and `&&` / `||` / `??` chains — e.g. both branches of +// `isLoading ? : {children}`. +const collectJsxRootsFromExpression = (expression: EsTreeNode, roots: EsTreeNode[]): void => { + const value = stripParenExpression(expression); + if (isNodeOfType(value, "JSXElement") || isNodeOfType(value, "JSXFragment")) { + roots.push(value); + return; + } + if (isNodeOfType(value, "ConditionalExpression")) { + if (value.consequent) collectJsxRootsFromExpression(value.consequent, roots); + if (value.alternate) collectJsxRootsFromExpression(value.alternate, roots); + return; + } + if (isNodeOfType(value, "LogicalExpression")) { + if (value.left) collectJsxRootsFromExpression(value.left, roots); + if (value.right) collectJsxRootsFromExpression(value.right, roots); + } +}; + +// Resolves the JSX roots a component can return: the expression body, or the +// arguments of `ReturnStatement`s anywhere in the body (so early returns and +// returns inside `if` branches are seen). +const collectReturnedJsxRoots = (functionNode: FunctionNode): EsTreeNode[] => { + const roots: EsTreeNode[] = []; const { body } = functionNode; - if (!body) return null; + if (!body) return roots; if (!isNodeOfType(body, "BlockStatement")) { - const expressionBody = stripParenExpression(body); - return isNodeOfType(expressionBody, "JSXElement") ? expressionBody : null; + collectJsxRootsFromExpression(body, roots); + return roots; } - for (const statement of body.body) { - if (!isNodeOfType(statement, "ReturnStatement")) continue; - if (!statement.argument) continue; - const argument = stripParenExpression(statement.argument); - if (isNodeOfType(argument, "JSXElement")) return argument; - } - return null; + walkAst(body, (node) => { + if (isFunctionNode(node) && node !== functionNode) return false; + if (isNodeOfType(node, "ReturnStatement") && node.argument) { + collectJsxRootsFromExpression(node.argument, roots); + return false; + } + return undefined; + }); + return roots; }; -const isChildrenForwardingExpression = (child: EsTreeNode): boolean => { - if (!isNodeOfType(child, "JSXExpressionContainer") || !child.expression) return false; - const expression = child.expression; - if (isNodeOfType(expression, "Identifier")) return expression.name === "children"; +const isChildrenForwardingExpression = ( + expression: EsTreeNode | null | undefined, + childrenLocalName: string, +): boolean => { + if (!expression) return false; + if (isNodeOfType(expression, "Identifier")) return expression.name === childrenLocalName; return ( isNodeOfType(expression, "MemberExpression") && isNodeOfType(expression.property, "Identifier") && @@ -51,69 +114,105 @@ const isChildrenForwardingExpression = (child: EsTreeNode): boolean => { ); }; +const isChildrenForwardingJsxChild = (child: EsTreeNode, childrenLocalName: string): boolean => + isNodeOfType(child, "JSXExpressionContainer") && + isChildrenForwardingExpression(child.expression, childrenLocalName); + +const isChildrenForwardingAttribute = (attribute: EsTreeNode, childrenLocalName: string): boolean => + isNodeOfType(attribute, "JSXAttribute") && + isNodeOfType(attribute.name, "JSXIdentifier") && + attribute.name.name === "children" && + isNodeOfType(attribute.value, "JSXExpressionContainer") && + isChildrenForwardingExpression(attribute.value.expression, childrenLocalName); + // True when somewhere in the returned JSX a text-handling element directly -// receives `{children}` (or `{props.children}`) — the `{children} -// ` shape, where the wrapper's raw string children still land -// inside a `` even though the root element isn't one. -const elementForwardsChildrenIntoText = ( - rootElement: EsTreeNodeOfType<"JSXElement">, - isTextHandlingRoot: (elementName: string) => boolean, +// receives the component's children — `{children}`, +// `{props.children}`, or `` — where +// the wrapper's raw string children still land inside a `` even though +// the root element isn't one. +const jsxRootForwardsChildrenIntoText = ( + jsxRoot: EsTreeNode, + childrenLocalName: string, + isTextHandlingElement: (elementName: string) => boolean, ): boolean => { let didForwardIntoText = false; - walkAst(rootElement, (node) => { - if (didForwardIntoText || !isNodeOfType(node, "JSXElement")) return; + walkAst(jsxRoot, (node) => { + if (didForwardIntoText || isFunctionNode(node)) return false; + if (!isNodeOfType(node, "JSXElement")) return undefined; const elementName = resolveJsxElementName(node.openingElement); - if (!elementName || !isTextHandlingRoot(elementName)) return; - didForwardIntoText = (node.children ?? []).some(isChildrenForwardingExpression); + if (!elementName || !isTextHandlingElement(elementName)) return; + didForwardIntoText = + (node.children ?? []).some((child) => + isChildrenForwardingJsxChild(child, childrenLocalName), + ) || + (node.openingElement.attributes ?? []).some((attribute) => + isChildrenForwardingAttribute(attribute, childrenLocalName), + ); }); return didForwardIntoText; }; // Records a component declaration when its name is PascalCase and it forwards -// its children into a text-handling element — either the returned root itself -// (`const Label = (...) => `) or a nested `{children}` -// inside the returned markup. Both variable declarators and function -// declarations are covered. +// its children into a text-handling element — either a returned root that is +// itself text-handling (`const Label = (...) => `) or a nested +// `{children}` inside any returned markup. const recordWrapperFromDeclaration = ( componentName: string | null, - functionNode: EsTreeNode | null | undefined, - isTextHandlingRoot: (elementName: string) => boolean, + definitionNode: EsTreeNode | null | undefined, + isTextHandlingElement: (elementName: string) => boolean, wrappers: Set, ): void => { if (!componentName || !isReactComponentName(componentName)) return; - if (!functionNode || !isFunctionNode(functionNode)) return; - const rootElement = resolveReturnedRootElement(functionNode); - if (!rootElement) return; - const rootName = resolveJsxElementName(rootElement.openingElement); - if (rootName && isTextHandlingRoot(rootName)) { - wrappers.add(componentName); - return; - } - if (elementForwardsChildrenIntoText(rootElement, isTextHandlingRoot)) { - wrappers.add(componentName); + if (wrappers.has(componentName)) return; + if (!definitionNode) return; + const functionNode = unwrapComponentDefinition(definitionNode); + if (!isFunctionNode(functionNode)) return; + const childrenLocalName = resolveChildrenLocalName(functionNode); + for (const jsxRoot of collectReturnedJsxRoots(functionNode)) { + if (isNodeOfType(jsxRoot, "JSXElement")) { + const rootName = resolveJsxElementName(jsxRoot.openingElement); + if (rootName && isTextHandlingElement(rootName)) { + wrappers.add(componentName); + return; + } + } + if (jsxRootForwardsChildrenIntoText(jsxRoot, childrenLocalName, isTextHandlingElement)) { + wrappers.add(componentName); + return; + } } }; -// Walks a program once and returns the names of in-file components that -// forward their children into a text-handling element. These behave like -// configured `rawTextWrapperComponents`: raw text inside them is safe only when -// the children are string-only (mixed children still get reported), since the -// wrapper is assumed to forward `children` into a single ``. +const MAX_TRANSITIVE_WRAPPER_PASSES = 3; + +// Walks a program and returns the names of in-file components that forward +// their children into a text-handling element. These behave like configured +// `rawTextWrapperComponents`: raw text inside them is safe only when the +// children are string-only (mixed children still get reported), since the +// wrapper is assumed to forward `children` into a single ``. Repeats the +// walk a bounded number of times so wrappers-of-wrappers +// (`const Badge = ({ children }) => {children}`) are detected. export const collectTextWrapperComponents = ( programNode: EsTreeNode, isTextHandlingRoot: (elementName: string) => boolean, ): ReadonlySet => { const wrappers = new Set(); + const isTextHandlingElement = (elementName: string): boolean => + isTextHandlingRoot(elementName) || wrappers.has(elementName); - walkAst(programNode, (node) => { - if (isNodeOfType(node, "VariableDeclarator")) { - const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; - recordWrapperFromDeclaration(componentName, node.init, isTextHandlingRoot, wrappers); - } else if (isNodeOfType(node, "FunctionDeclaration")) { - const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; - recordWrapperFromDeclaration(componentName, node, isTextHandlingRoot, wrappers); - } - }); + for (let pass = 0; pass < MAX_TRANSITIVE_WRAPPER_PASSES; pass += 1) { + const sizeBeforePass = wrappers.size; + walkAst(programNode, (node) => { + if (isNodeOfType(node, "VariableDeclarator")) { + const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; + recordWrapperFromDeclaration(componentName, node.init, isTextHandlingElement, wrappers); + } else if (isNodeOfType(node, "FunctionDeclaration")) { + const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; + recordWrapperFromDeclaration(componentName, node, isTextHandlingElement, wrappers); + } + }); + if (wrappers.size === sizeBeforePass) break; + } return wrappers; }; From 8ee97c9ad7df7b093dc9f246f15234600e841735 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:30:28 +0000 Subject: [PATCH 4/6] fix: detect children aliases, props spreads, class components, and styled(Text) factories as text wrappers Co-Authored-By: Aiden Bai --- .../rn-no-raw-text-wrapper-false-positive.md | 2 +- .../rules/react-native/rn-no-raw-text.test.ts | 102 ++++++++ .../utils/collect-text-wrapper-components.ts | 227 ++++++++++++++---- 3 files changed, 289 insertions(+), 42 deletions(-) diff --git a/.changeset/rn-no-raw-text-wrapper-false-positive.md b/.changeset/rn-no-raw-text-wrapper-false-positive.md index ef0c13958..c426b233d 100644 --- a/.changeset/rn-no-raw-text-wrapper-false-positive.md +++ b/.changeset/rn-no-raw-text-wrapper-false-positive.md @@ -3,4 +3,4 @@ "react-doctor": patch --- -Fix false positives in `rn-no-raw-text` (#788) for custom components that forward their children into a ``: the in-file wrapper detection now recognizes components that render `{children}` (or `{props.children}`) inside a nested `` (the `{children}` shape), not just components whose returned root is a ``. Detection also handles parenthesized `return (...)` bodies, `memo`/`forwardRef`-wrapped components, fragment roots, conditional and logical returns, early returns inside `if` branches, renamed destructured children (`{ children: content }`), the `` prop form, and wrappers that forward through another in-file wrapper. The rule is also tagged `test-noise`, so it no longer fires in test/story files — raw text rendered through React Native Testing Library never ships to users, and cross-file wrappers (an imported `Test Chip` in a `.test.tsx`) were the main source of unfixable noise there. +Fix false positives in `rn-no-raw-text` (#788) for custom components that forward their children into a ``: the in-file wrapper detection now recognizes components that render `{children}` (or `{props.children}`) inside a nested `` (the `{children}` shape), not just components whose returned root is a ``. Detection also handles parenthesized `return (...)` bodies, `memo`/`forwardRef`-wrapped components, fragment roots, conditional and logical returns, early returns inside `if` branches, renamed destructured children (`{ children: content }`), the `` prop form, wrappers that forward through another in-file wrapper, children aliased to a variable or destructured from props in the body, props spreads that carry children (``, ``, ``), class components, and `styled(Text)` / `styled.Text` factories. The rule is also tagged `test-noise`, so it no longer fires in test/story files — raw text rendered through React Native Testing Library never ships to users, and cross-file wrappers (an imported `Test Chip` in a `.test.tsx`) were the main source of unfixable noise there. diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts index c6e7a95ad..a25419151 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts @@ -191,6 +191,108 @@ describe("react-native/rn-no-raw-text", () => { `); }); + it("suppresses a wrapper that aliases children to a variable", () => { + expectPass(` + function Chip({ children }) { + const content = children; + return ( + + {content} + + ); + } + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper that destructures children from props in the body", () => { + expectPass(` + const Chip = (props) => { + const { children } = props; + return ( + + {children} + + ); + }; + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper spreading props onto a nested Text", () => { + expectPass(` + const Chip = (props) => ( + + + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a wrapper spreading an object rest that carries children", () => { + expectPass(` + const Chip = ({ style, ...rest }) => ( + + + + ); + const App = () => Test Chip; + `); + }); + + it("still fires when the spread rest excludes children", () => { + expectFail(` + const Chip = ({ children, ...rest }) => ( + + + {children} + + ); + const App = () => Test Chip; + `); + }); + + it("suppresses a class component forwarding this.props.children into a Text", () => { + expectPass(` + class Chip extends React.Component { + render() { + return ( + + {this.props.children} + + ); + } + } + const App = () => Test Chip; + `); + }); + + it("suppresses a styled(Text) factory component", () => { + expectPass(` + const FancyChip = styled(Text)\` + color: red; + \`; + const App = () => Test Chip; + `); + }); + + it("suppresses a styled.Text factory component", () => { + expectPass(` + const FancyCopy = styled.Text({ color: "red" }); + const App = () => Test Chip; + `); + }); + + it("still fires for a styled(View) factory component", () => { + expectFail(` + const Card = styled(View)\` + padding: 4px; + \`; + const App = () => Test Chip; + `); + }); + it("does not treat a render-prop's Text as the wrapper's own markup", () => { expectFail(` const Box = ({ children, renderLabel }) => ( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts index c897acaa3..c8c75be5b 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts @@ -39,22 +39,102 @@ const unwrapComponentDefinition = (node: EsTreeNode): EsTreeNode => { return current; }; -// The local identifier the component's children are bound to: `children` for -// `({ children })` and props-object params, or the rename in -// `({ children: content })`. -const resolveChildrenLocalName = (functionNode: FunctionNode): string => { +interface ChildrenBindings { + // Identifiers that hold the component's children (`children`, a destructure + // rename, or a later alias like `const content = children`). + childrenNames: Set; + // Identifiers whose `.children` (and whose spread) carries the component's + // children — the props param or an object rest that still includes children. + propsObjectNames: Set; +} + +const resolveChildrenPropertyLocalName = (property: EsTreeNode): string | null => { + if (!isNodeOfType(property, "Property")) return null; + if (!isNodeOfType(property.key, "Identifier") || property.key.name !== "children") return null; + const value = property.value; + if (isNodeOfType(value, "Identifier")) return value.name; + if (isNodeOfType(value, "AssignmentPattern") && isNodeOfType(value.left, "Identifier")) { + return value.left.name; + } + return null; +}; + +// The identifiers the component's children are bound to: `children` for +// `({ children })` and props-object params, the rename in +// `({ children: content })`, plus the props object itself (`props` or an +// object rest that still carries children). +const resolveParamChildrenBindings = (functionNode: FunctionNode): ChildrenBindings => { + const bindings: ChildrenBindings = { + childrenNames: new Set(["children"]), + propsObjectNames: new Set(), + }; const firstParam = functionNode.params?.[0]; - if (!firstParam || !isNodeOfType(firstParam, "ObjectPattern")) return "children"; + if (!firstParam) return bindings; + if (isNodeOfType(firstParam, "Identifier")) { + bindings.propsObjectNames.add(firstParam.name); + return bindings; + } + if (!isNodeOfType(firstParam, "ObjectPattern")) return bindings; + let didDestructureChildren = false; + let restName: string | null = null; for (const property of firstParam.properties ?? []) { - if (!isNodeOfType(property, "Property")) continue; - if (!isNodeOfType(property.key, "Identifier") || property.key.name !== "children") continue; - const value = property.value; - if (isNodeOfType(value, "Identifier")) return value.name; - if (isNodeOfType(value, "AssignmentPattern") && isNodeOfType(value.left, "Identifier")) { - return value.left.name; + if (isNodeOfType(property, "RestElement") && isNodeOfType(property.argument, "Identifier")) { + restName = property.argument.name; + continue; } + const localName = resolveChildrenPropertyLocalName(property); + if (localName) { + didDestructureChildren = true; + bindings.childrenNames.add(localName); + } + } + if (restName && !didDestructureChildren) bindings.propsObjectNames.add(restName); + return bindings; +}; + +const MAX_CHILDREN_ALIAS_PASSES = 3; + +const isChildrenValueExpression = ( + expression: EsTreeNode | null | undefined, + bindings: ChildrenBindings, +): boolean => { + if (!expression) return false; + const value = stripParenExpression(expression); + if (isNodeOfType(value, "Identifier")) return bindings.childrenNames.has(value.name); + return ( + isNodeOfType(value, "MemberExpression") && + isNodeOfType(value.property, "Identifier") && + value.property.name === "children" + ); +}; + +// Folds body-level aliases into the bindings: `const content = children`, +// `const { children } = props` (or `this.props`), and re-aliases of aliases +// (bounded passes). +const collectChildrenAliases = (functionNode: FunctionNode, bindings: ChildrenBindings): void => { + const { body } = functionNode; + if (!body || !isNodeOfType(body, "BlockStatement")) return; + for (let pass = 0; pass < MAX_CHILDREN_ALIAS_PASSES; pass += 1) { + const sizeBeforePass = bindings.childrenNames.size; + walkAst(body, (node) => { + if (isFunctionNode(node)) return false; + if (!isNodeOfType(node, "VariableDeclarator") || !node.init) return undefined; + if (isNodeOfType(node.id, "Identifier")) { + if (isChildrenValueExpression(node.init, bindings)) { + bindings.childrenNames.add(node.id.name); + } + return undefined; + } + if (isNodeOfType(node.id, "ObjectPattern")) { + for (const property of node.id.properties ?? []) { + const localName = resolveChildrenPropertyLocalName(property); + if (localName) bindings.childrenNames.add(localName); + } + } + return undefined; + }); + if (bindings.childrenNames.size === sizeBeforePass) break; } - return "children"; }; // Collects the JSX roots a value can evaluate to, looking through parentheses, @@ -101,30 +181,35 @@ const collectReturnedJsxRoots = (functionNode: FunctionNode): EsTreeNode[] => { return roots; }; -const isChildrenForwardingExpression = ( - expression: EsTreeNode | null | undefined, - childrenLocalName: string, +const isChildrenForwardingJsxChild = (child: EsTreeNode, bindings: ChildrenBindings): boolean => + isNodeOfType(child, "JSXExpressionContainer") && + isChildrenValueExpression(child.expression, bindings); + +// `children={children}` or a props spread (`{...props}` / `{...this.props}`) +// that carries the component's children onto the element. +const isChildrenForwardingAttribute = ( + attribute: EsTreeNode, + bindings: ChildrenBindings, ): boolean => { - if (!expression) return false; - if (isNodeOfType(expression, "Identifier")) return expression.name === childrenLocalName; + if (isNodeOfType(attribute, "JSXSpreadAttribute")) { + const argument = stripParenExpression(attribute.argument); + if (isNodeOfType(argument, "Identifier")) return bindings.propsObjectNames.has(argument.name); + return ( + isNodeOfType(argument, "MemberExpression") && + isNodeOfType(argument.object, "ThisExpression") && + isNodeOfType(argument.property, "Identifier") && + argument.property.name === "props" + ); + } return ( - isNodeOfType(expression, "MemberExpression") && - isNodeOfType(expression.property, "Identifier") && - expression.property.name === "children" + isNodeOfType(attribute, "JSXAttribute") && + isNodeOfType(attribute.name, "JSXIdentifier") && + attribute.name.name === "children" && + isNodeOfType(attribute.value, "JSXExpressionContainer") && + isChildrenValueExpression(attribute.value.expression, bindings) ); }; -const isChildrenForwardingJsxChild = (child: EsTreeNode, childrenLocalName: string): boolean => - isNodeOfType(child, "JSXExpressionContainer") && - isChildrenForwardingExpression(child.expression, childrenLocalName); - -const isChildrenForwardingAttribute = (attribute: EsTreeNode, childrenLocalName: string): boolean => - isNodeOfType(attribute, "JSXAttribute") && - isNodeOfType(attribute.name, "JSXIdentifier") && - attribute.name.name === "children" && - isNodeOfType(attribute.value, "JSXExpressionContainer") && - isChildrenForwardingExpression(attribute.value.expression, childrenLocalName); - // True when somewhere in the returned JSX a text-handling element directly // receives the component's children — `{children}`, // `{props.children}`, or `` — where @@ -132,7 +217,7 @@ const isChildrenForwardingAttribute = (attribute: EsTreeNode, childrenLocalName: // the root element isn't one. const jsxRootForwardsChildrenIntoText = ( jsxRoot: EsTreeNode, - childrenLocalName: string, + bindings: ChildrenBindings, isTextHandlingElement: (elementName: string) => boolean, ): boolean => { let didForwardIntoText = false; @@ -142,16 +227,65 @@ const jsxRootForwardsChildrenIntoText = ( const elementName = resolveJsxElementName(node.openingElement); if (!elementName || !isTextHandlingElement(elementName)) return; didForwardIntoText = - (node.children ?? []).some((child) => - isChildrenForwardingJsxChild(child, childrenLocalName), - ) || + (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings)) || (node.openingElement.attributes ?? []).some((attribute) => - isChildrenForwardingAttribute(attribute, childrenLocalName), + isChildrenForwardingAttribute(attribute, bindings), ); }); return didForwardIntoText; }; +// Resolves a styled-component factory back to its base element name — +// `styled(Text)`…``, `styled.Text`…``, `styled(Text)({})`, and +// `styled(Text).attrs(…)`…`` all resolve to "Text". +const resolveStyledFactoryBaseName = (definitionNode: EsTreeNode): string | null => { + let current: EsTreeNode | null = stripParenExpression(definitionNode); + while (current) { + if (isNodeOfType(current, "TaggedTemplateExpression")) { + current = stripParenExpression(current.tag); + continue; + } + if (isNodeOfType(current, "CallExpression")) { + const callee = stripParenExpression(current.callee); + if (isNodeOfType(callee, "Identifier") && callee.name === "styled") { + const baseArgument = current.arguments?.[0]; + if (!baseArgument) return null; + const base = stripParenExpression(baseArgument); + return isNodeOfType(base, "Identifier") ? base.name : null; + } + current = callee; + continue; + } + if (isNodeOfType(current, "MemberExpression")) { + if ( + isNodeOfType(current.object, "Identifier") && + current.object.name === "styled" && + isNodeOfType(current.property, "Identifier") + ) { + return current.property.name; + } + current = stripParenExpression(current.object); + continue; + } + return null; + } + return null; +}; + +// The render function of a class component (`class Chip extends Component { +// render() { … } }`), or `null` when the node isn't a class or has no render. +const resolveClassRenderFunction = (classNode: EsTreeNode): FunctionNode | null => { + if (!isNodeOfType(classNode, "ClassDeclaration") && !isNodeOfType(classNode, "ClassExpression")) { + return null; + } + for (const member of classNode.body?.body ?? []) { + if (!isNodeOfType(member, "MethodDefinition")) continue; + if (!isNodeOfType(member.key, "Identifier") || member.key.name !== "render") continue; + return member.value && isFunctionNode(member.value) ? member.value : null; + } + return null; +}; + // Records a component declaration when its name is PascalCase and it forwards // its children into a text-handling element — either a returned root that is // itself text-handling (`const Label = (...) => `) or a nested @@ -165,9 +299,17 @@ const recordWrapperFromDeclaration = ( if (!componentName || !isReactComponentName(componentName)) return; if (wrappers.has(componentName)) return; if (!definitionNode) return; - const functionNode = unwrapComponentDefinition(definitionNode); - if (!isFunctionNode(functionNode)) return; - const childrenLocalName = resolveChildrenLocalName(functionNode); + const unwrapped = unwrapComponentDefinition(definitionNode); + const styledBaseName = resolveStyledFactoryBaseName(unwrapped); + if (styledBaseName && isTextHandlingElement(styledBaseName)) { + wrappers.add(componentName); + return; + } + const functionNode = + resolveClassRenderFunction(unwrapped) ?? (isFunctionNode(unwrapped) ? unwrapped : null); + if (!functionNode) return; + const bindings = resolveParamChildrenBindings(functionNode); + collectChildrenAliases(functionNode, bindings); for (const jsxRoot of collectReturnedJsxRoots(functionNode)) { if (isNodeOfType(jsxRoot, "JSXElement")) { const rootName = resolveJsxElementName(jsxRoot.openingElement); @@ -176,7 +318,7 @@ const recordWrapperFromDeclaration = ( return; } } - if (jsxRootForwardsChildrenIntoText(jsxRoot, childrenLocalName, isTextHandlingElement)) { + if (jsxRootForwardsChildrenIntoText(jsxRoot, bindings, isTextHandlingElement)) { wrappers.add(componentName); return; } @@ -206,7 +348,10 @@ export const collectTextWrapperComponents = ( if (isNodeOfType(node, "VariableDeclarator")) { const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; recordWrapperFromDeclaration(componentName, node.init, isTextHandlingElement, wrappers); - } else if (isNodeOfType(node, "FunctionDeclaration")) { + } else if ( + isNodeOfType(node, "FunctionDeclaration") || + isNodeOfType(node, "ClassDeclaration") + ) { const componentName = node.id && isNodeOfType(node.id, "Identifier") ? node.id.name : null; recordWrapperFromDeclaration(componentName, node, isTextHandlingElement, wrappers); } From 67109b46c444862014cc333f7c2046c6dd03781c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:45:09 +0000 Subject: [PATCH 5/6] fix: reject wrappers with unsafe return branches and unrelated children destructures Co-Authored-By: Aiden Bai --- .../rules/react-native/rn-no-raw-text.test.ts | 28 ++++++++ .../utils/collect-text-wrapper-components.ts | 67 ++++++++++++++++--- 2 files changed, 84 insertions(+), 11 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts index a25419151..75c332da0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts @@ -293,6 +293,34 @@ describe("react-native/rn-no-raw-text", () => { `); }); + it("still fires when one branch renders children outside a Text", () => { + expectFail(` + const Chip = ({ children, inline }) => { + if (inline) return {children}; + return ( + + {children} + + ); + }; + const App = () => Test Chip; + `); + }); + + it("still fires when children comes from an unrelated destructure", () => { + expectFail(` + const Chip = ({ item }) => { + const { children } = item; + return ( + + {children} + + ); + }; + const App = () => Test Chip; + `); + }); + it("does not treat a render-prop's Text as the wrapper's own markup", () => { expectFail(` const Box = ({ children, renderLabel }) => ( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts index c8c75be5b..7b67ddd49 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts @@ -65,7 +65,7 @@ const resolveChildrenPropertyLocalName = (property: EsTreeNode): string | null = // object rest that still carries children). const resolveParamChildrenBindings = (functionNode: FunctionNode): ChildrenBindings => { const bindings: ChildrenBindings = { - childrenNames: new Set(["children"]), + childrenNames: new Set(), propsObjectNames: new Set(), }; const firstParam = functionNode.params?.[0]; @@ -108,6 +108,23 @@ const isChildrenValueExpression = ( ); }; +// The props object itself: a param identifier (or qualifying rest), or +// `this.props` inside a class render method. +const isPropsObjectExpression = ( + expression: EsTreeNode | null | undefined, + bindings: ChildrenBindings, +): boolean => { + if (!expression) return false; + const value = stripParenExpression(expression); + if (isNodeOfType(value, "Identifier")) return bindings.propsObjectNames.has(value.name); + return ( + isNodeOfType(value, "MemberExpression") && + isNodeOfType(value.object, "ThisExpression") && + isNodeOfType(value.property, "Identifier") && + value.property.name === "props" + ); +}; + // Folds body-level aliases into the bindings: `const content = children`, // `const { children } = props` (or `this.props`), and re-aliases of aliases // (bounded passes). @@ -125,7 +142,7 @@ const collectChildrenAliases = (functionNode: FunctionNode, bindings: ChildrenBi } return undefined; } - if (isNodeOfType(node.id, "ObjectPattern")) { + if (isNodeOfType(node.id, "ObjectPattern") && isPropsObjectExpression(node.init, bindings)) { for (const property of node.id.properties ?? []) { const localName = resolveChildrenPropertyLocalName(property); if (localName) bindings.childrenNames.add(localName); @@ -192,14 +209,7 @@ const isChildrenForwardingAttribute = ( bindings: ChildrenBindings, ): boolean => { if (isNodeOfType(attribute, "JSXSpreadAttribute")) { - const argument = stripParenExpression(attribute.argument); - if (isNodeOfType(argument, "Identifier")) return bindings.propsObjectNames.has(argument.name); - return ( - isNodeOfType(argument, "MemberExpression") && - isNodeOfType(argument.object, "ThisExpression") && - isNodeOfType(argument.property, "Identifier") && - argument.property.name === "props" - ); + return isPropsObjectExpression(attribute.argument, bindings); } return ( isNodeOfType(attribute, "JSXAttribute") && @@ -235,6 +245,33 @@ const jsxRootForwardsChildrenIntoText = ( return didForwardIntoText; }; +// True when a non-text element directly receives the component's children +// (`{children}`) — a return path like this would render the +// wrapper's raw string children outside any ``, so the component must +// not be treated as a safe wrapper even if another path forwards into text. +const jsxRootRendersChildrenOutsideText = ( + jsxRoot: EsTreeNode, + bindings: ChildrenBindings, + isTextHandlingElement: (elementName: string) => boolean, +): boolean => { + let didRenderOutsideText = false; + walkAst(jsxRoot, (node) => { + if (didRenderOutsideText || isFunctionNode(node)) return false; + if (!isNodeOfType(node, "JSXElement") && !isNodeOfType(node, "JSXFragment")) { + return undefined; + } + if (isNodeOfType(node, "JSXElement")) { + const elementName = resolveJsxElementName(node.openingElement); + if (elementName && isTextHandlingElement(elementName)) return false; + } + didRenderOutsideText = (node.children ?? []).some((child) => + isChildrenForwardingJsxChild(child, bindings), + ); + return undefined; + }); + return didRenderOutsideText; +}; + // Resolves a styled-component factory back to its base element name — // `styled(Text)`…``, `styled.Text`…``, `styled(Text)({})`, and // `styled(Text).attrs(…)`…`` all resolve to "Text". @@ -310,7 +347,15 @@ const recordWrapperFromDeclaration = ( if (!functionNode) return; const bindings = resolveParamChildrenBindings(functionNode); collectChildrenAliases(functionNode, bindings); - for (const jsxRoot of collectReturnedJsxRoots(functionNode)) { + const jsxRoots = collectReturnedJsxRoots(functionNode); + if ( + jsxRoots.some((jsxRoot) => + jsxRootRendersChildrenOutsideText(jsxRoot, bindings, isTextHandlingElement), + ) + ) { + return; + } + for (const jsxRoot of jsxRoots) { if (isNodeOfType(jsxRoot, "JSXElement")) { const rootName = resolveJsxElementName(jsxRoot.openingElement); if (rootName && isTextHandlingElement(rootName)) { From eb7188afa02d4364e87559412c2b48094430da1b Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Fri, 12 Jun 2026 03:50:51 +0000 Subject: [PATCH 6/6] fix: require props-object member access for children forwarding and treat childless props spreads as unsafe Co-Authored-By: Aiden Bai --- .../rules/react-native/rn-no-raw-text.test.ts | 25 ++++++++++++ .../utils/collect-text-wrapper-components.ts | 40 +++++++++++++------ 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts index 75c332da0..3bbeaf5e7 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/rn-no-raw-text.test.ts @@ -321,6 +321,31 @@ describe("react-native/rn-no-raw-text", () => { `); }); + it("still fires when the nested Text receives an unrelated object's children", () => { + expectFail(` + const Chip = ({ item }) => ( + + {item.children} + + ); + const App = () => Test Chip; + `); + }); + + it("still fires when one branch spreads props onto a non-text element", () => { + expectFail(` + const Chip = (props) => { + if (props.inline) return ; + return ( + + {props.children} + + ); + }; + const App = () => Test Chip; + `); + }); + it("does not treat a render-prop's Text as the wrapper's own markup", () => { expectFail(` const Box = ({ children, renderLabel }) => ( diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts index 7b67ddd49..6616e6c20 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/react-native/utils/collect-text-wrapper-components.ts @@ -94,34 +94,35 @@ const resolveParamChildrenBindings = (functionNode: FunctionNode): ChildrenBindi const MAX_CHILDREN_ALIAS_PASSES = 3; -const isChildrenValueExpression = ( +// The props object itself: a param identifier (or qualifying rest), or +// `this.props` inside a class render method. +const isPropsObjectExpression = ( expression: EsTreeNode | null | undefined, bindings: ChildrenBindings, ): boolean => { if (!expression) return false; const value = stripParenExpression(expression); - if (isNodeOfType(value, "Identifier")) return bindings.childrenNames.has(value.name); + if (isNodeOfType(value, "Identifier")) return bindings.propsObjectNames.has(value.name); return ( isNodeOfType(value, "MemberExpression") && + isNodeOfType(value.object, "ThisExpression") && isNodeOfType(value.property, "Identifier") && - value.property.name === "children" + value.property.name === "props" ); }; -// The props object itself: a param identifier (or qualifying rest), or -// `this.props` inside a class render method. -const isPropsObjectExpression = ( +const isChildrenValueExpression = ( expression: EsTreeNode | null | undefined, bindings: ChildrenBindings, ): boolean => { if (!expression) return false; const value = stripParenExpression(expression); - if (isNodeOfType(value, "Identifier")) return bindings.propsObjectNames.has(value.name); + if (isNodeOfType(value, "Identifier")) return bindings.childrenNames.has(value.name); return ( isNodeOfType(value, "MemberExpression") && - isNodeOfType(value.object, "ThisExpression") && isNodeOfType(value.property, "Identifier") && - value.property.name === "props" + value.property.name === "children" && + isPropsObjectExpression(value.object, bindings) ); }; @@ -245,10 +246,15 @@ const jsxRootForwardsChildrenIntoText = ( return didForwardIntoText; }; +const isMeaningfulJsxChild = (child: EsTreeNode): boolean => + !isNodeOfType(child, "JSXText") || Boolean(child.value?.trim()); + // True when a non-text element directly receives the component's children -// (`{children}`) — a return path like this would render the -// wrapper's raw string children outside any ``, so the component must -// not be treated as a safe wrapper even if another path forwards into text. +// (`{children}`, or a children-carrying attribute like +// `` with no JSX children to override it) — a return path +// like this would render the wrapper's raw string children outside any +// ``, so the component must not be treated as a safe wrapper even if +// another path forwards into text. const jsxRootRendersChildrenOutsideText = ( jsxRoot: EsTreeNode, bindings: ChildrenBindings, @@ -263,6 +269,16 @@ const jsxRootRendersChildrenOutsideText = ( if (isNodeOfType(node, "JSXElement")) { const elementName = resolveJsxElementName(node.openingElement); if (elementName && isTextHandlingElement(elementName)) return false; + const hasJsxChildren = (node.children ?? []).some(isMeaningfulJsxChild); + if ( + !hasJsxChildren && + (node.openingElement.attributes ?? []).some((attribute) => + isChildrenForwardingAttribute(attribute, bindings), + ) + ) { + didRenderOutsideText = true; + return undefined; + } } didRenderOutsideText = (node.children ?? []).some((child) => isChildrenForwardingJsxChild(child, bindings),