Skip to content

fix(react): a component a file declares itself as a value is what its JSX renders - #2420

Merged
colbymchenry merged 1 commit into
mainfrom
claude/interesting-hermann-dcee7f
Oct 14, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/interesting-hermann-dcee7f

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

A .tsx/.jsx file can declare a component as a value:

  • 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() {…}), React.memo(TableBody, areEqual) or forwardRef(Inner) as {…};
  • an alias or a context: const DialogPortal = DialogPrimitive.Portal, createContext(…).

Such a value is a constant node. The lookups behind React component references (resolveComponent) and JSX children (jsxChild) keep only component, function and class nodes, so they skipped the value and took a lone same-named component anywhere in the repository. #2400 fixed this for one case only: references out of route nodes. #2452 later carried that case through sync.

Plain JSX (<RegisterPage />) never reaches reactResolver.resolve. Those edges come from the jsx-render synthesizer (reactJsxChildEdges → jsxChild), which had the same blind spot. Some examples:

  • In excalidraw's examples/with-nextjs, <Excalidraw /> comes from const Excalidraw = dynamic(…), but the edge went to the docs site's Excalidraw.
  • In mastodon, const Emoji = lazy(() => import('…/emoji_picker').then(({ Emoji }) => ({ default: Emoji }))) rendered components/emoji's unrelated Emoji.
  • Also in mastodon, let EmojiPicker, Emoji; // load asynchronously rendered the async loader functions of ui/util/async-components.

Fix

One lexical rule for every React component name. declaredComponent in frameworks/react.ts is exported and now returns { declaration, component }. If the file declares the name as a module-level value, the name binds to that declaration, never to another file's component. The same rule now applies:

  • to every PascalCase Pattern 1 reference from a .tsx/.jsx file, not just route references: a route's element, a Next.js page, a TSX type;
  • to layoutComponent, since a layout: reference has no name-matching fallback;
  • to the jsx-render synthesizer's jsxChild, where the lookup is memoized per file and name.

What the value renders, read from its initializer:

  1. The module its lazy loader loads. valueLoader reads the loader with fix(react-router): a lazy route links the page its loader picks #2480's loaderValue, the same reader a data router's lazy gets. That covers:
    • lazy(() => import('./x')), wrapped (Loadable(lazy(…)), dynamic(…, { ssr: false }), Loadable({ loader })) or not;
    • (await import('./x')).default;
    • a picked export: .then((m) => ({ default: m.Chart })) or .then(({ Emoji }) => ({ default: Emoji }));
    • either branch of a conditional: SigNoz's IS_DEV ? React.lazy(…) : null.
  2. Else the function it wraps. An inner function of the value's name, or one ending where the value ends (observer(function Profile_() {…})). Or the same-file component handed to a wrapper call: observer(Inner), connect(m)(Inner), or a component ahead of options (React.memo(TableBody, areEqual), withAuthZ(Page, { … })). A type assertion on the call is skipped (as typeof Inner, as { … }). An import inside a function body the value wraps loads nothing the value renders.
  3. Else the declaration itself.

Lazy modules. A module whose default export is observer(Login), connect(…)(Login) or memo(Login, eq) hands over Login (defaultExportedName, used by exportedComponent). A value found there is unwrapped too. A .jsx file's ./layout/MainLayout resolves with the file's own extensions (MainLayout/index.jsx) before TypeScript's.

Sync. A tag's edge now depends on the modules that the file's lazy values load. So collectInputs records those modules, with the barrels on the way, as synthesis inputs (lazyValueModules). A sync that gives one of them its default export redraws the tag the way a fresh index draws it; without this, sync-lazy-route-value.test.ts's default-export case diverged from the fresh index. lazyModules also covers a layout the route file declares as a lazy value.

Converging with what landed since

This branch is a single commit on main 9b23709, rebuilt from scratch rather than rebased:

Open overlap: #2451 (import-first jsxChild) edits the start of jsxChild too. When it rebases, the own-value check should stay first. A module can't both import and declare a name, so the two rules never compete for one tag.

Validation

Every repo was indexed fresh (init -y) twice, with main 9b23709 and with this branch, and the graphs compared with scripts/dump-graph.mjs.

