Skip to content

fix(react): a type argument or a name the component binds itself renders no JSX child - #2442

Merged
colbymchenry merged 1 commit into
mainfrom
claude/magical-burnell-27ab13
Oct 7, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/magical-burnell-27ab13

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

The jsx-render synthesizer (reactJsxChildEdges → jsxChild in src/resolution/callback-synthesizer.ts) collects tag names with JSX_TAG_RE over each parent's source and links the parent to a component, function or class of that name. Two kinds of name it reads that way are not a child the parent renders.

  1. A name written only as a type argument or a type parameter.
    • outline's <PaginatedList<Document> items={…} /> writes the Document model right after PaginatedList, and useState<User>() writes User right after useState. outline's document lists, settings tables and sidebars were linked to its model classes: PaginatedDocumentList → class Document, GroupMembersPage → class User, Export → class FileOperation.
    • excalidraw's createUndoAction was linked to class HistoryChangedEvent.
    • bulletproof-react's generic table, export const Table = <Entry extends BaseEntity>({ … }) =>, was linked in each of its three apps to the Next.js app's Entry page component.
  2. A name the parent binds itself. A local or a parameter has no node, so the tag fell through to any same-named component in the repository.
    • outline's menus pick a Radix part with const Content = variant === "dropdown" ? DropdownMenuPrimitive.SubContent : ContextMenuPrimitive.SubContent, and rendered the command bar's styled Content instead.
    • MenuGroup's local Group was linked to the Group model class.
    • Object.values(this.widgets).map((Widget, index) => <Widget …/>) was linked to shared/editor/components/Widget.tsx.

Fix

Tag position, for every name (opensTag).

  • A <Name written right after an identifier character opens a type argument list, not a tag. return<Foo/> is the exception.
  • <Name extends … opens a type parameter list.
  • A name the parent never writes in tag position renders nothing.

This is the asTag rule that #2420 applies only to the values a file declares, extended to every name.

A name the parent binds itself. By the line of its first tag, the parent may declare the name with a plain const/let/var, or name it in a parameter list. That is name-matcher's jsCodeBindsName, the check jsFunctionLocalScope already applies to bare calls; it is now exported and takes a file and a line instead of a reference. When the parent binds the name, the tag is that local. It then renders only a component the parent declares inside its own lines, like const Row = ({ row }) => <tr /> in a table's body, and otherwise nothing (declaredInside).

  • The check reads the code only through the name's first tag. So a tag written above the local still links its import: an imported <Icon />, then const Icon = action.icon in a later callback.
  • It runs only for names the parent also writes outside a tag (writtenBare). That cut the checks on outline from 2,930 to 61, with the same edges.
  • Destructured names are not counted, just as jsCodeBindsName doesn't count them for calls. const { Sidebar, Footer, … } = excalidrawLib in excalidraw's example app keeps linking by name; see Not changed.

Validation

main at ed199e60 against this branch. Every repo was indexed fresh (init -y) and compared with scripts/dump-graph.mjs.

repo jsx-render edges what goes
outline/outline −45 30 type arguments; 15 locals (14 const, 1 parameter)
excalidraw/excalidraw −2 createUndoAction → HistoryChangedEvent (type argument); TTDDialogInput → CodeMirrorEditor (a local, right by luck)
alan2207/bulletproof-react −3 Table → Entry in each of the three apps (type parameter)
codedthemes/mantis 0 byte-identical
bradtraversy/proshop_mern (control) 0 byte-identical
leerob/next-saas-starter (control) 0 byte-identical
t3-oss/create-t3-turbo (control) 0 byte-identical
refinedev/refine examples −14 4 type arguments; 10 locals (const Link = useLink() ×7, const Wrapper = withBrowser ? Browser : Fragment ×3)
crwn-clothing −1 const CustomButton = getButton(buttonType)
remix-run/react-router 6.29 −1 testPartialHydration(…, RouterProvider)'s parameter
binner, vispana, hathor-explorer 0 byte-identical; their locals are components declared inside the parent, which keep their edges

