fix(react): a JSX tag renders what the file imports under that name
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:
- a component the file declares itself (unchanged);
- 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;
- 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.
Each tag now resolves once per file instead of once per parent and tag. Names written only as type arguments never get this far: #2442's scan on main skips them, and its local-binding post-check now applies to the memoized result.
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 Tallowed) /export { Qux as default };- a wrapper call: the first argument of its last call, read the same way, when it is a component's name or ends a curried chain. Examples:
memo(Foo),observer(function Settings() {…}),styled(Button)\…`,connect(mapState)(Bar),withRouter(connect(m)(Bar)),traceFunction({…})(accountProvisioner). A factory's argument is not the export:createStore(reducer)exports a store, sostore.dispatch()doesn't bindreducer. A wrapped binding must also be a function, class or component, socreateRouter(routes)doesn't exportroutes` either; new Storage(): the class, whose members are the instance's;- a binding the module imports rather than declares (
import Login from './Login'; export default Loginin anindex) 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'scodegenNativeComponent). 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. #2433's require('./x').default now uses the same reading.
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, 14 cases. 10 fail on main, each for the reason the bug report gives. The other 4 are guards for shapes the validation 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/actionexported aboveexport default function, andexport 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 andexport { X }clause) and a constant alias (const Badge = BadgeWithTooltip); - a TS interface + value pair (guard), and RN
codegenNativeComponentexported directly or through a const (guard); - a class imported under another name, rendered as a tag;
- 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:
createApppast an earlierhelper,traceFunction(…)(accountProvisioner),export default new Storage()(main resolvesStorage.get()tostorageKey), andexport default createStore(reducer)(guard).
__tests__/default-export-declaration.test.ts is #2432's test file, carried unchanged, plus two cases from another session's validation of this branch: React Native's AnimatedColor shape loaded with require('./x').default and const { default: X } = require(…), and an export default abstract class below an exported helper that a class extends. Both fail on main. All 7 cases pass: data-router routes, an async handler, a class, a declaration and export default Banner beating an exported styled component, anonymous defaults, new Service(), and the two new ones.
npx tsc --noEmit is clean. On the branch after merging main e47cb258: 441/442 pass (1 skipped) across the JSX (with #2442's new block), default-export, require-default, catch-all alias, CommonJS, alias-binding, Fabric/Paper, HOC, React Router, resolution and dead-code suites. The full-suite result is in the comments below.
Validation
Before/after scripts/dump-graph.mjs on fresh codegraph init -y indexes of 19 repos. Both arms have the same freshly built native kernel staged (TS/JS are kernel-routed). The base is main at e47cb258, which already has #2412, #2423, #2424, #2429, #2433, #2437, #2442 and #2450; the other arm is this branch merged with it. Earlier runs on 31c3328d and ed199e60 gave the same diffs except where those PRs moved main's side. Edge counts are natural-key rows.
| repo | jsx-render main → branch | re-targeted | lost | new | other edges −/+ | refs −/+ |
|---|---|---|---|---|---|---|
| bulletproof-react | 556 → 557 | 167 | 0 | 1 | 0 | 0 |
| refine examples (281 apps) | 6904 → 6928 | 781 | 10 | 34 | −0/+16 | −16/+0 |
| outline | 2939 → 2997 | 157 | 54 | 112 | −1163/+1550 | −393/+18 |
| excalidraw | 480 → 484 | 2 | 3 | 7 | −37/+39 | −2/+0 |
| react.dev | 242 → 242 | 2 | 0 | 0 | 0 | 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 |
| 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 177 → 10. 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 isAuthLayout as AuthLayoutComponent. The 10 left are out of scope: 9 tags imported from packages (react-error-boundary,react-router) and one<Progress />in a comment. - 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 insidegh-banner. The 10 lost all pointed into other apps: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'sconst { Text } = Typography). - outline:
- The 157 re-targets were checked against source. 103
<Text>now render@shared/components/Text's styledText(a component since #2429) instead ofEditableTitle's privateText. 28Avatar→AvatarWithHoverCard(const Avatar = AvatarWithHoverCard). The rest are{ Meta as DocumentMeta },export default WrappedTooltip, the defaultobserver(…)s, and model classes likeDocument/Desktopreplaced by the imported components. - All 54 lost pointed at strangers: 25
<Header>, 21<Footer>and 8<Diff>in email templates whose default isexport default () => …, where main bound command-bar, explorer and editor-extension namesakes. - The 112 new edges are MobX
observer(function X_…)values and renamed defaults.
- The 157 re-targets were checked against source. 103
- excalidraw:
{ Toast }→ToastComponent(Object.assign(ToastComponent, …)),Dimension/DragInput/DropdownMenuGroupdefaults, andindex.tsx'simport Footer from './components/footer/FooterCenter'; export { Footer }. The 3 lost were strangers (Sidebar/CommandPalettefrom other files). - react.dev:
<CodeBlock>inCodeDiagramandPackageImportnow rendersCodeBlock/index.tsx'smemo(function CodeBlockWrapper…), whichimport CodeBlock from './CodeBlock'names. Main skipped that wrapper for the innerCodeBlock.MDXComponents' default import ofSandpackWithHTMLOutputstays at import 0.9, past the template's statements. - mantis, matx, chickadee, berry, minimal-kit: mantis's vite and next
<Drawer>renderedthemes/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,271 of them, finds each written as a tag.
The non-JSX changes come from the default-export reading and reach every default import:
- outline: 609 re-targeted, 402 resolved where main failed, 536 same target with new metadata, and 18 removed.
- Re-targeted: 56
@Relationdecorators boundgetInverseRelationsForModelClass;paginationboundpaginateQuery(41);lazyWithRetryboundisStaleChunkError;ToggleBlock.isEmpty()bound a free function. 349 server-sideLogger.info/warn/error/debugcalls went to the browser'sapp/utils/Logger.ts, because both tiers doclass Logger+export default new Logger().presentDocument/presentTemplatecalls went to namesakes inserver/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, andfunction_refrejects a class, so these refs are now unresolved. The rest areexport default new Plugin(…),export default function (…), andexport default Sentryof a namespace import.
- Re-targeted: 56
- vanlife: the data router's routes now reach
Login/Vans/VanDetail/Dashboard/HostVans/HostVanDetailinstead ofaction/loader. That includes the/hostindex route and the threelayout:HostVanDetailrefs that #2423 added. - react-boilerplate:
getInjectorsandgithubDatadefaults re-target from the factory/saga exported above them. The 8 removedinstantiatesedges went from an anonymousexport default ({…}) => …to the class nested inside it. - takenote: 35
reducer(...)calls throughexport default noteSlice.reducerwere bound togetFirstNoteIdand 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.
The #2432 session cross-checked this branch at d1553d63 against #2432. On the 8 repos where #2432 changes anything, it reproduces all 398 of #2432's reference-site changes, with the same target, resolvedBy and confidence.
Timing (measured on ed199e60 arms). 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
Already resolved by merging main:
- #2442: kept its
{ tags, bare }scan and local-binding post-check, applied to this PR's per-filechildOfmemo, and dropped this PR's own type-argument gate, as agreed with that session. - #2433:
esmDefaultExport/hasEsmDefaultExportnow read the statements. Arequire('./x').defaultgets the precise default, and a JS file whose only exported component is a styled helper no longer counts as having an ES default. - #2437 and #2450: only an adjacent-line conflict in the import block.
Still open:
- #2432: fixes the same first-export guess in the same function. This branch carries its test file and rules and reproduces all its site changes (above). Its session recommends closing it once this lands; that's the maintainer's call.
- #2420 (own-file values in JSX) extends the same
jsxChild. The merged order should be own value → same-file declaration → import → name. It also readsexport defaultand wrapper calls a second way inframeworks/react.ts(defaultExportedName/wrappedName/wrappedComponent). Whichever lands second should switch to this PR'sfindExportedSymboldefault branch andvalueBinding, so there is one reading. - A follow-up from another session declines package-imported tags. Where the two meet,
importedJsxChild'sif (!module) return undefined;becomesreturn ctx.isOutOfRepoImport?.(mapping.source, file, language) ? null : undefined;.
Left out (follow-ups)
- Tags imported from a package still fall back to the name, as the task asked: 4,200 cross-app edges in refine's examples,
react-router'sLinkin bulletproof, and antd's<Modal>/<Button>binding project namesakes on SigNoz. The follow-up above covers it. - A jsconfig
baseUrl-only import (from 'src/components') doesn't resolve, so its tag falls back to the name (one edge in refine's examples). This is the knownpath-aliases.tsgap.
🤖 Generated with Claude Code