fix(react): a type argument or a name the component binds itself renders no JSX child
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.
- A name written only as a type argument or a type parameter.
- outline's
<PaginatedList<Document> items={…} />writes theDocumentmodel right afterPaginatedList, anduseState<User>()writesUserright afteruseState. 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
createUndoActionwas linked toclass 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'sEntrypage component.
- outline's
- 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 styledContentinstead. MenuGroup's localGroupwas linked to theGroupmodel class.Object.values(this.widgets).map((Widget, index) => <Widget …/>)was linked toshared/editor/components/Widget.tsx.
- outline's menus pick a Radix part with
Fix
Tag position, for every name (opensTag).
- A
<Namewritten 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 />, thenconst Icon = action.iconin 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
jsCodeBindsNamedoesn't count them for calls.const { Sidebar, Footer, … } = excalidrawLibin 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 |
- 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'susePluginValue(Hook.Icon, id)andviewsLoadedOnMount.current ? Fragment : Fade. - 2 were right by luck:
- excalidraw's
const CodeMirrorEditor = editorState.componentholds the component thatimport("./CodeMirrorEditor")loads. - react-router's
RouterProviderparameter is filled by its callers with react-router-dom'sRouterProvider, and once with react-router's own.
- excalidraw's
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,Sidebargoes to the example's ownExampleSidebar, andFootergoes to the internalFooterrather than theFooterCenterthe library exports under that name.
That choice is left open; a follow-up task has the data.
- 9 are right by luck: excalidraw's example app,
-
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
asTagrule for the values a file declares, e.g.useForm<Schema>()underconst Schema = z.object(…). This PR applies it to every name. Whichever lands second drops #2420'sasTagparameter and itsMap<name, boolean>, and keeps #2420's own-value lookup (ownValueOf) in thejsxChildcall, ahead of this PR's local check. Both orders were validated, onmainand on #2420's tip merged with main, and they remove the same edges. -
#2451 (open) makes
jsxChildresolve a tag through the file's import first, called through a per-filechildOfmemo. Its loop keeps aMap<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
languageargument andchildOfmemo; - drop #2451's
asTagmap 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.
- keep this PR's
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
constand 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(bothEBUSYon teardown) andmcp-writer-lockpass.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