mirror of
https://github.com/cloudflare/vinext.git
synced 2026-09-14 19:04:59 +08:00
eca6d3fe83
* 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.
118 lines
4.2 KiB
TypeScript
118 lines
4.2 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
import {
|
|
ensureAppRouteModulesLoaded,
|
|
type LazyLoadableRoute,
|
|
} from "../packages/vinext/src/server/app-route-module-loader.js";
|
|
|
|
describe("ensureAppRouteModulesLoaded", () => {
|
|
it("returns the route synchronously when there are no lazy thunks (eager route)", () => {
|
|
const pageModule = { default: () => null };
|
|
const route: LazyLoadableRoute = { page: pageModule };
|
|
|
|
const result = ensureAppRouteModulesLoaded(route);
|
|
|
|
// No promise — eager routes resolve synchronously.
|
|
expect(result).toBe(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.__loaded).toBe(true);
|
|
});
|
|
|
|
it("hydrates a lazy page module onto route.page", async () => {
|
|
const pageModule = { default: () => null, generateMetadata: () => ({}) };
|
|
const __loadPage = vi.fn(async () => pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
const loaded = await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(loaded).toBe(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.routeHandler).toBeUndefined();
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("hydrates a lazy route-handler module onto route.routeHandler", async () => {
|
|
const handlerModule = { GET: () => new Response("ok") };
|
|
const __loadRouteHandler = vi.fn(async () => handlerModule);
|
|
const route: LazyLoadableRoute = { routeHandler: null, __loadRouteHandler };
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.routeHandler).toBe(handlerModule);
|
|
});
|
|
|
|
it("loads both page and route handler in parallel", async () => {
|
|
const pageModule = { default: () => null };
|
|
const handlerModule = { POST: () => new Response() };
|
|
const route: LazyLoadableRoute = {
|
|
page: null,
|
|
routeHandler: null,
|
|
__loadPage: async () => pageModule,
|
|
__loadRouteHandler: async () => handlerModule,
|
|
};
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
|
|
expect(route.page).toBe(pageModule);
|
|
expect(route.routeHandler).toBe(handlerModule);
|
|
});
|
|
|
|
it("is idempotent: a second call does not re-import", async () => {
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi.fn(async () => pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
await ensureAppRouteModulesLoaded(route);
|
|
const second = ensureAppRouteModulesLoaded(route);
|
|
|
|
// Already loaded → returns the route synchronously (not a promise).
|
|
expect(second).toBe(route);
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("dedups concurrent calls into a single import", async () => {
|
|
let resolveImport: (mod: unknown) => void = () => {};
|
|
const importPromise = new Promise((resolve) => {
|
|
resolveImport = resolve;
|
|
});
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi.fn(() => importPromise);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
const a = ensureAppRouteModulesLoaded(route);
|
|
const b = ensureAppRouteModulesLoaded(route);
|
|
|
|
// Both callers observe the same in-flight promise.
|
|
expect(a).toBe(b);
|
|
resolveImport(pageModule);
|
|
await Promise.all([a, b]);
|
|
|
|
expect(__loadPage).toHaveBeenCalledTimes(1);
|
|
expect(route.page).toBe(pageModule);
|
|
});
|
|
|
|
it("does not cache a failed import: re-throws and retries on the next call", async () => {
|
|
const pageModule = { default: () => null };
|
|
const __loadPage = vi
|
|
.fn()
|
|
.mockRejectedValueOnce(new Error("chunk load failed"))
|
|
.mockResolvedValueOnce(pageModule);
|
|
const route: LazyLoadableRoute = { page: null, __loadPage };
|
|
|
|
// First call rejects and the rejection propagates to the caller.
|
|
await expect(ensureAppRouteModulesLoaded(route)).rejects.toThrow("chunk load failed");
|
|
// The failure is not stuck: state is reset for a retry.
|
|
expect(route.__loaded).toBeFalsy();
|
|
expect(route.__loading).toBeNull();
|
|
|
|
// Next call retries the import and succeeds.
|
|
await ensureAppRouteModulesLoaded(route);
|
|
expect(route.page).toBe(pageModule);
|
|
expect(__loadPage).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("tolerates null / undefined routes", () => {
|
|
expect(ensureAppRouteModulesLoaded(null)).toBeNull();
|
|
expect(ensureAppRouteModulesLoaded(undefined)).toBeUndefined();
|
|
});
|
|
});
|