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

Merged
CO
8d ago
+333-8

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.

reporoute edges addedremoved
luci-go milo/ui+51 (51 failed refs → linked)0
dannileb/article-app (FSD export { default as Component } from)+40
folo (export { App as Component })+20
te1m0z/blog-frontend (const Component = observer(…); export { Component })+20
Parv-01/parv-portfolio (export { RetroPage as default } from)+10
16 controlsdumps 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