Repository navigation
fix(react): a type argument or a name the component binds itself renders no JSX child - #2442
Merged
Merged
Conversation
…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>
This was referenced Oct 7, 2026
Merged
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
The
jsx-rendersynthesizer (reactJsxChildEdges→jsxChildinsrc/resolution/callback-synthesizer.ts) collects tag names withJSX_TAG_REover 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.<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.createUndoActionwas linked toclass HistoryChangedEvent.export const Table = <Entry extends BaseEntity>({ … }) =>, was linked in each of its three apps to the Next.js app'sEntrypage component.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.Fix
Tag position, for every name (
opensTag).<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.This is the
asTagrule 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'sjsCodeBindsName, the checkjsFunctionLocalScopealready 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, likeconst Row = ({ row }) => <tr />in a table's body, and otherwise nothing (declaredInside).<Icon />, thenconst Icon = action.iconin a later callback.writtenBare). That cut the checks on outline from 2,930 to 61, with the same edges.jsCodeBindsNamedoesn't count them for calls.const { Sidebar, Footer, … } = excalidrawLibin excalidraw's example app keeps linking by name; see Not changed.Validation
mainated199e60against this branch. Every repo was indexed fresh (init -y) and compared withscripts/dump-graph.mjs.const, 1 parameter)createUndoAction → HistoryChangedEvent(type argument);TTDDialogInput → CodeMirrorEditor(a local, right by luck)Table → Entryin each of the three apps (type parameter)const Link = useLink()×7,const Wrapper = withBrowser ? Browser : Fragment×3)const CustomButton = getButton(buttonType)testPartialHydration(…, RouterProvider)'s parameterbuildScreens) is unchanged on outline, excalidraw, bulletproof-react and mantis.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.
const, 3 to a parameter.useLink(),getButton(…), a plugin'susePluginValue(Hook.Icon, id)andviewsLoadedOnMount.current ? Fragment : Fade.const CodeMirrorEditor = editorState.componentholds the component thatimport("./CodeMirrorEditor")loads.RouterProviderparameter is filled by its callers with react-router-dom'sRouterProvider, 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.
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:
const { TTDDialog, WelcomeScreen, … } = excalidrawLib.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.
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
fix(react): a component a file declares itself as a value is what its JSX renders #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 fix(react): a component a file declares itself as a value is what its JSX renders #2420'sasTagparameter and itsMap<name, boolean>, and keeps fix(react): a component a file declares itself as a value is what its JSX renders #2420's own-value lookup (ownValueOf) in thejsxChildcall, ahead of this PR's local check. Both orders were validated, onmainand on fix(react): a component a file declares itself as a value is what its JSX renders #2420's tip merged with main, and they remove the same edges.fix(react): a JSX tag renders what the file imports under that name #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:{ tags, bare }scan and its local check after the child lookup;languageargument andchildOfmemo;asTagmap and gate, which this PR's skip covers.fix(react): a JSX tag renders what the file imports under that name #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.tsgains four cases, each with a decoy that name matching would pick:<PaginatedList<Document>,useState<User | null>,useRef<Badge>beside a real<Badge />tag, and bulletproof's<Entry extends BaseEntity>;constand a parameter the parent binds, one of them shadowing a value the file declares at module level;The first three fail on
main.tsc --noEmitis 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 Caller/impact graph misses methods passed as first-class references (callbacks), e.g. executor.submit(obj.method, …) #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