Skip to content

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
mainfrom
claude/interesting-hermann-dcee7f
Open

colbymchenry wants to merge 3 commits into
mainfrom
claude/interesting-hermann-dcee7f

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

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:

  • a lazily loaded page: const RegisterPage = Loadable(lazy(() => import('pages/auth/Register'))), or dynamic(async () => (await import('../excalidrawWrapper')).default)
  • a wrapped component: const Settings = observer(function Settings() {…}), or observer(TableViewInner)
  • an alias or styled component: const DialogPortal = DialogPrimitive.Portal, or a styled.div<Props> that extraction leaves a constant

The lookups behind React component references (resolveComponent) and JSX children (jsxChild) keep only component, function and class nodes, and these are constant nodes. 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's RegisterPage. #2400 fixed that for references from route nodes only.

Where <X /> actually goes. A JSX tag is not an extracted reference; it never reaches reactResolver.resolve. Plain JSX usages become jsx-render edges in the synthesizer (reactJsxChildEdges → jsxChild), which had the same blind spot. In excalidraw, examples/with-nextjs' pages router renders <Excalidraw /> from const Excalidraw = dynamic(async () => (await import("../excalidrawWrapper")).default, …), and that edge went to the docs site's Excalidraw in dev-docs/. Pattern 1 sees the remaining PascalCase references from .tsx/.jsx files: route elements (#2400's case), Next.js and Expo pages, and incidental ones such as a TSX type. With const User = z.object(…); type User = …, (u: User) bound to another app's User component.

Fix

One lexical rule for every React component name. declaredComponent in frameworks/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:

  1. the component of the module the value's own loader imports
  2. else the function the value wraps
  3. else the declaration itself.

It now applies to:

  • every Pattern 1 reference from a .tsx/.jsx file (the route-node condition is gone);
  • a route layout the route file declares (layout: references cannot fall back to name matching, so the declaration binds there directly);
  • every jsx-render child. A <Name written right after an identifier is a type argument (useForm<Schema>() under const Schema = z.object(…)), not a tag, so it renders nothing. The lookup is memoized per file and name.

What a value renders:

  • Lazy loaders. lazy(() => import('./x')), wrapped or not (Loadable(lazy(…)), loadable(…), dynamic(…, { ssr: false }), Loadable({ loader: () => import(…) })), and dynamic(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's withInternalFallback("TTDDialogBase", (…) => { … })) loads nothing the value renders. Comments are stripped first, so import(/* webpackChunkName */ …) reads too.
  • Wrapped functions. An inner function of the value's own name, or one that ends where the value does (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:

  • follows a module that only forwards its component: import Login from './Login'; export default Login in an index, export { default } from, export * for a named pick;
  • reads a wrapped default export (export default observer(Login), connect(…)(Login), React.memo(Login));
  • resolves the specifier with the importing file's own extensions before TypeScript's. A .jsx file's ./layout/MainLayout is MainLayout/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

main at 31c3328d against this branch, every repo indexed fresh (init -y) and compared with scripts/dump-graph.mjs.

repo edges (−/+) what changed
excalidraw/excalidraw −1 / +3 examples/with-nextjs pages router: <Excalidraw/> → ExcalidrawWrapper (was dev-docs' Excalidraw); app router: <ExcalidrawWithClientOnly/> → ExcalidrawWrapper (new); TTDDialog → its TTDDialogBase (new)
alan2207/bulletproof-react +6 shadcn's <DialogPortal> / <DrawerPortal> → the same file's DialogPrimitive.Portal aliases, in each of the three apps
minimal-ui-kit/material-kit-react +1 <LazyChart> → its lazy(() => import('react-apexcharts')…) declaration (a package, so the declaration)
t3-oss/create-t3-turbo (control) +1 React 19's <ThemeContext value> → the same file's createContext
uilibrary/matx-react 0 byte-identical
codedthemes/mantis 0 byte-identical: #2400 already bound its routes, and its vite app renders the lazy pages only from route objects, never from a component's JSX
bradtraversy/proshop_mern (control) 0 byte-identical
leerob/next-saas-starter (control) 0 byte-identical
outline/outline (added: MobX observer, lazily loaded screens, styled.div<Props>) −18 / +49 below

outline:

  • 14 jsx-render edges move off another file's same-named component.
    • 11 now go to the file's own value. Most are styled.div<Props> consts: FloatingToolbar's <Wrapper> and <Background> were DialogTitle.tsx's and ModelSelectionToolbar.tsx's.
    • 2 go to the function a value wraps. Table's <TableView> was the ProseMirror TableView class in shared/editor; it is now TableViewInner.
    • 1 goes to the module a lazy value loads: the document ShareButton's <SharePopover> was Sharing/Collection/SharePopover; it is now Sharing/Document/SharePopover.
  • 4 route references (/, /create, two /s/:shareId routes) move from the lazy declaration, which name matching had bound, to the screen its module exports. That means SharedScene through export default observer(SharedScene), and Login through scenes/Login/index.ts's import Login from "./Login"; export default Login;.
  • 31 new jsx-render edges:
    • 18 to the file's own values;
    • 11 to the function a value wraps (observer(function HeadingPrefixMenuItem_…) and the like), which had no edge because the inner function's name differs;
    • 2 to lazily loaded modules (DocumentSidebarContent → Comments, History).
  • Nothing is removed outright.

Screens (buildScreens): excalidraw, bulletproof-react and mantis are unchanged. On outline:

  • / and /s/:shareId are now named by SharedScene, not the lazy declaration's Shared.
  • The login page's <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 main after #2400 merged, and the changed edges are identical line for line. main has 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-render edge, some <Name in the parent sits in tag position. For each edge, the target is one of:

  • the file's own declaration of that name;
  • a function inside the declaration or handed to its wrapper;
  • in the module its import('…') names.

I also read the source behind each kind of change. Examples: outline's FloatingToolbar, UserHoverCard, ShareButton, useDocumentSidebar, ImageUpload, Template, Table, Lightbox and routes/index.tsx; excalidraw's two Next.js pages and TTDDialog; bulletproof's dialog and drawer; minimal-kit's chart; t3's theme.

Tests

  • __tests__/jsx-child-disambiguation.test.ts gains a block of seven cases, each with a decoy component elsewhere:
    • a lazy page in a vite app beside a Next.js app;
    • a lazy page whose import only the app's own build resolves (renders the declaration);
    • excalidraw's dynamic(async () => (await import(…)).default), plus a .then named export through an export * barrel;
    • outline's index forwarding of export default observer(Login);
    • an alias, an observer(function …) and an observer(Inner) as typeof Inner;
    • a type argument and an import inside a wrapped arrow, which render nothing;
    • a TSX type named like a value the file declares.
  • __tests__/react-router.test.ts gains a layout loaded lazily from MainLayout/index.jsx with a same-named layout in another app.
  • With this change's two source files reverted, all 8 new tests fail. The vite page binds to the Next.js page, <Login> to the decoy, useForm<Schema>() "renders" the decoy Schema, and the layout to the other app's MainLayout.
  • tsc is 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, one EBUSY teardown and one wal-deferral count). Run again serially with long timeouts, those 14 files pass 952 of 952.

Known limits

  • A component a function binds locally (const Icon = item.icon; <Icon />, ({ icon: Icon })) has no node, so the rule doesn't see it. The old lookup still applies to it.
  • A value whose loader is computed (import(`./pages/${name}`)), picks an export through an option (outline's createLazyComponent(…, { exportName }), once), or sits behind an import only the app's own build resolves binds to the declaration, not a guessed page.
  • Route references re-resolve when their own file changes, as before. jsx-render edges 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, a Map<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's Map<name, asTag> in it in place of the Set. asTag reads only the span's own text, so the memo stays graph-neutral. This PR leaves JSX_TAG_RE's source unchanged, which #2424's jsx-render-work.test.ts counts 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

colbymchenry and others added 3 commits October 7, 2026 00:49
… 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>
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

No deployments
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