Repository navigation
fix(react-router): a lazy route links the page its loader picks - #2480
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A React Router data-router
lazyloader resolves to the route's properties, and many loaders pick the page from the module they load:scanRoutesrecorded only the loader's firstimport('…'), aslazy-import:<spec>, and resolution linked that module's default export, else itsComponentexport. When the loader picks a member itself, that is the wrong page or none:tabsmodule is a barrel with neither, so its three tab routes linked nothing. These were the last 3 of the 91 failed refs fix(react-router): a lazy route links the page a barrel module forwards #2436 counted there..then((m) => ({ Component: m.ReportList })),({ Component: (await import('./reports')).ReportPage })andconst page = await import('./profile'); return { Component: page.ProfilePage }linked the module's default export, a different page.lazy: { loader: async () => (await import('./show.loader')).loader, Component: async () => (await import('./show')).Show }, read the loader's module.const { teamLoader } = await import('./loaders/team'); const { TeamPage } = await import('./pages/team')) read the wrong module.async lazy() { … }, the form React Router's docs and its ownlazy-loading-router-providerexample use, is a method rather than alazy:property, so the scanner skipped it and those routes did not exist.A GitHub code search for
createBrowserRouter+lazygave 876 router files from 834 repos, holding 2,604lazyloaders. 468 of them are async loaders in 60 repos: 183 pick{ default: Component }, and the rest pick a named page.Fix
Reading the loader.
lazyRouteReferenceinframeworks/react.tsreads alazyloader the way it runs. It evaluates the loader's return value, binding what eachawait import(…)destructures, a module held in a variable,.thencallbacks,Promise.allarrays and the v7.5 object of lazy properties. The page is the returned object'sComponent, or the page itselementshows:lazy-import:<spec>.import:<spec>#<export>, for exampleimport:./tabs#TestTaborimport:./pages/Login#default.Component:field would.When the page is not a plain binding, it is the one lazily imported export the
Componentorelementuses, as in<AgeGate><FireworksPage /></AgeGate>,() => <PrivateRoute component={<Favorites />} />orcompose(withAuth(), layout)(Home). A loader this does not read, such aslazyPage(() => import('./x'))or.then(convert), keeps its first import, as before. One that hands over only aloaderrenders nothing.Scanner changes.
async lazy() { … }methods are now read. Comments are stripped first, soimport(/* webpackChunkName: "x" */ './x')is no longer missed.Ref format.
import:<path>#<export>is the form Vue Router and Angular already use for a lazy component.MODULE_REFERENCE(#2422) therefore parks a failed ref under the module'smodule:<stem>tail unchanged, and sync retries it. Resolution goes through #2436'sexportedComponent, barrels included.The gate. React is registered before Vue Router and Angular, so
resolve()andlayoutComponentanswer only references a React route makes (isReactRouteRef). Those are written in tsx or jsx even in a.tsroutes file, and Vue's and Angular's never are. Trap 18 inframework-coverage.mdrecords this.Sync.
lazyModules(#2452) also returns the files a picked export is read from. A sync that movesexport defaultto another component therefore redraws a{ default: Component }route, as it already does forlazy-import:routes.Verification
Regression test.
__tests__/react-router-lazy-member.test.tshas 16 tests:loader-only loader, a pick the module lacks,{ default }of a module without one).lazy-import:and fix(sync): a route rendering a same-file lazy value follows its module, and a new component gets its JSX edges #2452 covers it. Here it pins that the newimport:…#defaultform keeps that behaviour.vue/views/Login.ts by react, because React steals Vue'simport:./views/Login#Login.getRetryableFailedReferencesfails both retry cases.lazyModulesentry fails the moved-default case.Checks.
tsc --noEmitis clean, and so is a strict type-check of the test file.AST oracle over the survey. The same 876 router files were parsed with the TypeScript compiler. Each
lazyproperty,async lazy()method and<Route lazy>was evaluated on the AST with real scoping, then compared with the text reader. They agree on all 2,538 loaders that import something:defaultlazy:propertyasync lazy()method<Route lazy>48 loaders were decided by the "one lazily imported export" rule. I read all 48:
<ProtectedRoute><XPage /></ProtectedRoute>.() => <RoleGate roles={…}><Clients /></RoleGate>.<AgeGate><FireworksPage /></AgeGate>.<PrivateRoute component={<Favorites />} />.createElement(module.default).React.createElement(RequireAuth)of a lazily imported guard, which is what those routes render.compose(withAuth(), layout)(Home).<Page />or<m.BasicExample />.All 48 are right. On main, the 3 AgeGate routes linked through the default export by luck, and the 11 RoleGate ones did too.
A/B on real repos (fresh
codegraph init, mainfa56256cvs this branch; onlyreact.jsdiffers between the builds)Controls: 26 of 28 byte-identical.
package.jsonlists it). All 30 of its Angular lazy routes (import:…#…) stay Angular Router's.lazy-loading-router-providerexample'sasync lazy()routes now exist, along with their 3<Link>s. Its data-router tests' import-lesslazy={async () => ({ element: <Comp /> })}routes now linkComp.20 survey apps. These are ones with async loaders: smartbook-system (object form), qltt, pawn-shop, KomunitasDisabilitas (
Promise.all), prepify-frontend, Dreamer-UI, Serviceos, morphofitfrontend, DigiShop.Frontend, jotti, iProcess, IntereSync, learn-lingo, es-covoiturage, picket-frontend, daily-writing-friends, farmap, trygc, glosse and toolbox.export defaultnames. None were flagged.lazy-import:./x→import:./x#default.async lazy()methods.navigate()calls that led nowhere.indexchild written as a method now claims its parent's address. The parent (Main,MeasuresApp) becomes its layout rather than the page, which is how a static index child is read. These are the 2 routes "removed": the address moved to the child.return { loader: (await import("./pages/EventDetail")).loader }, commented "we want the loader, but not the Component here". On main, the page was the layout of the routes inside it../components/folder the repo doesn't contain.import { UserLayout } from './UserLayout'; export { UserLayout }, whichexportedComponentdoesn't follow. Main fails the same refs aslazy-import:. See "Not in this PR".Sync. On 8 of these apps (milo/ui, qltt, Dreamer-UI, KomunitasDisabilitas, iProcess, pawn-shop, jotti, farmap):
codegraph syncrun.Overlap
lazyImportOfparses the same shapes forReact.lazyvalues (.then((m) => ({ default: m.Chart })),(await import('./x')).Chart). This PR's evaluator reads them too:loaderValueon the loader function, then the object'sdefaultmember (or the export itself, fornext/dynamic's direct.then((m) => m.Chart)). When fix(react): a component a file declares itself as a value is what its JSX renders #2420 is rebased,lazyImportOfcan become that call instead of a second parser. fix(react): a component a file declares itself as a value is what its JSX renders #2420 also carries the import-forwarding barrel step DigiShop needs.lazyModulesto picked exports, as above.Not in this PR
import { X } from './x'; export { X }(DigiShop's@/pagesand@/layout).exportedComponentfollowsexport … fromandexport *but not an export clause of imports, for module-only routes as much as for picks.lazyPage(() => import('./x'), 'Landing')(107 loaders in 5 repos) orloadLazyRoute(() => import('./x')), still read the module as before.🤖 Generated with Claude Code