Skip to content

fix(react): a tag written in a comment or a string renders no JSX child - #2564

Merged
colbymchenry merged 3 commits into
mainfrom
claude/sweet-jemison-7b2c50
Oct 15, 2026
Merged

colbymchenry merged 3 commits into
mainfrom
claude/sweet-jemison-7b2c50

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

The jsx-render synthesizer (reactJsxChildEdges) read tag names off each parent function's raw lines with JSX_TAG_RE, so text that only mentions a tag rendered it:

  • react-router's hooks rendered the Router, Routes and Route their JSDoc, line comments and invariant messages name ("You cannot render a <Router> inside another <Router>");
  • shadcn's useCarousel rendered the Carousel its error names ("useCarousel must be used within a <Carousel />");
  • layouts rendered markup commented out of them ({/* <Progress /> */});
  • outline's opensearchResponse, in a .ts file, rendered the <Image> and <Description> of its XML template.

Main still scanned raw text: no merged or open PR covers this. #2442 and #2476 handle type arguments, and #2475, #2420 and #2563 handle what a tag binds to.

What changed

  • src/resolution/jsx-code.ts (new): jsxCode(source) returns the file with comments blanked, along with the contents of string, template and regex literals and of JSX attribute strings. Offsets and line breaks are kept, so the scan's line counting is unchanged.
    • It follows JSX elements, so a quote, slash or backtick inside JSX text stays text (<p>Don't have an account? <Link to="/signup">, https://…).
    • A < that opens a type parameter list where an expression can start (<T,>(x: T) =>, type Fn = <T>(x: T) => T, { <T>(x: T): T }) is read again as an operator once it fails to read as an element.
    • When a file doesn't read as one program, it returns null and the pass scans the raw text as before. That covers a string still open at the end of its line, braces that don't balance, a closing tag naming another element, a </ or /> among code, or the re-read or step budget running out.
  • reactJsxChildEdges:
    • It scans jsxCode(content) ?? content, still split once per file and memoized per span.
    • It skips parents in TypeScript .ts files, which can't hold JSX. There a <Name> is a type assertion or text.
  • V8 recompiles: the lexer's state lives in a class whose methods every file shares, not in closures made per call. The closure version made V8 throw away and recompile the function's optimized code for file after file: 15 compiles of 40–90 ms each on excalidraw. Output is identical between the two versions on all 9,482 candidate files of the validation repos.

Validation

Occurrence check against TypeScript's parser

For each <Name the pass's own pattern (main's, with #2476's lookahead) finds in the raw source, does TypeScript parse a JSX element whose < is there, and does the pattern still match over jsxCode's output?

The check ran on 36 React repos: 7,649 JSX-capable files holding </ or />, with 62,377 matches.

  • 0 real JSX tags blanked, and 0 files given up on.
  • 614 matches with no JSX element at that spot were removed: comments, JSDoc, strings, templates and attribute values.
  • 70 non-JSX matches were kept. All are type parameter or argument shapes (<T extends …>, styled(X)<Props>, <Item = MenuItem,>), which aren't comments or strings and stay with opensTag.
  • .ts files: 322 with </ or />, 0 given up on.
  • JS-heavy and non-React repos: 1,248 files, 0 lost, 0 given up on. These include go-ethereum's 980 KB graphiql.min.js, qwik, hugo, bootstrap 4 and several RN/Expo libraries.

Graph A/B

Fresh indexes per arm, compared through scripts/dump-graph.mjs, main 3f714bcc vs this branch:

repo jsx-render removed added
remix-run/react-router (v6.29) 19 0
refine-examples 5 0
cboard 3 0
excalidraw 2 0
outline 2 0
bulletproof-react 1 0
next-saas-starter 1 0
binner 1 0
volun-mern 1 0
mantis, proshop_mern, create-t3-turbo 0 0 (byte-identical)

Nodes, refs, files and every other edge are unchanged. A TypeScript-checker oracle graded all 36 removed edges "no JSX tag of that name in the parent (comment/string)". The same 36 edges came out on three bases (883504d2, cd97bb37 and 3f714bcc).

Examples of removed edges:

  • react-router:
    • useRoutesImpl → Route/Routes/Router/Outlet/Blog (comment examples, template invariant messages)
    • createRouter → Form/ScrollRestoration (JSDoc in router.ts)
    • getChildren → Route/Routes
  • refine:
    • useCarousel → Carousel and useChart → ChartContainer, from error strings
    • Shipping/PaymentContainer → Radio
    • AuthenticatedLayout → ThemedLayout
  • excalidraw:
    • ToggleTheme → dev-docs' Excalidraw, from an error string holding backticks
    • getLatestValue, in a .ts file
  • commented-out JSX:
    • bulletproof Layout → Progress
    • next-saas TeamMembers → AvatarImage
    • binner Inventory → MatchingPartsMemoized
    • cboard EditorSidebar → three New…Buttons
    • volun-mern SearchedEventsList → EventExcerpt

Tests

  • New in jsx-child-disambiguation.test.ts:
    • "a tag written in a comment or a string", with three integration cases:
      • react-router, shadcn, excalidraw, bulletproof and binner shapes;
      • outline's .ts template, plus a .ts type assertion in a file the lexer gives up on;
      • a recall guard: tags after an apostrophe, a URL or a backtick in JSX text, and after a generic arrow, a regex holding a backtick and a nested template.
    • jsxCode unit cases.
  • All three integration cases fail on main and pass here. Removing the .ts gate fails the .ts case.
  • The 11 JSX-related test files: 100/100 pass.
  • Full suite: 7,119 tests. Six files failed under load: Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #1820, the MCP daemon, writer-lock and status-freshness tests, the sync watch lock and the viewer steps hook. All six passed when re-run serially (142/142).
  • tsc and npm run build pass.

Cost

  • jsxCode takes about 16 ms on go-ethereum's 980 KB graphiql.min.js (the same with --no-regexp-optimization) and about 5–7 ms on excalidraw's 381 KB App.tsx. The per-span memo is untouched, and jsx-render-work.test.ts passes.
  • jsxEdges pass CPU, median of 3 interleaved runs: excalidraw 501 → 656 ms, outline 1376 → 1360 ms (within noise).

Not in this PR

  • importedTagModules, sync's list of the modules a file's tags are read through, still scans raw text. A tag in a comment there only adds a spare read dependency, never an edge.
  • Type parameter shapes that aren't comments or strings: 70 occurrences in 36 repos, with no edge seen.
  • Vue, Svelte and Astro script nodes carry their host language, so this pass never scans them.
  • A regex right after if (…) reads as a division to the lexer, so that file falls back to the raw scan. That happened in 0 of 9,482 files.

Overlap

🤖 Generated with Claude Code

colbymchenry and others added 3 commits October 14, 2026 19:04
The jsx-render pass read tag names off each parent's raw lines, so text
that only mentions a tag rendered it: react-router's hooks rendered the
`Router`, `Routes` and `Route` their JSDoc, comments and invariant
messages name ("You cannot render a <Router> inside another <Router>"),
shadcn's `useCarousel` the `Carousel` its error names, and layouts the
markup commented out of them (`{/* <Progress /> */}`).

The pass now scans a code-only view of each file (`jsxCode`, new in
src/resolution/jsx-code.ts): comments and the contents of string,
template and regex literals and of JSX attribute strings are blanked,
offsets and line breaks kept. The lexer follows JSX elements, so a quote,
slash or backtick in JSX text (`Don't`, `https://…`) stays text, and it
re-reads a `<` that opens a type parameter list (`<T,>(x: T) =>`) as an
operator. A file it can't read as one program (an unbalanced brace, a
string open at a line's end, a mismatched closing tag) is scanned as it
is, as before. TypeScript `.ts` files are skipped outright: they can't
hold JSX.

Checked against TypeScript's parser on 36 React repos (7,649 files,
61,668 `<Name` matches): no real tag blanked, no file given up on, 612
non-JSX matches removed. Graph A/B on 12 repos: -36 jsx-render edges,
+0, nothing else changed; the TypeScript oracle grades all 36 as tags in
comments or strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…X child

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
jsxCode made its helpers as closures on every call. Each file's closures
were new call targets to V8's optimizer, which threw the function's
optimized code away and compiled it again for file after file, at 40 to
90 ms a time: 15 times over excalidraw, about 0.9 s of CPU.

The state now lives in a class whose methods every file shares, the pure
helpers are module functions, and the main loop is split by mode. Output
is identical to the closure version on all 9,482 candidate files of the
68 validation repos; excalidraw's jsxEdges pass is back to within the
lexer's own ~0.1 s of main.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant