Skip to content

fix(react): a JSX tag renders what the file imports under that name - #2451

Open
colbymchenry wants to merge 7 commits into
mainfrom
claude/ecstatic-shirley-f490b0
Open

colbymchenry wants to merge 7 commits into
mainfrom
claude/ecstatic-shirley-f490b0

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

What

A JSX tag now renders what the file's import of it resolves to, instead of whichever component shares its name.

reactJsxChildEdges / jsxChild used to pick a tag's component by name and consult the import only to break ties: a unique name was taken even when the file imports the tag from a module that does not declare it, a tie fell through to the first candidate when the import named a barrel, and a renamed default import found nothing. The new order is:

  1. a component the file declares itself (unchanged);
  2. what the file's import of the name resolves to (ctx.resolveImport → findExportedSymbol, through barrels, renames and default exports), for an import of a project module:
    • a component, function or class: that node;
    • a type, or a value: the value its module declares under the type's name (export interface X + export const X = …), else the function the value hands on (const Avatar = AvatarWithHoverCard, observer(function X() {…})), else nothing;
    • a default export with no declaration of its own (export default () => <Search {...props} />): the component its own module declares under the tag's name, else nothing;
  3. the name, as before, for a tag the file neither declares nor imports from the project (package imports included), or whose import can't be followed.

A component reached under another name (import SharedEditor from '~/editor') renders only where the parent writes that name as a tag, not as a type argument (useRef<SharedEditor>). Each tag now resolves once per file instead of once per parent and tag. The per-span tag memo from #2424 now carries this "written as a tag" flag.

Default exports had to become precise first

