Skip to content

fix(react): a JSX tag imported from the project renders what its module exports - #2563

Merged
colbymchenry merged 1 commit into
mainfrom
claude/objective-colden-8a151b
Oct 14, 2026
Merged

colbymchenry merged 1 commit into
mainfrom
claude/objective-colden-8a151b

Conversation

@colbymchenry

Copy link
Copy Markdown
Owner

Summary

The JSX render pass (jsxChild in src/resolution/callback-synthesizer.ts) matched a tag by name and used the file's imports only to break ties. So when a tag is imported through an index that forwards another module's component, the index declares no candidate and the name picks one elsewhere: outline's Sidebar/index.ts is import Sidebar from "./App"; export default Sidebar, and <Sidebar> rendered Aside.tsx's styled Sidebar instead of the AppSidebar that App.tsx exports as observer(AppSidebar).

A tag imported from a module of the project now renders what that module exports under the imported name. The export is read by exportedComponent in frameworks/react.ts, the reader lazy routes already use (now exported), so the following are covered:

  • Forwarding through an index or barrel: import X from './App'; export default X, export { X } of an import, export { default } from, export * (with JavaScript's ambiguity rule), and renamed default imports.
  • Wrappers: observer(AppSidebar), React.memo(StaticCanvas, areEqual), React.forwardRef<…>(Button).
  • Two new shared rules, which lazy routes get too:
    • A const holding another binding is that binding. outline's Avatar/index.ts is const Avatar = AvatarWithHoverCard; export { Avatar }, and signoz has export const TanStackRowCell = TanStackRowCellMemo as typeof TanStackRowCellInner. If the binding leads nowhere, the value stays the export. A let isn't followed, because it may be reassigned. The withAuth(Page) guard from fix(react-router): a lazy route follows a barrel that re-exports an import #2558 is unchanged.
    • A memo wrapper's component node is the component it wraps. mastodon's const MemoizedBlurhash = memo(Blurhash); export { MemoizedBlurhash as Blurhash } and excalidraw's export const Excalidraw = React.memo(ExcalidrawBase, areEqual) used to land on a component node with no body and no outgoing edges, so render flows dead-ended there.
  • Decline: when the import resolves to a module whose export is no component node (export default ({ url }) => …), the tag links nothing. Before, it linked a namesake elsewhere. A namesake the module itself declares still links; that covers a CommonJS module.exports = Card.
  • Sync: synthesis inputs now include every file a project-imported tag is read through. So codegraph sync and the watcher redraw the tag when only the forwarding index, or the module behind it, changes. The new sync tests compare against a fresh index.

One existing expectation changed. In react-router-lazy-barrel.test.ts, the /builders route's module is export const Component = BuilderListPage, and the route now links BuilderListPage (the function with the markup) instead of the alias constant, which has no edges.

Scope

Validation

Fresh-index A/B. Main d0bb9be5 against this branch: fresh indexes of 13 repos, dumped with scripts/dump-graph.mjs and compared by natural keys. Every changed edge was graded by a TypeScript-checker oracle. The oracle binds the tag with the checker, follows import aliases, and follows a value that holds another binding to its declaration. A target is right if it is that declaration or the component its wrapper hands on. A tag whose name binds no component in the project is right with no edge.

repo changed tag sites none→right wrong→right right→right¹ wrong→none²
outline 386 175 158 53
bulletproof-react 255 88 167
mastodon 101 57 25 15 4
signoz 61 56³ 5
excalidraw 55 34 5⁴ 16⁴
bigbluebutton 24 16 8
mantis 4 2 2
berry, matx, minimal-kit 1 each 2 1
total 889 430 371 31 57
  • Controls: proshop_mern, next-saas-starter and create-t3-turbo are byte-identical.
  • Recall: no tag loses a correct edge (no right→wrong or right→none).
  • Dead ends: no tag moves from a node with outgoing edges to one without.

¹ The tag moved from a bodyless memo wrapper to the component it wraps: <Excalidraw> → ExcalidrawBase, mastodon <ShortNumber> → ShortNumberRenderer.
² The decline: outline's email Header/Footer/Diff tags, whose modules default-export an unnamed arrow and used to link the app's namesakes, and mastodon's 4 connect()-wrapped containers.
³ Includes one generic tag, <TanStackTable<T, TItemKey>>, that the oracle can't locate. I checked it by hand.
⁴ Includes two sites in examples/with-nextjs, graded with that example's own tsconfig. The <Excalidraw> one is the same unwrap; the checker can't resolve the package import there.

Tests:

  • New: 9 tag cases in jsx-child-disambiguation.test.ts and a lazy-route memo case in react-router-lazy-barrel.test.ts. One tag case pins that a let alias is not followed, since it may be reassigned.
  • New file sync-jsx-import-forwarding.test.ts: the index rewired (full and scoped sync), the index switching to a value alias, and the module behind the index rewrapped. Each case compares the synced graph with a fresh index.
  • Mutation checks: the alias tests fail without the alias hop, and the Blurhash tag and route tests fail without the memo unwrap.
  • 26 related suites: 596/596 pass.
  • Full suite on Windows: 6,687 passed. 18 tests in 9 unrelated files failed under load: 16 timeouts and 2 EBUSY daemon teardowns, in the daemon, MCP freshness, sync and worktree suites. All 9 files pass when rerun serially (155/155).

Timing: I compared main and this branch over 5 interleaved rounds per arm, on a shared box, and took medians.

repo full index wall jsxEdges pass wall
bulletproof-react +5.8% (+0.18 s) +53 ms
excalidraw −2.3% +95 ms
outline +0.4% +68 ms
mastodon +0.1% +56 ms
signoz +1.9% +144 ms

A CPU profile of the pass puts the added time in comment-stripping the modules that tags import from (about 125 ms on signoz) and in resolving those imports' paths. I didn't add a cross-pass cache of module text: it would pin text the resolver's LRU file cache evicts on purpose.

🤖 Generated with Claude Code

…le exports

The JSX render pass matched a tag by name and used the file's imports only
to break ties, so an index that forwards another module's component
declared no candidate and the tag took a namesake elsewhere: outline's
Sidebar/index.ts is `import Sidebar from "./App"; export default Sidebar`,
and <Sidebar> rendered Aside.tsx's styled Sidebar, not the AppSidebar that
App.tsx exports as observer(AppSidebar).

A tag imported from a module of the project now renders what that module
exports under the imported name, read by exportedComponent, the reader
lazy routes use: through an index or barrel, past a wrapper call. Two rules
join that reader, for routes too: a const holding another binding is that
binding (`const Avatar = AvatarWithHoverCard; export { Avatar }`; a let may
be reassigned, so it stays), and a memo wrapper's bodyless component node is
the component it wraps (`const MemoizedBlurhash = memo(Blurhash)`). A module
whose export is no component node (`export default () => ...`) links nothing
rather than a namesake. Sync records every file a tag's import is read
through as a synthesis input, so rewiring only the index 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