fix(react-router): a lazy route links the page a barrel module forwards
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, orexport { Page as Component }viaextractLocalExportAliases; - otherwise follows the module's re-exports from
context.getReExports, up to 4 hops with a visited set, the wayexportedTablein 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 whengetRetryableFailedReferencesis 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 skipsbuild/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 onlyhandle = { 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 wholebuildfeature.- A route table returned by a function and assigned to a local before
createBrowserRouter(routes). That is all of Medusa's admin routes. - Async
lazyloaders that pick a member,{ default: Component }or{ TestTab }. The ref records only the module, so such a route links the module's default orComponentrather than the picked export. - The nested-barrel sync limit above.
🤖 Generated with Claude Code