repo edges (−/+)
outline/outline −7 / +31 See below.
mastodon/mastodon −9 / +20 See below.
SigNoz/signoz −4 / +7 See below.
excalidraw/excalidraw −1 / +3 examples/with-nextjs: the pages router's <Excalidraw/> → ExcalidrawWrapper (was dev-docs' Excalidraw); the app router's <ExcalidrawWithClientOnly/> → ExcalidrawWrapper (new); TTDDialog → its TTDDialogBase value (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, codedthemes/mantis, codedthemes/berry 0 byte-identical (#2400/#2452 already bind their routes)
bradtraversy/proshop_mern, leerob/next-saas-starter (controls) 0 byte-identical

outline.

  • 4 route references move. / (twice), /create and /s/:shareId now reach the screens their lazy declarations load, not the declarations:
    • SharedScene, through export default observer(SharedScene);
    • Login, through scenes/Login/index.ts's import Login from "./Login"; export default Login.
  • 3 tags leave another file's namesake:
    • Table's <TableView> is now TableViewInner, not ProseMirror's TableView class;
    • UserHoverCard's <Profile> is now its own observer(function Profile_…), not the settings page;
    • ShareButton's <SharePopover> is now the lazily loaded Sharing/Document one, not Sharing/Collection's.
  • 24 new edges:
    • 11 to the functions values wrap: HeadingPrefixMenuItem_, CollectionScene_, SelectableRow_, …;
    • 11 to the file's own values: ObservedCell, AvatarEditorDialog, SVGIcon, …;
    • 2 to lazily loaded modules: DocumentSidebarContent → Comments, History.

mastodon.

  • 5 tags move to the right file. In compose/redesign/emoji.tsx, <Emoji> (×3) and <EmojiPicker> now reach the exports their lazy(() => import('…/emoji_picker').then(({ Emoji }) => …)) loaders pick. They had reached components/emoji's unrelated Emoji and the async-components loader function. MediaAttachments's <Video> likewise reaches features/video's component, not the loader.
  • 4 tags now bind to the file's own let. emoji_picker_dropdown.jsx declares let EmojiPicker, Emoji; // load asynchronously and assigns them from emoji-mart at runtime. Its tags had gone to those same two wrong targets and now bind to the declaration.
  • 11 new edges:
    • 8 from lazily loaded redesign components (LazyStatusRedesign, LazyStatusLegacy, ComposeLazyForm, LazyColumnsAreaRedesign) to their modules' components;
    • 2 to wrapped functions: forwardRef(ComboboxWithRef) as {…} and injectIntl(EmojiPickerMenuImpl);
    • 1 to <MenuContext>, a React 19 context provider.

SigNoz.

  • 3 tags now bind to the file's own value, not to a namesake that happened to be right:
    • cmdKPalette.tsx's two IS_DEV ? React.lazy(…) : null devtools. The loader is read, but its module path is out of reach (see Known limits).
    • a test's const NumberPanelRenderer = BaseNumberPanelRenderer as React.FC<…>.
  • 4 new edges:
    • <ResponsiveGridLayout> ×2 → their WidthProvider(GridLayout) declarations;
    • <ViewRoleContent> → ViewRoleContentInner, through withAuthZContent(Inner, {…});
    • <MemoizedTableBody> → TableBody, through React.memo(TableBody, eq). TableV3 already reached TableBody through <TableBody>, so that edge is only relabeled and counts as the fourth removal.

Precision. A script checks every added edge against the source: 69 of 69 check out. It accepts a jsx-render edge only when:

  • some <Name sits in tag position inside the parent;
  • and the target is the file's own declaration, a function inside it or handed to its wrapper, or something in the module its import('…') names.

Every removed edge is a replacement: the same source and tag now reach a different target. I read the source behind each one.

Index time. Measured interleaved on this shared box (run 1 / run 2):

repo main this branch
signoz 22 s / 26 s 23 s / 27 s
mastodon 16 s / 20 s 20 s / 17 s