Following the import is only as good as findExportedSymbol's default branch, and that branch guessed. It took the module's first exported component, then export default NAME;, then its first exported function or class. On a probe it returned the wrong node for 8 of 12 common shapes, for example export function loader above export default function Vans → loader, and export const Wrapper = styled… above export default function Card → Wrapper. So for JS-family files the default branch now reads the module's own export default statement and never guesses:

  • export default function Vans / class Baz / Page; (as T / satisfies T allowed) / export { Qux as default };
  • a wrapper call: the first argument of its last call, read the same way. Examples: memo(Foo), connect(mapState)(Bar), withRouter(connect(m)(Bar)), observer(function Settings() {…}), traceFunction({…})(accountProvisioner), styled(Button)\…`. A wrapped binding must be a function, class or component: createRouter(routes)does not exportroutes`;
  • new Storage(): the class, whose members are the instance's;
  • a binding the module imports rather than declares (import Login from './Login'; export default Login in an index) is followed to that module;
  • an expression with no name (export default () => …, an object, requireNativeComponent('X')) is a node only when one stands exactly at the expression (React Native's codegenNativeComponent). Otherwise it is nothing.

Every line-initial export default outside a block comment is read, and the first one that names a node is the module's. A template can hold statement-shaped text: react.dev's SandpackWithHTMLOutput.tsx has two sandbox files' export default function lines above its own export default memo(function …). A function or class a statement declares binds only the node that starts there, so a template's export default function formatHTML can't reach the file's real formatHTML. Both rules come from #2432. Each statement is read from a 4 KB window at its start, and one pass over the file sorts out block comments for all of them. SFC files (.vue/.svelte) and modules with no export default statement keep the old fallbacks. Separately, a named lookup now follows an export clause that forwards an import (import AdvancedMarker from './advanced-marker'; export { AdvancedMarker }), which the clause-alias index never could: import nodes are named by their source.

Tests

__tests__/jsx-child-imports.test.ts, 13 cases. 10 fail on main, each for the reason the bug report gives. The other 3 are guards for shapes the triage below turned up, and they pass on main too:

  • barrel-imported duplicate names across two apps (@/ per app);
  • aliased default import with a same-named decoy ({ default as Settings }, import Settings from, { ProfileRoute as Settings });
  • a function component default-imported under another name;
  • loader/action exported above export default function, and export default observer(Card) under another name;
  • an anonymous default with a same-named stranger (no edge), and an anonymous wrapper around the module's own component (guard);
  • a forwarding index (default and export { X } clause) and a constant alias (const Badge = BadgeWithTooltip);
  • a TS interface + value pair (guard), and RN codegenNativeComponent exported directly or through a const (guard);
  • a class imported under another name used only as a type argument;
  • template text with export default function … lines above the real statement, one of them naming a function the file also declares (react.dev's shape);
  • calls through default imports: createApp past an earlier helper, traceFunction(…)(accountProvisioner), and export default new Storage() (main resolves Storage.get() to storageKey).

__tests__/default-export-declaration.test.ts is #2432's test file, carried unchanged. All 5 cases pass: data-router routes, an async handler, a class, a declaration and export default Banner beating an exported styled component, anonymous defaults, and new Service().

npx tsc --noEmit is clean. On the branch after merging main: the JSX, Fabric/Paper, HOC, React Router, default-import, dead-code, #2424 and resolution suites pass (387/387). The full suite passed on the merged branch before the last commit (6,596 passed, 0 failed); the final run is in the comments below.

Validation

Before/after scripts/dump-graph.mjs on fresh codegraph init -y indexes. Both arms have the same freshly built native kernel staged (TS/JS are kernel-routed). The base is main at ed199e6, which already has #2412, #2423 and #2424; the other arm is this branch merged with it. An earlier run on 31c3328 + #2412 gave the same per-repo diffs, except vanlife, where #2423 adds routes. Edge counts are natural-key rows.

repo jsx-render main → branch re-targeted lost new other edges −/+ refs −/+
bulletproof-react 559 → 560 167 0 1 0 0
refine examples (281 apps) 6916 → 6938 781 12 34 −0/+16 −16/+0
outline 2927 → 2857 54 182 112 −1166/+1553 −393/+18
excalidraw 482 → 486 2 3 7 −37/+39 −2/+0
mantis 335 → 337 2 0 2 0 0
matx 366 → 366 1 0 0 0 0
berry 425 → 426 0 0 1 0 0
minimal-kit 142 → 143 0 0 1 0 0
chickadee 134 → 134 1 0 0 0 0
vanlife 2 → 2 0 0 0 −8/+8 0
react-boilerplate 49 → 49 0 0 0 −26/+18 −0/+8
redux-realworld 27 → 27 0 0 0 −1/+10 −9/+0
takenote 51 → 51 0 0 0 −35/+0 −0/+35
binner 214 → 214 0 0 0 −8/+8 0
proshop-v2 44 → 44 0 0 0 0 0
react.dev 2 re-targeted, nothing else 2 0 0 0 0
controls: proshop_mern, next-saas-starter, create-t3-turbo identical

Every removed jsx-render edge was triaged as re-targeted or lost:

  • bulletproof-react: cross-app jsx-render edges go 179 → 12. All 167 re-targets are a same-named component moving into the parent's own app (Button ×50, Spinner, Form, Link, Input, …). The new edge is AuthLayout as AuthLayoutComponent. The 12 left are out of scope: 9 tags imported from packages (react-error-boundary, react-router), one <Progress /> in a comment, and two generic parameters (TableColumn<Entry>).
  • refine examples: 768 re-targets leave another app for the parent's own, and 13 stay in the app but reach the right file, for example <Text> → components/text, not a helper inside gh-banner. The 12 lost all pointed into other apps: import type { User } used as a generic, const Form = FormProvider, const Select = SelectPrimitive.Root. The 34 new are renamed imports (Sidebar as ShadcnSidebar), Svg* icon defaults, and the map barrels' export { X } clauses. The 4,200 remaining cross-app edges are package imports; another 208 are names that aren't imported (antd's const { Text } = Typography).
  • outline:
    • All 182 lost edges pointed at strangers. 118 <Text> resolve to a styled constant, where main bound EditableTitle's local Text. 25 <Header> and 21 <Footer> are email templates whose default is export default () => …, where main bound command-bar and explorer namesakes. There are also 8 <Diff> → an editor extension class, 6 <Emoji> → a model class, 2 <EditorContainer>, and 2 type-only imports.
    • All 54 re-targets were checked against source: Avatar → AvatarWithHoverCard (×28, const Avatar = AvatarWithHoverCard), { Meta as DocumentMeta }, export default WrappedTooltip, the default observer(…)s, and model classes like Document/Desktop replaced by the imported components.
    • The 112 new edges are MobX observer(function X_…) values and renamed defaults.
  • excalidraw: { Toast } → ToastComponent (Object.assign(ToastComponent, …)), Dimension/DragInput/DropdownMenuGroup defaults, and index.tsx's import Footer from './components/footer/FooterCenter'; export { Footer }. The 3 lost were strangers (Sidebar/CommandPalette from other files).
  • react.dev: <CodeBlock> in CodeDiagram and PackageImport now renders CodeBlock/index.tsx's memo(function CodeBlockWrapper…), which import CodeBlock from './CodeBlock' names. Main skipped that wrapper for the inner CodeBlock. MDXComponents' default import of SandpackWithHTMLOutput stays at import 0.9, past the template's statements.
  • mantis, matx, chickadee, berry, minimal-kit: mantis's vite and next <Drawer> rendered themes/overrides/Drawer (the next app's, from the vite app). matx's <ConsecutiveSnackbar> and chickadee's <NotFound> rendered a styled helper (the "module's only component" fallback).
  • A tag-position check over every new or re-targeted edge, 1,166 of them, finds each written as a tag (none only as a type argument).

