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

Merged
CO
8d ago
+301-24

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.

repojsx-render edgeswhat goes
outline/outline−4530 type arguments; 15 locals (14 const, 1 parameter)
excalidraw/excalidraw−2createUndoAction → HistoryChangedEvent (type argument); TTDDialogInput → CodeMirrorEditor (a local, right by luck)
alan2207/bulletproof-react−3Table → Entry in each of the three apps (type parameter)
codedthemes/mantis0byte-identical
bradtraversy/proshop_mern (control)0byte-identical
leerob/next-saas-starter (control)0byte-identical
t3-oss/create-t3-turbo (control)0byte-identical
refinedev/refine examples−144 type arguments; 10 locals (const Link = useLink() ×7, const Wrapper = withBrowser ? Browser : Fragment ×3)
crwn-clothing−1const CustomButton = getButton(buttonType)
remix-run/react-router 6.29−1testPartialHydration(…, RouterProvider)'s parameter
binner, vispana, hathor-explorer0byte-identical; their locals are components declared inside the parent, which keep their edges
  • No edge is added. Nothing outside jsx-render changes: nodes, unresolved refs, files and every other edge kind are byte-identical in all 13 repos.
  • Screens (buildScreens) is unchanged on outline, excalidraw, bulletproof-react and mantis.
  • The same A/B on #2420's tip merged with main (d0033b9e) removed exactly the same 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

  • #2420 (open) adds the asTag rule for the values a file declares, e.g. useForm<Schema>() under const Schema = z.object(…). This PR applies it to every name. Whichever lands second drops #2420's asTag parameter and its Map<name, boolean>, and keeps #2420's own-value lookup (ownValueOf) in the jsxChild call, ahead of this PR's local check. Both orders were validated, on main and on #2420's tip merged with main, and they remove the same edges.

  • #2451 (open) makes jsxChild resolve a tag through the file's import first, called through a per-file childOf memo. Its loop keeps a Map<name, asTag> with a narrower gate, for a renamed import written only as a type argument. The two sessions agreed on the merge, and both PRs record it:

    • keep this PR's { tags, bare } scan and its local check after the child lookup;
    • keep #2451's language argument and childOf memo;
    • drop #2451's asTag map and gate, which this PR's skip covers.

    #2451 also changes how default imports resolve. If it lands first, this PR's A/B gets re-run on the new main and the numbers above updated.

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:

  • daemon-pid-reuse, mcp-status-freshness (both EBUSY on teardown) and mcp-writer-lock pass.
  • function-ref's #1820 case runs past its own 60 s in-file limit under load. It passes alone in 48 s once that limit is raised.

🤖 Generated with Claude Code