Tests

  • __tests__/jsx-child-disambiguation.test.ts gains 8 cases, each with a decoy component elsewhere:
    • a vite page beside a Next.js app;
    • a page whose import only the app's build resolves, which renders the declaration;
    • dynamic(async () => (await import(…)).default);
    • a .then pick through an export * barrel;
    • outline's forwarded export default observer(Login);
    • an alias, observer(function …) and observer(Inner) as typeof Inner;
    • SigNoz's conditional loader, mastodon's forwardRef(…) as {…} and React.memo(Rows, eq);
    • a type argument and an import inside a wrapped arrow, which render nothing;
    • a TSX type named like a value.
  • __tests__/react-router.test.ts gains a layout loaded lazily from MainLayout/index.jsx, beside another app's MainLayout.
  • __tests__/sync-lazy-route-value.test.ts: scenario 1 also expects App's <DocsPage> to reach Docs.tsx's component. That is the new jsx-render edge; sync matches the fresh index.
  • Red phase: with the tests run against main's source, all 11 new or changed cases fail.
  • tsc is clean. The 20 React, JSX, router and sync test files pass (23 files, 487 of 487).
  • Full suite: 6690 passed, 0 failed, 404 skipped. An earlier run had one EBUSY teardown failure in mcp-initialize.test.ts, a known flake on this Windows box, which passed 3 of 3 when rerun alone.

Known limits

  • A value with no node. A component bound inside a function (const Icon = item.icon; <Icon />) has no node, so the old lookup still applies to it.
  • An alias of an import binds to its own declaration. const NumberPanelRenderer = BaseNumberPanelRenderer as React.FC<…> in a SigNoz test is one example.
  • Modules the index can't resolve. When a value's module can't be resolved, the tag binds to the value's declaration: a computed import(`./pages/${name}`), or an import only the app's build resolves. SigNoz's two devtools values fall here. Their lib/authz/… paths come from frontend/tsconfig.json's paths, which isExternalImport doesn't consult: it reads only the root config's aliases, so those imports count as npm packages. That gap predates this change and affects all of SigNoz's alias imports, so I'm raising it separately.

Docs

  • Trap 19 and the React Router row in docs/design/framework-coverage.md.
  • A CHANGELOG bullet under [Unreleased] → ### Fixes.

🤖 Generated with Claude Code

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>
colbymchenry added a commit that referenced this pull request Oct 14, 2026
…mport (#2558)

A lazily loaded folder whose index passes on a page it imports linked
nothing: DigiShop.Frontend's `src/layout/index.ts` does `import { UserLayout }
from './UserLayout'; export { UserLayout }`, and its routes load
`await import('@/layout')`. exportedComponent, the barrel walk behind lazy
routes, React.lazy values and lazy layouts (#2436, #2452, #2480), read the
export clause as naming a declaration in the barrel, which has none: the
name is an import binding. `import Login from './Login'; export default
Login` and `export { Page as Component }` of an imported Page failed the
same way, so all 27 lazy route refs in DigiShop's App.tsx stayed failed.

A binding the module exports but does not declare is now followed through
the module's import of it, a default import as `default` and a named one by
the name it imports, with each file read added to `reads` so lazyModules
still redraws the route. A namespace import or a package's binding links
nothing. `export default withAuth(Page)` hands over a call, whose first
identifier ownExport reads; that was harmless while only same-file
declarations counted, so a default is followed only when the statement is
the binding itself (exportsBinding), or the route would link the HOC.

#2451 (open) adds the same step to the general import walk; #2420 (open)
carried it in a lazyRouteComponent rewrite that exportedComponent superseded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… JSX renders

A PascalCase name that a .tsx/.jsx file declares as a module-level value is
that declaration wherever the file uses it: a JSX tag, a route's element, a
layout or a TSX type. Its initializer decides what it renders, in order:
- the component of the module its lazy loader loads, or the export the
  loader picks, in either branch of a conditional;
- the function it wraps (`observer(function X() {…})`,
  `forwardRef(Inner) as {…}`);
- else the declaration itself.

Before, the lookup by kind skipped the value and took a lone same-named
component anywhere, often in another app of the repository.

- Pattern 1 asks declaredComponent about every PascalCase reference, not
  only route references. layoutComponent and the jsx-render synthesizer's
  jsxChild ask it too.
- valueLoader reads a value's loader with loaderValue (#2480).
- defaultExportedName reads `export default observer(Login)`.
- lazyValueModules records the modules a file's lazy values load as
  synthesis inputs, so a sync that changes one of them redraws the tag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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