The non-JSX changes come from the default-export reading and reach every default import:

  • outline: 609 re-targeted, 402 resolved where main failed, 539 same target with new metadata, and 18 removed.
    • Re-targeted: 56 @Relation decorators bound getInverseRelationsForModelClass; pagination bound paginateQuery (41); lazyWithRetry bound isStaleChunkError; ToggleBlock.isEmpty() bound a free function. 349 server-side Logger.info/warn/error/debug calls went to the browser's app/utils/Logger.ts, because both tiers do class Logger + export default new Logger(). presentDocument/presentTemplate calls went to namesakes in server/tools/.
    • Resolved where main failed: traceFunction(…)(accountProvisioner) and the other commands, plus the presenters barrel (import presentPolicies from './policy'; export { presentPolicies }).
    • The 18 removed were all guesses. A class passed as a value ([CodeFence, …]) had been bound to the first exported function, and function_ref rejects a class, so these refs are now unresolved. The rest are export default new Plugin(…), export default function (…), and export default Sentry of a namespace import.
  • vanlife: the data router's routes now reach Login/Vans/VanDetail/Dashboard/HostVans/HostVanDetail instead of action/loader. That includes the /host index route and the three layout:HostVanDetail refs that fix(react-router): a JSX index route is the page at its parent's address, inside the layout around it #2423 added.
  • react-boilerplate: getInjectors and githubData defaults re-target from the factory/saga exported above them. The 8 removed instantiates edges went from an anonymous export default ({…}) => … to the class nested inside it.
  • takenote: 35 reducer(...) calls through export default noteSlice.reducer were bound to getFirstNoteId and are now unresolved.
  • redux-realworld: 9 connect(…)(X) routes now resolve.
  • binner: 8 routes keep their targets; export default (props) => <Search …/> is anonymous, so they now come from name matching (exact-match 0.5) instead of the import guess.

Timing. In the pipeline, the jsxEdges pass of a full init took 3.81 s → 4.22 s on refine examples, 2.11 → 2.20 s on outline, and 0.38 → 0.43 s on excalidraw; small repos changed by less than 15 ms. Run alone on a cold resolver, over 5 interleaved runs with the machine at about 70% load, CPU medians were:

repo main this branch
outline 0.73 s 0.83 s
excalidraw 0.23 s 0.36 s
refine examples 1.30 s 1.64 s

The extra work is import-path resolution, mostly existsSync probes for each imported tag's specifier. In the pipeline those are mostly warm, because resolution has already resolved the same specifiers. Name queries per parent and tag drop, since each tag resolves once per file.

Overlap with open PRs

Left out (follow-ups)

🤖 Generated with Claude Code

colbymchenry and others added 3 commits October 7, 2026 09:07
A JSX tag was matched by name, with the file's import consulted only to
break a tie, so a monorepo's apps rendered each other's same-named
components (bulletproof-react: 179 of 559 jsx-render edges crossed apps
through `@/components/ui/*` barrels), a default import named like an
unrelated symbol bound that symbol, and a function component imported
under another name rendered nothing. The tag now resolves through the
import first (same-file declarations still win, and a name the file does
not import from the project keeps the old name-based rules). A value the
import names hands on what it wraps or aliases, a type renders nothing,
and a component reached under another name renders only where the name
is written as a tag, not as a type argument.

Following the import needed a precise default export: findExportedSymbol
guessed the module's first exported component or function, so
`export function loader` above `export default function Vans` made every
`import Vans` the loader. The default branch now reads the module's own
`export default` statement for JS-family files: a declaration, a name,
`export { X as default }`, what a wrapper call wraps (`observer(Card)`,
`connect(m)(Bar)`, `traceFunction(...)(fn)`), an instance's class
(`new Storage()`), a binding the module imports, or nothing for an
anonymous expression. A named lookup also follows an export clause that
forwards an import. Includes #2412's `{ default as X }` mapping (same hunk).

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

# Conflicts:
#	CHANGELOG.md
#	src/resolution/callback-synthesizer.ts
…ed path first

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry and others added 4 commits October 7, 2026 09:34
…ode is the module's

A template can hold statement-shaped text: react.dev's
SandpackWithHTMLOutput.tsx writes two sandbox files' `export default
function` lines above its own `export default memo(function ...)`, and
reading only the first line left the module's default import with
nothing. Every line-initial statement outside a block comment is now
read, the first that names a node wins, and a function or class a
statement declares binds only the node that starts there, so a
template's `export default function formatHTML` can't reach the file's
real formatHTML. The block-comment check reads the file once for all of
its statements.

Carries #2432's default-export tests unchanged (all pass), and adapts
its multi-statement and declaration-position rules.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`export default createStore(reducer)` exports a store, but the wrapper
reading took `reducer` as the default, so `store.dispatch()` through the
default import linked the reducer. A wrapper call now hands on its
argument only when it is a component's name (`memo(Card)`,
`observer(function Settings…)`) or ends a curried chain
(`connect(mapState)(view)`, `traceFunction({…})(provision)`).

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

# Conflicts:
#	CHANGELOG.md
#	src/resolution/callback-synthesizer.ts
#	src/resolution/import-resolver.ts
…ey-f490b0

# Conflicts:
#	CHANGELOG.md
#	src/resolution/import-resolver.ts

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