Skip to content

fix(react-router): a lazy route links the page a barrel module forwards - #2436

Merged
colbymchenry merged 8 commits into
mainfrom
claude/eager-austin-5aa039
Oct 7, 2026
Merged

colbymchenry merged 8 commits into
mainfrom
claude/eager-austin-5aa039

Conversation

@colbymchenry

@colbymchenry colbymchenry commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Problem

A React Router data-router route lazy: () => import('@/test_verdict/pages/invocation_page') linked nothing when the module it loads is a barrel: invocation_page/index.ts holding export * from './invocation_page'. That holds even on a fresh full index.

lazyRouteComponent in src/resolution/frameworks/react.ts resolved the spec to one file and read only that file, for export default … or export (const|function|class) Component. Three forwarding forms were never followed:

  • export * from './x'
  • export { default } from './x'
  • export { Page as Component } from './x'

The same reader also missed a module's own export clause, export { GraphView as Component }; (milo/ui's chronicle views, folo, blog-frontend).

Found on luci-go's milo/ui: 91 failed lazy-import: / layout:lazy-import: refs over 51 modules.

Fix

lazyRouteComponent now asks exportedComponent(file, 'default'), then exportedComponent(file, 'Component'). That walk:

  • reads the module's own export first: export default Page, export function/const Component, or export { Page as Component } via extractLocalExportAliases;
  • otherwise follows the module's re-exports from context.getReExports, up to 4 hops with a visited set, the way exportedTable in the same file already follows them for route tables.

It follows JavaScript's rules, so it never guesses:

  • export * never forwards the default.
  • A name the module exports itself hides one an export * would forward.
  • A name that two export * modules both forward, to different declarations, is exported by neither, so nothing is linked.

React.lazy values in a route file (declaredComponent) and layout: refs through import { default as X } go through the same function, so they follow barrels too.

Verification

Regression test. __tests__/react-router-lazy-barrel.test.ts:

  • On main's react.ts, 6 of its 7 index cases fail. The 7th, the negatives, passes on main and guards against over-linking.
  • Removing the ambiguity rule or the "export * skips the default" rule each fails that negative case. Both mutations were checked.
  • Two sync cases: a page added behind its barrel after the index, and an edit that gives the page its Component. Both fail when getRetryableFailedReferences is stubbed to return nothing.

Checks. tsc --noEmit is clean, and so is a strict type-check of the new test file. The 20 React, React Router and router test files pass; 8 of them hit hook timeouts on this saturated box and passed when rerun serially. Full suite on a box at 100% CPU: 6,183 passed and 5 failed. Rerun serially, the 9 files involved pass except function-ref’s #1820 case, which hits its own 60 s timeout on main too.

A/B on real repos (fresh codegraph init, main ed199e60 vs this branch)

21 repos. Only react.js differs between the two builds.

repo route edges added removed
luci-go milo/ui +51 (51 failed refs → linked) 0
dannileb/article-app (FSD export { default as Component } from) +4 0
folo (export { App as Component }) +2 0
te1m0z/blog-frontend (const Component = observer(…); export { Component }) +2 0
Parv-01/parv-portfolio (export { RetroPage as default } from) +1 0
16 controls dumps byte-identical —

The 16 controls are bulletproof, rr-examples, infrahub, outline, medusa, berry, mantis, matx, minimal-kit, hathor-explorer, bitser, flight-planner, proshop, next-saas, t3-turbo and takenote.

Every added edge was checked by hand. On milo/ui each barrel reaches the leaf module's export function Component, chronicle's five views reach the view their clause names, and fleet's home page reaches HomePage. The added sets are identical on the earlier base 22a7ba85. After the merge I re-ran it on final main 51000e3a, which by then included #2433, #2437 and #2442 (JS/TS resolution): the same 60 added edges, and bulletproof, rr-examples, infrahub, mantis, proshop and next-saas byte-identical.

milo/ui's other 40 failed refs are not this bug:

  • 19 refs (13 modules) load files under src/build/. The default ignore skips build/ at any depth, so that whole feature folder is unindexed.
  • 8 refs (7 modules) load modules that export only a loader.
  • 10 refs load @/fleet/root, which exports only handle = { layout }.
  • 3 refs come from lazy: async () => { const { TestTab } = await import('…/tabs'); return { Component: TestTab }; }, where the loader picks a named export.

Medusa's admin dashboard. Each of its 193 route-map modules is an index.ts that forwards the page, mostly as export { X as Component } from './x'. Its routes aren't extracted (const routes = getRouteMap({...}); createBrowserRouter(routes) isn't followed), so the A/B can't show them. Calling the resolver on each spec directly: main links 0 of 193 and this branch links 193 of 193. An independent text oracle that follows the barrels by hand agrees on all 187 it can decide. The other 6 re-export with an explicit .tsx extension, and I checked those by hand.

Sync. I held out every file a lazy route links to, indexed the rest, put the files back and ran codegraph sync. On parv-portfolio, blog-frontend, article-app and folo the synced graph equals the fresh index. On milo/ui it differs by 4 test-file imports edges, and main differs by the same 4.

Known sync limit. A page nested two barrels deep in a folder named unlike the route's import isn't relinked when it's added or edited after the index. For example, @/builds/pages → pages/index.ts → builder_list_page/index.ts → builder_list_page.tsx. The cause: moduleReferenceKeys only covers the changed file's own folder and stem. A fresh index links it, and before this change neither did. In the late-file runs above, every page sat in its barrel's folder or was named like the route's import, so all of them converged.

Overlap with #2420

#2420, still open, also rewrites lazyRouteComponent. It returns a Node, takes a member, and follows import X from './X'; export default X plus named re-exports. Its wildcard step runs only for a non-default member, so it does not fix these routes: a data-router lazy module goes through the default member, and export * has no default. Whichever lands second should keep #2420's structure and add this PR's step: when the default finds nothing, look up Component, following export * with the ambiguity rule. Both PRs' tests pin the behaviour.

Not in this PR (follow-ups)

  • src/build/ source folders in JS/TS apps are skipped by the default ignore. That is milo/ui's whole build feature.
  • A route table returned by a function and assigned to a local before createBrowserRouter(routes). That is all of Medusa's admin routes.
  • Async lazy loaders that pick a member, { default: Component } or { TestTab }. The ref records only the module, so such a route links the module's default or Component rather than the picked export.
  • The nested-barrel sync limit above.

🤖 Generated with Claude Code

colbymchenry and others added 6 commits October 7, 2026 05:54
A data router's `lazy: () => import('@/pages/invocation_page')` loads the
folder's `index.ts`. When that file only forwards the page, with
`export * from './invocation_page'`, `export { default } from './page'` or
`export { Page as Component } from './page'`, the route linked nothing: the
module was read for an export of its own and none was found. The lookup now
follows the module's re-exports a few hops to the file that declares the
page, the way JavaScript resolves them (`export *` never forwards the
default, a name two `export *` modules both forward is exported by
neither), and reads a module's own `export { Page as Component }` clause.
`React.lazy` values in a route file, which go through the same lookup,
follow such barrels too.

On luci-go's milo/ui, 51 of 91 failed lazy route refs now link; the rest
load a file under `src/build/` (not indexed), export only a `loader` or a
`handle`, or pick a named export in an async loader.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Pins the sync side of the barrel fix: a page added behind its folder's
index file after the index, and an edit that gives the page the
`Component` its barrel forwards, both link on `sync` the way a fresh index
links them. Both fail with sync's failed-reference retry stubbed out.

Adds the CHANGELOG entry and the validation note in the framework coverage
doc.

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