Commit Graph

3 Commits

Author SHA1 Message Date
James Anderson fbf68d588e fix(app-router): restore cache and parallel route parity (#2261)
* fix(app-router): preserve searchParams cache variants

* test(app-router): cover RSC metadata cache variants

* fix(app-router): isolate metadata searchParams observation

* fix(app-router): preserve static ISR cache proofs

* fix(app-router): limit queryless cache probes

* fix(app-router): skip redundant cache proof probes

* fix(app-router): harden incomplete cache proofs

* fix(app-router): observe loading page execution

* fix(app-router): preserve exotic page exports

* fix(app-router): preserve client page ISR

* fix(app-router): avoid duplicate queryless page execution

* fix(app-router): observe viewport search params

* fix(app-router): clear force-static search params

* fix(app-router): isolate intercept static context

* fix(app-router): preserve intercept request context

* fix(app-router): isolate action static context

* fix(app-router): match static request API semantics

* fix(app-router): preserve dynamic segment constraints

* fix(app-router): apply slot-only segment config

* fix(app-router): preserve draft mode reads in static errors

* fix(app-router): include nested slot constraints

* fix(app-router): refresh slot layout constraints

* fix(app-router): render nested slot layouts

* fix(app-router): preserve nested slot params

* fix(app-router): preserve intercepted slot head

* fix(app-router): scope parallel layout params

* fix(app-router): preserve intercept layout parity

* fix(app-router): preserve intercept ancestor params

* fix(app-router): scope intercept source params

* fix(app-router): preserve intercept source route params

* test(app-router): stabilize parity validation
2026-06-22 23:17:41 +01:00
Nathan Nguyen 301418a3f1 perf(app-router): defer layout, template and boundary modules to first request (#2145)
* perf(app-router): defer layout, template and boundary modules to first request

The generated RSC entry statically imported every route's layout, template,
loading/error/not-found/forbidden/unauthorized boundary and parallel-slot
module at the top level. On a cold isolate — and on the dev server's first
request — that eagerly evaluated the whole app's segment tree, including
routes never visited in that lifetime. Page and route-handler modules were
already lazy; the surrounding segment modules were not.

Emit those modules as lazy `() => import()` loaders paired with `null`
placeholders in the manifest, and hydrate them onto the route's synchronous
fields in `ensureAppRouteModulesLoaded` before any read (extended from
page/handler to layouts, templates, boundaries and parallel-slot modules).
Intercept layouts hydrate via the dedicated `loadAppInterceptLayouts` because
they live on the match object, not the route. Every reader already runs behind
`ensureAppRouteModulesLoaded` (handler dispatch and `buildPageElements`) or the
intercept resolvers, so no synchronous module field is read before hydration.

The shared intercept `interceptLayouts` arrays keep their `readonly` public
types; the loader is their sole writer and fills slots through one
boundary-local cast, so route metadata stays immutable to callers.

Dev cold start is unchanged on page-heavy apps — pages were already lazy — but
on a boundary-heavy app (80 independent sections, each with
layout+template+error+loading+not-found) first-request module compile drops
704ms -> 534ms (~24%) and time-to-first-200 1917ms -> 1665ms. The saving scales
with the number of unrelated segments deferred.

* test(app-router): localize react max header font fixture

* fix(app-router): dedupe lazy intercept module loads

* fix(app-router): share intercept load state across matches

* docs(app-router): explain root layout loader reuse

---------

Co-authored-by: James <james@eli.cx>
2026-06-19 14:49:29 +01:00
James Anderson eca6d3fe83 perf(router): lazy-load App Router page and route-handler modules (#1781)
* feat(router): lazy-load App Router page and route-handler modules

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.

* fix(router): retry failed lazy route-module imports instead of caching 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.

* docs(router): clarify why route handlers can always be lazy-loaded

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).

* feat(router): lazy-load all page modules, including dynamic routes

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.

* refactor(router): preserve source order in prerender static-params resolver

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.
2026-06-07 00:18:42 +01:00