perf(router): lazy-load App Router page and route-handler modules#1781
Conversation
Statically-imported route modules were all evaluated at Worker startup. For typical pages this is cheap, but routes with expensive module-level initialization make startup scale linearly and can exceed Cloudflare's ~400ms startup CPU budget. Measured on real Workers: 150 pages with heavy module-init went from ~414-467ms startup (eager) to ~17ms (lazy); typical pages are unaffected (~0.01ms/route either way). The RSC manifest now emits page modules of static routes and all route-handler modules as `() => import()` thunks instead of eager `import * as`, so they are code-split out of the entry's top-level evaluation and loaded on demand for the matched route only. ensureAppRouteModulesLoaded() hydrates a route's lazy modules onto the synchronous page/routeHandler fields before any consumer reads them; it is idempotent and dedups concurrent loads. Hydration is invoked at the central match point and at every mid-flight route lookup that reads modules before buildPageElement (server-action redirect/rerender targets, interception and ISR revalidation source routes). Dynamic-route pages stay eager because their generateStaticParams is referenced in the module-level generateStaticParamsMap; making those lazy needs a prerender-resolver rework and is tracked as follow-up.
commit: |
|
|
|
||
| const varName = `load_${lazyIdx++}`; | ||
| const absPath = normalizePathSeparators(filePath); | ||
| imports.push(`const ${varName} = () => import(${JSON.stringify(absPath)});`); |
|
/bigbonk review for issues |
There was a problem hiding this comment.
/bigbonk review — lazy-load App Router page/route-handler modules
I traced every synchronous read of route.page / route.routeHandler against the new hydration sites. The wiring is solid: the central match point (app-rsc-handler.ts:678), buildPageElements (app-rsc-entry.ts:493), the action redirect/rerender targets, intercept + ISR revalidation source routes, and handleServerActionRequest are all hydrated before any read. Intercept page fields and dynamic-route pages stay eager, so the lazy paths are consistent. No correctness blocker found.
A few things worth considering:
1. Failed lazy import is cached permanently (no retry / no reset)
ensureAppRouteModulesLoaded (app-route-module-loader.ts:60-72) only clears __loading and sets __loaded inside the .then success continuation. If the dynamic import() rejects:
__loadingkeeps holding the rejected promise,__loadedstaysfalse,- every subsequent call returns that same rejected promise (line 51).
So a single transient import failure for a route wedges that route into a permanent 500 for the life of the Worker isolate, and the stored rejected promise can surface as an unhandled rejection if a later caller doesn't attach a handler in time.
This is a behavior change from the eager model: with import * as a module-eval failure happens once at startup and is effectively deterministic, whereas now it's per-request and sticky. Import failures are rare and usually deterministic, so severity is low — but a .catch that resets __loading = null (leaving __loaded false so the next request retries) would match the "load on demand" intent better and avoid the sticky-rejection footgun. At minimum, add a test for the rejection path.
2. CodeQL alert on app-rsc-manifest.ts:124 is a false positive — worth a suppression/comment
The new getLazyLoaderVar emits const load_N = () => import(${JSON.stringify(absPath)}). CodeQL flags it as "improper code sanitization" because the path flows into generated code. The input is a filesystem scan result (route.pagePath / route.routePath), the same trust level as the pre-existing eager import * as ${var} from ${JSON.stringify(absPath)} two lines up (line 114), which CodeQL does not flag. It's a false positive driven by import() being treated as dynamic. Recommend dismissing the alert with a note (or a short codegen comment) so it doesn't recur — the surrounding getImportVar is identical in trust model.
3. Minor: double hydration on the intercept path
app-page-dispatch.ts:719 awaits ensureRouteLoaded(interceptRoute) and then calls buildPageElement, which (app-rsc-entry.ts:493) awaits __ensureRouteLoaded(route) again. Idempotent so harmless, but the inner await makes the dispatch-site hydration redundant for that callback. Not a problem, just noting it's belt-and-suspenders.
Verified
vp test run tests/app-route-module-loader.test.ts tests/entry-templates.test.ts— 32 passing.renderNotFound(which readsroute.pageatapp-rsc-entry.ts:902) is only invoked withroute: null(app-rsc-handler.ts:660), so the un-hydrated path is unreachable there.- Prerender endpoint reads only
staticParamsMap(eager dynamic-route modules); static-route pages have nogenerateStaticParams, so leaving them lazy is correct. - Pages Router prod server delegates App Router to the built RSC entry, inheriting hydration — no separate gap.
Overall this is a well-scoped, correct change. Item 1 is the only one I'd want addressed (or consciously waived) before merge.
|
Review posted to PR #1781. SummaryI traced every synchronous read of Findings posted:
Also verified: |
…g the rejection Addresses /bigbonk review feedback on the lazy route-module loader: - ensureAppRouteModulesLoaded no longer caches a rejected dynamic import(). On failure it clears __loading (leaving __loaded false) and re-throws, so the current request still sees the error but the next request retries — matching the eager model's per-isolate retry semantics instead of wedging the route into a permanent 500 and risking an unhandled rejection. - Document why the getLazyLoaderVar import() codegen is a CodeQL false positive (trusted filesystem-scan path, same trust model as the eager getImportVar import * as). - Add a rejection-path unit test covering retry-after-failure.
|
Thanks @ask-bonk — addressed in 61a7eb2. 1. Sticky failed import (fixed). 2. CodeQL false positive (documented). Added a comment at 3. Double hydration on the intercept path (intentional, keeping). The dispatch-site Loader + entry-template + action/dispatch suites green (103 passing). |
|
/bigbonk review for issues |
|
APIError: Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead. |
|
@james-elicx Bonk workflow failed. Check the logs for details. View workflow run · To retry, trigger Bonk again. |
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
/bigbonk review — lazy-load App Router page/route-handler modules
Re-reviewed at 61a7eb2 (the failed-import fix). I re-traced every synchronous read of route.page / route.routeHandler against the hydration sites and ran the loader, entry-template, features (311), and production-server + worker-entry (42) suites locally — all green. The wiring is correct and the prior blocker is properly resolved.
Verified correct
- Sticky-failure fix is sound.
ensureAppRouteModulesLoaded's.catchclears__loading(leaving__loadedfalse) and re-throws, so the current request observes the error and the next request retries — matching the eager per-isolate model. Concurrent callers share the one rejected promise and allawaitit, so there's no unhandled-rejection footgun. The new rejection-path test (does not cache a failed import…) covers it. - Every hydration site
awaits. All 8 call sites (app-rsc-handlercentral match,buildPageElements, intercept + ISR-revalidationgetSourceRoutetargets inapp-page-dispatch, action redirect/current/rerender targets inapp-server-action-execution, andhandleServerActionRequest) await before any.page/.routeHandlerread. handleServerActionRequestordering is correct:await __ensureRouteLoaded(__actionMatch.route)(entry:795) precedes the__resolveAppPageSegmentConfig({ page: route.page })runtime read (entry:797).- Intercept pages stay eager (
getImportVar, manifest:234), so interceptpagereads need no hydration; the intercept source route is hydrated before its layouts/page render via thebuildPageElementswrapper (entry:493), and__buildPageElementsis never called bypassing that wrapper. renderNotFound'sroute.pageread (entry:902) is only reached withroute: null(handler:664) and is guarded.- Background ISR regeneration re-enters
renderFreshPageForCache, which hydratesrevalidationTarget.route(dispatch:568) before reading — covered. - Prerender endpoint touches only the eager
staticParamsMap; App Router prod-server delegates to the built RSC entry so it inherits hydration. - CodeQL alert at
app-rsc-manifest.tsgetLazyLoaderVaris a false positive (JSON-encoded filesystem-scan path, same trust model as the eagerimport * asingetImportVar); now documented inline. Fine to dismiss.
Minor (non-blocking)
-
Inaccurate comment, not a bug.
registerRouteModules(app-rsc-manifest.ts:157) says route handlers "have no generateStaticParams." Next.js route handlers do supportgenerateStaticParamsfor dynamic segments (app/api/[slug]/route.ts). The behavior here is still safe becausebuildGenerateStaticParamsEntriesonly ever sources fromroute.layoutsandroute.pagePath— neverroute.routePath— so dynamic route-handler static params were already absent fromgenerateStaticParamsMapbefore this PR. Making route handlers lazy introduces no regression, but the comment overstates the reason. Suggest rewording to: "route handlers are never referenced bygenerateStaticParamsMap(it sources only layouts + page), so they can always be lazy" — the accurate justification, and a reminder that prerendering dynamic route handlers remains a separate gap. -
Double hydration on the intercept path (
app-page-dispatch.ts:719+ the innerbuildPageElementsawait) is intentional and idempotent, as the author noted —resolveRouteFetchCacheMode(interceptRoute)reads.pagebeforebuildPageElementruns, so the dispatch-site await is required. No change needed.
No correctness blocker. LGTM once item 1's comment is reworded (optional).
|
Review posted to PR #1781. SummaryI re-reviewed the lazy-route-module change at Conclusion: no correctness blocker. The implementation is sound and the prior review's only blocker (sticky failed import) is properly resolved. Findings posted:
|
Reword the getLazyLoaderVar comment per /bigbonk re-review: route handlers are lazy because they are never sourced into generateStaticParamsMap (which only reads layouts + page), not because they 'have no generateStaticParams' (Next.js route handlers can export it for prerendering — a separate gap).
|
Thanks @ask-bonk — addressed the non-blocking item in 8220e7f. Item 1 (comment reworded). Updated the Item 2 (double hydration) is intentional/idempotent as discussed — no change. CodeQL alert will be dismissed as a false positive in the UI. |
|
/bigbonk re-review for issues |
|
APIError: Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead. |
There was a problem hiding this comment.
/bigbonk re-review — lazy-load App Router page/route-handler modules
Re-reviewed at 8220e7f (latest, includes the failed-import fix and the reworded codegen comment). I re-traced every synchronous read of route.page / route.routeHandler against the hydration sites and ran the loader + entry-template + production-server + worker-entry suites locally — all green.
Conclusion: no correctness blocker. Both prior findings (sticky failed import, inaccurate comment) are resolved, and I could not find a new issue.
Verified correct at this commit
-
Sticky-failure fix is sound.
ensureAppRouteModulesLoaded's.catch(loader:71-80) clears__loadingand re-throws, leaving__loadedfalse so the next request retries — matching the eager per-isolate model. The.then/.catchcallbacks run as a single microtask chain off the samePromise.all, so a concurrent caller can never observe a half-reset state: while pending, all callers share the one__loadingpromise (loader:51); once settled, late callers either see__loaded(success) or a freshly-null__loadingand retry (failure). The new rejection-path test (does not cache a failed import…) covers it. -
All hydration sites
awaitbefore any module read. I confirmed each one:- Central match:
app-rsc-handler.ts:678hydrates before the route-handler-vs-page branch (route.routeHandlerread at :696) and beforedispatchMatchedPage's synchronousroute.page?.defaultread (entry:593). buildPageElementswrapper: entry:493 awaits__ensureRouteLoaded(route)before__buildPageElements.__buildPageElementsis never called bypassing this wrapper.- Intercept + ISR-revalidation
getSourceRoutetargets:app-page-dispatch.ts:568(beforeresolveRouteFetchCacheMode(revalidationTarget.route)at :573) and :719 (beforeresolveRouteFetchCacheMode(interceptRoute)at :720). - Action redirect target:
app-server-action-execution.ts:1037beforecanRenderActionRedirectTarget(reads.routeHandler/.pageat :646-649) andresolveRouteFetchCacheMode(targetMatch.route)at :1063. - Action current route: :1047 before
resolveRouteRuntime(currentMatch.route)at :1089. - Action re-render target: :1174 before
resolveRouteFetchCacheMode(actionRerenderTarget.route)at :1176. handleServerActionRequest: entry:795 awaits__ensureRouteLoaded(__actionMatch.route)before the__resolveAppPageSegmentConfig({ ..., page: __actionMatch.route.page })read at :797.
- Central match:
-
getSourceRoute-resolved targets only readroute.paramspre-hydration.resolveAppPageInterceptionRerenderTarget/resolveAppPageActionRerenderTargetcallgetRouteParamNames(sourceRoute)→sourceRoute.params, which is a static field always present in the route table (manifest:282). The.pageread on the source route is deferred to the hydratedbuildPageElementcallback. Correct. -
renderNotFound'sroute.pageread (entry:902) is only reached withroute: null(handler:664), guarded by theroute ? ... : falseternary. -
Prerender endpoint reads only
staticParamsMap(app-prerender-endpoints.ts:57), which sources exclusively from eager dynamic-route page modules (mod_N?.generateStaticParams) — never lazy static-routeroute.page. Leaving static pages lazy introduces no prerender gap. -
Shared mutable route objects are intentional, not a per-request leak.
__loaded/__loading/page/routeHandlerare mutated on the module-level singletonroutesarray; once loaded they stay loaded for the isolate's life. That is the cache, and it carries no per-request state. -
CodeQL alert at
getLazyLoaderVar(manifest:118-133) is a false positive — a JSON-encoded filesystem-scan path, identical trust model to the eagerimport * asingetImportVartwo functions up. Now documented inline; fine to dismiss. -
Comment rewording (item 1 from last round) landed correctly. manifest:157-162 now justifies route-handler laziness by "never referenced by
generateStaticParamsMap(sources only layouts + page)" and notes that wiring route-handlergenerateStaticParamsinto prerendering is a pre-existing gap unaffected by this PR. Accurate.
Local verification
vp test run tests/app-route-module-loader.test.ts— 8 passing (incl. rejection-path retry test).vp test run tests/entry-templates.test.ts— 25 passing.vp test run tests/app-router-production-server.test.ts tests/app-router-worker-entry.test.ts— 42 passing, including route-handler ISR STALE/background-regen (exercises lazy route-handler hydration end-to-end), public-file serving, and hybrid production.
LGTM.
|
Review posted to PR #1781. SummaryRe-reviewed the lazy-route-module change at Conclusion: no correctness blocker. Both prior findings are properly resolved and I found no new issue. What I verified:
Local runs: loader (8), entry-templates (25), and prod-server + worker-entry (42, including route-handler ISR which exercises lazy hydration end-to-end) — all green. LGTM. |
The previous cut kept dynamic-route pages (and anything nested under a
dynamic segment) eager, because their generateStaticParams was referenced
synchronously in the module-level generateStaticParamsMap. That gutted the
benefit for a large class of apps: anything with a dynamic root segment
(i18n `[locale]`, multi-tenant `[org]`) made every route dynamic, so
nothing was lazy — and the heavy `[slug]` page (the prime heavy-module-init
candidate) stayed eager.
Now ALL page modules are lazy. generateStaticParamsMap embeds lazy `{ load }`
page sources instead of `mod.generateStaticParams`, and
createAppPrerenderStaticParamsResolver imports them on demand at prerender
time (already async). It returns the same null "no static params" sentinel
the prerender driver relies on (skip / output:export error), so SSG
behavior is unchanged. Layout generateStaticParams sources stay eager.
The eager set is now just genuinely-shared modules: layouts, templates,
boundaries, intercepts, and global-error.
Verified on real Cloudflare: 150 heavy pages under a dynamic `[locale]`
root went from eager (~450ms startup) to ~22ms, and SSG via the `[locale]`
layout's generateStaticParams still prerenders correctly.
Tests: prerender + prerender-route-params + prerender-endpoints + features +
prod/dev server + interception + actions all green; new
app-prerender-static-params unit tests cover the lazy-source path and null
sentinel; entry-templates updated for all-lazy pages and `{ load }` sources.
|
Pushed The prior cut had a real gap (thanks for the push on this): dynamic-route pages stayed eager because their Now:
Verified on real Cloudflare: 150 heavy pages under a dynamic Tests: prerender + prerender-route-params + prerender-endpoints + features + prod/dev server + interception + actions all green (699 in the broad batch); added |
|
/bigbonk re-review for issues |
There was a problem hiding this comment.
/bigbonk re-review — lazy-load all page modules, including dynamic routes
Re-reviewed at ad2d915, which is a substantive new change since my last LGTM: dynamic-route pages (and everything nested under a dynamic segment) are now lazy too, and generateStaticParamsMap was reworked to embed lazy { load } page sources resolved at prerender time. I focused this pass on the new prerender static-params resolver path and the SSG-parity claim, since that's where the risk moved.
Conclusion: no correctness blocker. The SSG/output: export sentinel is preserved and the prerender suites confirm it end-to-end.
Verified correct at this commit
-
Null-sentinel parity holds across the consumer. The behavioral shift is that
createAppPrerenderStaticParamsResolvernow returnsnull(no resolver) only when there are zero sources, whereas before it returnednullwhenever no source was a function. For a dynamic route with a lazy page source but nogenerateStaticParams, the factory now returns a resolver that yieldsnullat call time (app-prerender-static-params.ts:85). I tracedhandleStaticParamsEndpoint(app-prerender-endpoints.ts:57-70): bothstaticParamsMap[pattern] == null(→jsonNullResponse(), :59) andcallAppPrerenderStaticParams(...) === null(→jsonResponse(null), :70) emit an identical"null"200 body. So the prerender driver's skip /output: exporterror path is unchanged. The only new cost is importing a dynamic page module at prerender time to discover it has nogenerateStaticParams— build-time only, acceptable. -
Only dynamic routes get a resolver entry.
buildGenerateStaticParamsEntriesshort-circuits onif (!route.isDynamic) continue;(app-rsc-manifest.ts:367), so purely-static routes never get agenerateStaticParamsMapentry →staticParamsMap[pattern]isundefined→app-prerender-endpoints.ts:58returns null without importing anything. No extra eager work for static apps. -
Source composition order is preserved. The resolver now partitions
sourcesinto[...eagerFns, ...lazyFns](app-prerender-static-params.ts:72) rather than the original order-preservingsources.filter. This is safe for the current emission because, per route, layout sources are appended (to their prefix patterns) before the page source (toroute.pattern) inbuildGenerateStaticParamsEntries:369-387, and for any pattern that collects both, layout-before-page ordering is the only ordering produced — so eager-then-lazy reconstructs the same hierarchical order. (Minor robustness note below.) -
Lazy
{ load }and route-table__loadPageshare one loader.getLazyLoaderVaris deduped vialazyMap(app-rsc-manifest.ts:118-132), so the page module's loader is reused by both the route table and the static-params source — oneimport()per module, not two. -
Central match hydration still covers the page read.
ensureRouteLoaded(route)is awaited atapp-rsc-handler.ts:678before theroute.routeHandlerbranch (:696) and beforedispatchMatchedPagereadsroute.page?.default(app-rsc-entry.ts:593). The all-lazy change didn't move any read ahead of this gate. -
Parallel-slot pages stay eager (
app-rsc-manifest.ts:185usesgetImportVar), so the synchronousslot.pagereads in segment-config resolution (app-rsc-entry.ts:597) remain valid without hydration. -
Failed-import retry fix (prior round) is intact at
app-route-module-loader.ts:71-80.
Local verification
tests/app-prerender-static-params.test.ts(5),tests/app-route-module-loader.test.ts(8),tests/entry-templates.test.ts(25) — all green.tests/prerender.test.ts(79),tests/app-prerender-endpoints.test.ts+tests/prerender-route-params.test.ts(107 total) — all green, includingoutput: exportSSR-route error wiring and the Cloudflare Workers hybrid build (cf-app-basic, prerendered 9 routes / 2 skipped), which exercises the in-process lazygenerateStaticParamsMapresolver end-to-end. This is the direct evidence for the SSG-parity claim.
Minor (non-blocking)
-
Partition-and-concat is more fragile than the old order-preserving filter.
[...eagerFns, ...lazyFns]happens to match source order only because the codegen always emits layout sources before the page source for a coinciding pattern. If a future change ever appended a lazy source before an eager one for the same pattern, composition order would silently differ fromsourcesorder. Consider resolving in originalsourcesorder (e.g. map each source to either its eager fn or its awaited lazy fn, then filter) so correctness doesn't depend on the emitter's append order. Not a bug today. -
Prerender-time lazy-load failure is sticky (mirror of the runtime issue, but acceptable here).
resolveFnsmemoizesresolvePromise; if asource.load()rejects, the rejected promise is re-returned on subsequent calls andresolvedFnsnever sets. Unlike the runtime loader (which now resets on failure), this is build/prerender-time only, where an import failure is deterministic and fatal anyway, so caching it is fine. Noting for symmetry, no change needed. -
CodeQL false positive at
getLazyLoaderVaris documented inline and fine to dismiss; double-hydration on the intercept path remains intentional/idempotent.
LGTM.
|
Review posted to PR #1781. SummaryThis pass focused on the new change since my last LGTM: commit Conclusion: no correctness blocker. What I verified:
Two minor non-blocking notes: the new LGTM. |
…solver Per /bigbonk re-review: the resolver partitioned sources into [...eagerFns, ...lazyFns], which only matched the intended composition order because the codegen happens to emit layout (eager) sources before the page (lazy) source. Resolve sources in their original declared order instead — each source maps to its eager function or its awaited lazy function, then non-functions are dropped — so composition order no longer depends on the emitter's append order. Added a unit test with a lazy source ordered before an eager one.
|
Thanks @ask-bonk — addressed the non-blocking robustness note in Note 1 (source-order robustness — fixed). The resolver no longer partitions into Note 2 (sticky prerender-time load failure). Leaving as-is per your call — it's build-time and deterministic, where an import failure is fatal anyway, so memoizing it is fine. (The runtime loader still resets-and-retries.) Notes 3 (CodeQL false positive documented inline; double-hydration intentional/idempotent) — no change. Prerender suites (112) + resolver unit tests (6) green. |
|
/bigbonk re-review for issues |
|
APIError: Function tools with reasoning_effort are not supported for gpt-5.5 in /v1/chat/completions. Please use /v1/responses instead. |
There was a problem hiding this comment.
/bigbonk re-review — lazy-load all App Router page modules + source-order resolver fix
Re-reviewed at 7516530 (latest), which adds the only new change since my prior LGTM: the prerender static-params resolver no longer partitions sources into [...eagerFns, ...lazyFns] — it now resolves each source in its original declared order. I re-traced the hydration sites against the all-lazy-pages change and ran the relevant suites locally.
Conclusion: no correctness blocker. LGTM.
Source-order fix verified (the one new change)
createAppPrerenderStaticParamsResolver (app-prerender-static-params.ts:53-87) now filters sources to usableSources (preserving order), then in resolveFns maps each source to its eager fn or awaited lazy fn and drops non-functions (.filter(fn !== null)). Composition no longer depends on the codegen emitting layout (eager) sources before the page (lazy) source for a coinciding pattern. The new unit test composes sources in declared order regardless of eager/lazy kind (lazy ordered before eager) locks it in. The prior [...eagerFns, ...lazyFns] fragility note is resolved.
Still-correct at this commit
- Null-sentinel parity preserved. Zero usable sources →
null(no resolver); a lazy page source with nogenerateStaticParams→ non-null resolver yieldingnullat call time.handleStaticParamsEndpointemits identical"null"for bothstaticParamsMap[pattern] == nulland a resolver returningnull, so theoutput: export/ skip path is unchanged. Confirmed end-to-end by the Cloudflare Workers hybrid build inprerender.test.ts(9 prerendered / 2 skipped). - All-lazy hydration sites all
awaitbefore anyroute.page/route.routeHandlerread. Re-traced every one for the now-lazy dynamic routes:- Central match:
app-rsc-handler.ts:678before the route-handler branch (:696) anddispatchMatchedPage'sroute.page?.default(entry:593). buildPageElementswrapper:app-rsc-entry.ts:493.- Redirect path (
app-server-action-execution.ts):targetMatch.routehydrated at :1037 coverscanRenderActionRedirectTarget(:1038, reads.routeHandler/.page),resolveRouteFetchCacheMode(:1063),buildServerActionPageTags/buildPageElement(:1064-65), andresolveRouteRuntime(targetRoute)(:1089);currentMatch.routehydrated at :1047 coversresolveRouteRuntime(currentRoute)(:1088). - Re-render target: :1174 before
resolveRouteFetchCacheMode(:1176);resolveAppPageActionRerenderTarget(:1157) reads only staticroute.paramspre-hydration. - Intercept + ISR
getSourceRoutetargets:app-page-dispatch.ts:566and :719 before theirresolveRouteFetchCacheModereads. handleServerActionRequest: entry:795 before theroute.pageread at :797.
- Central match:
- Failed-import retry fix intact (
app-route-module-loader.ts:71-80):.catchclears__loading, leaves__loadedfalse, re-throws; rejection-path test present. - Static apps pay nothing:
buildGenerateStaticParamsEntriesshort-circuits non-dynamic routes; lazy{ load }page source dedups with the route table's__loadPagevialazyMap(oneimport()per module). - Parallel-slot pages stay eager (
getImportVar), so synchronousslot.pagereads (entry:597) remain valid; metadata routes register their own eagergetImportVarand read fromimportMap, unaffected by lazy page loading. - CodeQL alert at
getLazyLoaderVaris a documented false positive (JSON-encoded filesystem-scan path; same trust model as the eagerimport * as). Fine to dismiss.
Local verification
app-route-module-loader(8),app-prerender-static-params(6, incl. the new declared-order test),entry-templates(25) — 39 passing.prerender+app-prerender-endpoints+prerender-route-params(107) — green, incl. the CF hybrid build exercising the lazy resolver end-to-end.app-router-production-server+app-router-worker-entry+app-page-dispatch(54) — green, incl. route-handler ISR STALE/background-regen (lazy route-handler hydration end-to-end).features(311) andapp-server-action-execution(58) — green.vp checkon all 5 changed source files — clean.
LGTM.
|
Review posted to PR #1781. SummaryRe-reviewed at Conclusion: no correctness blocker. LGTM. What I verified:
Local runs all green: loader (8), static-params (6), entry-templates (25), prerender suites (107, incl. CF hybrid build), prod-server + worker-entry + dispatch (54), features (311), action execution (58); |
Summary
The App Router RSC entry statically imported every route module, so they were all evaluated at Worker startup. For typical pages this is cheap, but routes with expensive module-level initialization make startup scale linearly and can exceed Cloudflare's ~400ms startup CPU budget.
This makes lazy route-module loading the default: static-route page modules and all route-handler modules are emitted as
() => import()thunks instead ofimport * as, so they're code-split out of the entry's top-level evaluation and loaded on demand for the matched route only.Measured impact (real
wrangler deploy→ "Worker Startup Time",vinextaccount)Typical pages — startup is flat regardless of route count, ~0.013 ms/route (noise):
index.jsHeavy module-level init (each page builds a large lookup table at import) — this is the failure mode lazy loading fixes:
Typical apps pay nothing; heavy apps stop blowing the startup budget.
How it works
app-rsc-manifest.ts): newgetLazyLoaderVaremitsconst load_N = () => import(path). Static-route pages and all route handlers use it; route table fields becomepage/__loadPageandrouteHandler/__loadRouteHandler.app-route-module-loader.ts):ensureAppRouteModulesLoaded(route)hydrates the lazy modules onto the synchronouspage/routeHandlerfields. Idempotent, and dedups concurrent loads into a single import.app-rsc-handler.ts) and at every mid-flight route lookup that reads a module beforebuildPageElement— server-action redirect/rerender targets (app-server-action-execution.ts), interception + ISR revalidation source routes (app-page-dispatch.ts),buildPageElements, andhandleServerActionRequest.Scope / follow-up
generateStaticParamsis referenced in the module-levelgenerateStaticParamsMap. Making those lazy needs a prerender static-params resolver rework — tracked as a follow-up.Testing
[slug](eager), and/api/helloroute handler (lazy) all render 200; heavy-150 startup 19 ms.features(311), prod/dev server + interception + prerender + production build (316), route-handler + ISR + worker-entry + codegen (142), app-page consumers (162), action/dispatch units (81),entry-templates(25, updated), newapp-route-module-loaderunit tests (7).vp checkclean.🤖 Generated with opencode