Repository navigation
fix(react): a component a file declares itself as a value is what its JSX renders - #2420
Open
colbymchenry wants to merge 3 commits into
Open
colbymchenry wants to merge 3 commits into
colbymchenry wants to merge 3 commits into
Conversation
… JSX renders
A JSX file that declares a component as a value - a lazily loaded page
(`const RegisterPage = Loadable(lazy(() => import('pages/auth/Register')))`,
`dynamic(async () => (await import('../wrapper')).default)`), a wrapped one
(`observer(function Settings() {...})`, `observer(TableViewInner)`) or an
alias or styled component (`const DialogPortal = DialogPrimitive.Portal`) -
lost the name to a same-named component anywhere in the repository: the
lookups behind component references (`resolveComponent`) and JSX children
(`jsxChild`) keep only component, function and class nodes, and a value is a
`constant`. codedthemes' mantis bound its vite app's routes to the Next.js
app's `RegisterPage`; #2400 fixed that for route references only.
`declaredComponent` is now the one rule for every React component name: a
name the file declares as a module-level value is that declaration - the
component of the module its own loader imports, else the function it wraps,
else the declaration itself. It applies to every Pattern 1 reference, a
route layout the file declares, and every `jsx-render` child, where a
`<Name` written right after an identifier is a type argument
(`useForm<Schema>()`), not a tag.
The lazy-module lookup #2400 added now reads the loader's chosen export
(`.then((m) => ({ default: m.Chart }))`, `(await import(...)).default`),
follows a module that only forwards its component (`import Login from
'./Login'; export default Login`, `export { default } from`, `export *`) and
a wrapped default export (`export default observer(Login)`), resolves with
the importing file's own extensions before TypeScript's (a `.jsx` file's
`./MainLayout` is `MainLayout/index.jsx`), and ignores an import inside a
function body the value wraps.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 7, 2026
colbymchenry
added a commit
that referenced
this pull request
Oct 7, 2026
…ers no JSX child (#2442) 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 branch has not been deployed
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
A JSX file that declares a component as a value lost the name to a same-named component anywhere in the repository. Examples of such values:
const RegisterPage = Loadable(lazy(() => import('pages/auth/Register'))), ordynamic(async () => (await import('../excalidrawWrapper')).default)const Settings = observer(function Settings() {…}), orobserver(TableViewInner)const DialogPortal = DialogPrimitive.Portal, or astyled.div<Props>that extraction leaves aconstantThe lookups behind React component references (
resolveComponent) and JSX children (jsxChild) keep onlycomponent,functionandclassnodes, and these areconstantnodes. So the value was skipped and a lone component of that name elsewhere won. On codedthemes/mantis-free-react-admin-template the vite app's routes bound to the Next.js app'sRegisterPage. #2400 fixed that for references from route nodes only.Where
<X />actually goes. A JSX tag is not an extracted reference; it never reachesreactResolver.resolve. Plain JSX usages becomejsx-renderedges in the synthesizer (reactJsxChildEdges→jsxChild), which had the same blind spot. In excalidraw,examples/with-nextjs' pages router renders<Excalidraw />fromconst Excalidraw = dynamic(async () => (await import("../excalidrawWrapper")).default, …), and that edge went to the docs site'sExcalidrawindev-docs/. Pattern 1 sees the remaining PascalCase references from.tsx/.jsxfiles: routeelements (#2400's case), Next.js and Expo pages, and incidental ones such as a TSX type. Withconst User = z.object(…); type User = …,(u: User)bound to another app'sUsercomponent.Fix
One lexical rule for every React component name.
declaredComponentinframeworks/react.ts, which #2400 used for route references only, is now exported. A name the file declares as a module-level value is that declaration, never another file's component. In order, the name binds to:It now applies to:
.tsx/.jsxfile (the route-node condition is gone);layout:references cannot fall back to name matching, so the declaration binds there directly);jsx-renderchild. A<Namewritten right after an identifier is a type argument (useForm<Schema>()underconst Schema = z.object(…)), not a tag, so it renders nothing. The lookup is memoized per file and name.What a value renders:
lazy(() => import('./x')), wrapped or not (Loadable(lazy(…)),loadable(…),dynamic(…, { ssr: false }),Loadable({ loader: () => import(…) })), anddynamic(async () => (await import('./x')).default). The export the loader picks is honored:.then((m) => ({ default: m.Chart })),.then((m) => m.Chart),.then(({ Chart }) => …). The loader must be the initializer's own. An import inside a function body the value wraps (excalidraw'swithInternalFallback("TTDDialogBase", (…) => { … })) loads nothing the value renders. Comments are stripped first, soimport(/* webpackChunkName */ …)reads too.observer(function Profile_() {…})), or a same-file PascalCase component handed to a wrapper call (observer(TableViewInner) as typeof TableViewInner). A helper inside a wrapped arrow (const load = () => import(…)) never counts, and neither does a lowercase argument (createStore(reducer)).The lazy-module lookup #2400 added (
lazyRouteComponent) now:import Login from './Login'; export default Loginin anindex,export { default } from,export *for a named pick;export default observer(Login),connect(…)(Login),React.memo(Login));.jsxfile's./layout/MainLayoutisMainLayout/index.jsx, which the TypeScript list doesn't try.These also improve #2400's own lazy routes and layouts, which use the same lookup.
Validation
mainat31c3328dagainst this branch, every repo indexed fresh (init -y) and compared withscripts/dump-graph.mjs.examples/with-nextjspages router:<Excalidraw/>→ExcalidrawWrapper(wasdev-docs'Excalidraw); app router:<ExcalidrawWithClientOnly/>→ExcalidrawWrapper(new);TTDDialog→ itsTTDDialogBase(new)<DialogPortal>/<DrawerPortal>→ the same file'sDialogPrimitive.Portalaliases, in each of the three apps<LazyChart>→ itslazy(() => import('react-apexcharts')…)declaration (a package, so the declaration)<ThemeContext value>→ the same file'screateContextobserver, lazily loaded screens,styled.div<Props>)outline:
jsx-renderedges move off another file's same-named component.styled.div<Props>consts:FloatingToolbar's<Wrapper>and<Background>wereDialogTitle.tsx's andModelSelectionToolbar.tsx's.Table's<TableView>was the ProseMirrorTableViewclass inshared/editor; it is nowTableViewInner.ShareButton's<SharePopover>wasSharing/Collection/SharePopover; it is nowSharing/Document/SharePopover./,/create, two/s/:shareIdroutes) move from the lazy declaration, which name matching had bound, to the screen its module exports. That meansSharedScenethroughexport default observer(SharedScene), andLoginthroughscenes/Login/index.ts'simport Login from "./Login"; export default Login;.jsx-renderedges:observer(function HeadingPrefixMenuItem_…)and the like), which had no edge because the inner function's name differs;DocumentSidebarContent→Comments,History).Screens (
buildScreens): excalidraw, bulletproof-react and mantis are unchanged. On outline:/and/s/:shareIdare now named bySharedScene, not the lazy declaration'sShared.<Link to="/">("Already have an account? Go to login") is now drawn from the two routes that render the page (/,/create), giving 6 → 7 links. Before, it reached the map only through/oauth/authorize, which embeds<Login />. A routed component's navigation belongs to its own screens.The A/B was run twice, on #2400's tip and on
mainafter #2400 merged, and the changed edges are identical line for line.mainhas since gained #2410 (Go imports) and #2411 (sync tests), neither of which touches JavaScript resolution.Precision, edge by edge. A script checks every added edge against the source: 60 of 60 check out. For a
jsx-renderedge, some<Namein the parent sits in tag position. For each edge, the target is one of:import('…')names.I also read the source behind each kind of change. Examples: outline's
FloatingToolbar,UserHoverCard,ShareButton,useDocumentSidebar,ImageUpload,Template,Table,Lightboxandroutes/index.tsx; excalidraw's two Next.js pages andTTDDialog; bulletproof's dialog and drawer; minimal-kit's chart; t3's theme.Tests
__tests__/jsx-child-disambiguation.test.tsgains a block of seven cases, each with a decoy component elsewhere:dynamic(async () => (await import(…)).default), plus a.thennamed export through anexport *barrel;indexforwarding ofexport default observer(Login);observer(function …)and anobserver(Inner) as typeof Inner;__tests__/react-router.test.tsgains a layout loaded lazily fromMainLayout/index.jsxwith a same-named layout in another app.<Login>to the decoy,useForm<Schema>()"renders" the decoySchema, and the layout to the other app'sMainLayout.tscis clean. The 9 related files (React Router, JSX children, HOC components, frameworks, resolution, same-name disambiguation) pass 488 of 488 when run serially. The full suite on this shared Windows box: 6049 passed and 26 failed, all in the git, sync, daemon and WAL files that time out here under load (24 timeouts, oneEBUSYteardown and onewal-deferralcount). Run again serially with long timeouts, those 14 files pass 952 of 952.Known limits
const Icon = item.icon; <Icon />,({ icon: Icon })) has no node, so the rule doesn't see it. The old lookup still applies to it.import(`./pages/${name}`)), picks an export through an option (outline'screateLazyComponent(…, { exportName }), once), or sits behind an import only the app's own build resolves binds to the declaration, not a guessed page.jsx-renderedges are recomputed on every index and sync.Overlap
#2424 (open) rewrites the same tag-scan block in
reactJsxChildEdges. It memoizes each parent's scanned tag names by line span (tagsBySpan, aMap<string, Set<string>>) and splits the file once, because every function of a minified bundle spans its one line. Whichever of the two lands second resolves the textual conflict by keeping #2424's span memo and storing this PR'sMap<name, asTag>in it in place of theSet.asTagreads only the span's own text, so the memo stays graph-neutral. This PR leavesJSX_TAG_RE's source unchanged, which #2424'sjsx-render-work.test.tscounts scans by.Docs
Trap 14 and the React Router row in
docs/design/framework-coverage.md, and a CHANGELOG bullet under[Unreleased]→### Fixes.🤖 Generated with Claude Code