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>
@colbymchenry
colbymchenry merged commit 16344af into main Oct 7, 2026
colbymchenry added a commit that referenced this pull request Oct 10, 2026
#2436 lets a lazy route's module forward its component through barrels, so
the answer reads every file that walk passes. lazyModules (was lazyModule)
returns them all: a page added or edited behind an unchanged barrel moves
the route on sync, as a fresh index does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
colbymchenry added a commit that referenced this pull request Oct 10, 2026
…e, and a new component gets its JSX edges (#2452)

A route that renders `const Docs = lazy(() => import('./pages/Docs'))` binds
to the component that module exports, and to the declaration while the module
is missing or exports none (#2400's declaredComponent). The reference had
resolved, so no failed-ref retry saw it, and CG-33's rebind reached it only
when the module's component shared the declaration's name: a sync that added
the module, gave it its default export or moved that export left the route
on its old answer. A data router's `lazy` route kept a moved default export
the same way.

FrameworkResolver.lazyModules names the modules a route's answer reads,
including the files a barrel forwards it through (#2436); React implements it
for lazy-import references and the same-file lazy values Pattern 1 reads.
After the CG-33 rebind, sync puts back the route references whose modules it
added or changed (reopenRouteModuleReaders), in the order their edges were
written, for the orphan sweep.

The jsx-render refresh read only each changed file's own content, so a new
component without JSX of its own never got the edge from `<Team />` in an
unchanged file. A sync whose definition delta holds a JSX-renderable node now
refreshes synthesis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
bompus added a commit to bompus/codegraph that referenced this pull request Oct 10, 2026
…eceivers (#430)

* fix(context): call paths label synthesized hops like explore, not as events (colbymchenry#2428)

Two surfaces label a synthesized (dynamic-dispatch) `calls` hop:
codegraph_explore's Flow and dynamic-dispatch links plus the
codegraph_node trail (`dynamic: interface → impl @file:line`), and
ContextBuilder's "## Call paths", which `codegraph context` prints
(`→[…]`). Each kept its own list. The call paths' list stopped at
callback, react-render, jsx-render, vue-handler, http-client, queue-job
and socket messages, so every other synthesizer fell through to
`event ${m.event ?? ''}`: an interface-impl hop read
`→[event  @src/pdf-exporter.ts:5]`, and so did a C++ override, a redux
thunk, a gin middleware chain or a closure collection.

The wording now lives once, in src/graph/synthesized-hop.ts
(`describeSynthesizedHop`: a summary, the detail only the call paths
print, and the wiring site), as AGENTS.md asks of a derivation two
surfaces render. ToolHandler renders `dynamic: <summary> @site`, the
call paths `<summary><detail> @site`. Explore and the node trail print
exactly what they printed before; the call paths keep their wording for
the kinds they already labelled, including the queue name, a socket's
direction and the HTTP clause, and now name the rest the way explore
does. A bus event reads `bus event` there too, and the section's
footnote no longer says hops are marked `[callback/event …]`.

The helper also holds the wording colbymchenry#2414 gives a Go method a struct gets
by embedding (`metadata.promotedInto`), so that arm moves here too. The
unused long `label` form of ToolHandler.synthEdgeNote (last read by the
removed codegraph_trace) is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges (colbymchenry#2452)

A route that renders `const Docs = lazy(() => import('./pages/Docs'))` binds
to the component that module exports, and to the declaration while the module
is missing or exports none (colbymchenry#2400's declaredComponent). The reference had
resolved, so no failed-ref retry saw it, and CG-33's rebind reached it only
when the module's component shared the declaration's name: a sync that added
the module, gave it its default export or moved that export left the route
on its old answer. A data router's `lazy` route kept a moved default export
the same way.

FrameworkResolver.lazyModules names the modules a route's answer reads,
including the files a barrel forwards it through (colbymchenry#2436); React implements it
for lazy-import references and the same-file lazy values Pattern 1 reads.
After the CG-33 rebind, sync puts back the route references whose modules it
added or changed (reopenRouteModuleReaders), in the order their edges were
written, for the orphan sweep.

The jsx-render refresh read only each changed file's own content, so a new
component without JSX of its own never got the edge from `<Team />` in an
unchanged file. A sync whose definition delta holds a JSX-renderable node now
refreshes synthesis.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): a range-based for loop's variable has the type the loop declares (colbymchenry#2439)

C++ receiver inference read a declaration only when the declared name was
followed by `;`, `=`, `,`, `)`, `[`, `{` or `(`, so a range-based for's
`Type name :` was never read. In protocolbuffers/protobuf's conformance
runner, `suite->SetVerbose(…)`, `suite->RunSuite(…)` and
`suite->GetFailureListFlagName()` inside `for (ConformanceTestSuite *suite :
suites)` fell through to Strategy 3's guess by the receiver's name, and in
RocksDB `log->file()` on a `log::Writer` loop variable went to
`BlobLogWriter::file`.

A line the declarator regex doesn't match is now read as a range-based for
when its `for (` header declares the receiver before a single `:` (not
`::`), and the loop's variable is taken only when the call is inside the
loop's body: past the header, up to the `}` closing a braced body or the end
of a single-statement body, with comments and literals skipped. After the
loop, or in a later function, the same name is another variable, and the
scan goes on as before. A loop over `auto` elements has nothing to read and
keeps the existing fallback. Bit-fields and the scope operator are never
read as a loop's declaration.

A loop's declaration is recorded like any other (colbymchenry#2413's
noteCppDeclaration), so the std-type gate applies to
`for (const std::string& s : …)` too, and a loop that doesn't enclose the
call still marks the receiver as shadowed. With colbymchenry#2421, the 13 conformance
runner calls reach ConformanceTestSuite's methods at 0.9.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(js,ts): a default import is what `export default` declares, not the first exported function (colbymchenry#2432)

A default import resolved to the module's first exported function or
component whenever the `export default` statement wrote a declaration
(`export default function Vans()`, `export default class Store`): only a
separate `export default NAME` statement was read. A React Router 6.4+
data-router page exports its `loader` or `action` above the page
component, so `import Vans, { loader as vansLoader } from './Vans'` bound
the route `/vans` (and every call through `Vans`) to `loader`.

The resolver now reads the file's own `export default` statement from
comment-stripped code. A declaration it writes is found by position, a
binding it names by name, and an anonymous function, class or arrow
function is no node at all (the guess took an exported function beside it,
or one nested inside it). The statement also beats an exported styled/memo
component above it. Every line-initial statement is read and the first one
that names a node of the file wins, so template text that only reads like
a statement names nothing. Strings are not masked: a masker loses step on a
template whose `${…}` holds another template or a regex with a backtick,
and on outline it blanked the real statement below one. Only an expression
default (`memo(Card)`, `new Service()`), a binding the file doesn't
declare, or no statement keeps the old guess.

`require('./x').default` (colbymchenry#2433) reads the same statement first, so it
reaches the declared default too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(cpp): a `->` call on a smart pointer or optional reaches the type it holds (colbymchenry#2440)

A C++ call made with `->` on a receiver the calling function or its class
declares as a `std::unique_ptr<T>`, `std::shared_ptr<T>` or
`std::optional<T>` now resolves on `T`: on the class C++ name lookup finds for
it where the call is written, or on a class that class derives from. Before,
receiver inference read the type as `unique_ptr`, found no method, and the
call fell through to the guess by method name: rocksdb's
`std::unique_ptr<Iterator> iter; iter->Valid()` reached
`ArenaWrappedDBIter::Valid`.

The held type goes through colbymchenry#2407's lookup (`cppClassWritten`, extracted from
`matchCppSupertype`), not `resolveMethodOnType` on its last name: rocksdb has
22 classes named `Iterator`, and the one the calls mean inherits its methods
from `IteratorBase`, so a last-name lookup reaches `MemTableRep::Iterator`.
The method is taken from that class or its bases through the class's own
extends edges (`getSupertypeNodes`). A template parameter, a library type or
an alias of one gets no edge; a type the project doesn't declare, or an alias
the lookup can't follow, leaves the call as it was.

The namespace-macro helpers move from name-matcher.ts to cpp-namespaces.ts
unchanged, so name-matcher can import cpp-supertypes without a cycle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(sync): a barrel edited to forward another page moves the routes that lazily load it

---------

Co-authored-by: Colby Mchenry <me@colbymchenry.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@colbymchenry
colbymchenry deleted the claude/eager-austin-5aa039 branch October 10, 2026 06:15
colbymchenry added a commit that referenced this pull request Oct 10, 2026
A data-router `lazy` loader resolves to the route's properties, and many
pick their page from the module they load: luci-go milo/ui's
`lazy: async () => { const { TestTab } = await import('…/tabs'); return
{ Component: TestTab }; }`, `const { default: Component } = await
import('./pages/Login')`, `import('./x').then((m) => ({ Component: m.Page }))`,
`({ Component: (await import('./x')).Page })`, React Router 7's
`lazy: { Component: async () => (await import('./x')).Page }`. scanRoutes
recorded only the first `import('…')` as `lazy-import:<spec>`, and
resolution linked the module's default export, else its `Component`: a
different page, or nothing (milo/ui's tabs barrel has neither).

lazyRouteReference reads the loader: its `Component`, or the page its
`element` shows, followed through the loader's bindings (destructured
`await import`, a module binding, `.then` callbacks, `Promise.all`, the
v7.5 object form), and the one lazily imported export a guard shows
(`<AgeGate><FireworksPage /></AgeGate>`). A loader that returns the module
keeps `lazy-import:<spec>`; one this does not read (a helper's, `.then(convert)`)
keeps its first import, as before; one that hands over only a `loader`
renders nothing. `async lazy() { … }` methods, which scanRoutes skipped as
entries, are read too, and comments no longer hide an import's specifier.

The picked export is named the way Vue Router and Angular name a lazy
component, `import:<path>#<export>`, so sync's module-tail retry (#2422)
parks and retries it unchanged, and it resolves through #2436's
exportedComponent, barrels included. React is registered before Vue Router
and Angular, so it answers only its own routes' references (tsx or jsx,
which theirs never are). lazyModules (#2452) names the files a picked
export is read from, so a sync that moves it redraws the route.

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