Every removed edge, checked against the TypeScript compiler. An independent oracle parses each parent's file with the TypeScript API. For each removed edge it finds the parent's JSX elements with that tag name and resolves the name with the checker.

  • 38 removed edges have no JSX element of that name in the parent at all; the name appears only in type argument or type parameter lists.
  • 28 have every tag of the name bound to a function's local: 25 to a const, 3 to a parameter.
  • I read the source behind each local. 26 of the 28 targets were wrong. Examples are the Radix parts, useLink(), getButton(…), a plugin's usePluginValue(Hook.Icon, id) and viewsLoadedOnMount.current ? Fragment : Fade.
  • 2 were right by luck:
    • excalidraw's const CodeMirrorEditor = editorState.component holds the component that import("./CodeMirrorEditor") loads.
    • react-router's RouterProvider parameter is filled by its callers with react-router-dom's RouterProvider, and once with react-router's own.

The rule that a tag never follows an identifier character holds in practice. The TypeScript AST of nine React repos (outline, excalidraw, bulletproof-react, mantis, matx, minimal-kit, proshop, next-saas, t3-turbo) has 11,400 PascalCase JSX tags, and none sits right after an identifier character. About 1,500 of the pattern's matches do, and none of them is a tag.

Cost. I timed the jsx pass alone on one database, 7 interleaved rounds, measuring process CPU. The box was at 100% CPU, so single rounds spread about ±0.25 s.

  • outline: 1.27 s → 1.41 s median.
  • excalidraw: 0.34 s → 0.36 s median.

Not changed

  • Destructured names still link by name, as for a destructured function call. I surveyed these repos plus 22 more that were already indexed. Destructured tags make 24 edges there:

    • 9 are right by luck: excalidraw's example app, const { TTDDialog, WelcomeScreen, … } = excalidrawLib.
    • 15 are wrong. Most are refine's Layout({ Sider, Header, Footer, OffLayoutArea }) props, which land on other example apps' components. In excalidraw, Sidebar goes to the example's own ExampleSidebar, and Footer goes to the internal Footer rather than the FooterCenter the library exports under that name.

    That choice is left open; a follow-up task has the data.

  • A generic component's own tag (<PaginatedList<Document>) is still not read: the pattern needs whitespace, / or > right after the name. A trial that read it added 10 correct edges on outline and 2 on excalidraw. It also added 3 wrong ones on refine: antd's <Table<IPost> and <Form<…>, and react95's <Select<number>, landed by name on other examples' components. So it waits until package-imported tags stop linking by name.

  • Tags in comments and strings, such as react-router's JSDoc and invariant messages, {/* <Progress /> */}, or outline's OpenSearch XML template, account for about 30 edges across these repos. That is a follow-up too.

Overlap

Tests

__tests__/jsx-child-disambiguation.test.ts gains four cases, each with a decoy that name matching would pick:

  • names only in type argument or parameter lists: <PaginatedList<Document>, useState<User | null>, useRef<Badge> beside a real <Badge /> tag, and bulletproof's <Entry extends BaseEntity>;
  • a const and a parameter the parent binds, one of them shadowing a value the file declares at module level;
  • a component the parent declares inside itself, and an imported tag written above a same-named local;
  • excalidraw's destructured library component, which keeps linking by name.

The first three fail on main. tsc --noEmit is clean.

The full suite (Windows, box at 100% CPU) had 4 failing tests out of 6,619. On a serial rerun:

🤖 Generated with Claude Code

…ers no JSX child

The jsx-render pass reads tag names off a parent's source with a pattern,
and two kinds of name it read that way linked a same-named class or
component elsewhere:

- A name written only in a type argument or type parameter list:
  outline's `<PaginatedList<Document> …>` and `useState<User>()`, or
  bulletproof-react's generic `<Entry extends BaseEntity>(…) =>`. A `<Name`
  right after an identifier opens a type argument list (the rule #2420
  applies to the values a file declares, here for every name), and a
  `<Name extends …` opens a type parameter list.
- A name the parent binds itself by its first tag, a `const`/`let`/`var` or
  a parameter (name-matcher's `jsCodeBindsName`, now exported): outline's
  `const Content = variant === "dropdown" ? DropdownMenu.SubContent :
  ContextMenu.SubContent`, `(Widget, index) => <Widget />`. Such a tag
  renders only a component the parent declares inside itself, else
  nothing. The check runs only for names the parent also writes outside a
  tag. Destructured names still link by name, as for a destructured call.

Fresh before/after indexes on 13 repos (main ed199e6): 66 jsx-render
edges removed and none added; no other edge, node, ref or file changes.
Every removal checks out against the TypeScript compiler (38 names with
no JSX tag in the parent, 28 tags bound to a local); 64 of the 66 targets
were wrong, 2 right by luck. mantis, proshop_mern, next-saas-starter and
create-t3-turbo are byte-identical, and Screens is unchanged.

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