Files
cloudflare__vinext/tests/app-router-isr-codegen.test.ts
T
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

218 lines
8.3 KiB
TypeScript

import { describe, expect, it } from "vitest";
import { generateRscEntry } from "../packages/vinext/src/entries/app-rsc-entry.js";
import type { AppRoute } from "../packages/vinext/src/routing/app-router.js";
describe("generateRscEntry ISR code generation", () => {
// Minimal route list — only the generated ISR guard logic matters here
const minimalRoutes = [
{
pattern: "/",
pagePath: "/tmp/test/app/page.tsx",
routePath: null,
layouts: ["/tmp/test/app/layout.tsx"],
templates: [],
parallelSlots: [],
loadingPath: null,
errorPath: null,
layoutErrorPaths: [null],
notFoundPath: null,
forbiddenPaths: [],
forbiddenPath: null,
unauthorizedPaths: [],
unauthorizedPath: null,
routeSegments: [],
layoutTreePositions: [0],
isDynamic: false,
params: [],
},
] as any[];
it('generated code contains process.env.NODE_ENV === "production" guard for ISR cache read', () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
expect(code).toContain('process.env.NODE_ENV === "production"');
});
it("generated handler delegates request and ctx handling to createAppRscHandler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
expect(code).toContain("createAppRscHandler");
expect(code).toContain("export default createAppRscHandler({");
});
it("configures the cache through the lightweight handler runtime", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
expect(code).toContain(
'configureMemoryCacheHandler as __configureMemoryCacheHandler } from "vinext/shims/cache-handler"',
);
expect(code).not.toContain(
'configureMemoryCacheHandler as __configureMemoryCacheHandler } from "next/cache"',
);
});
it("generated code stores root layout params separately from leaf params", () => {
const routes = [
{
...minimalRoutes[0],
pattern: "/[lang]/[locale]/other/[slug]",
patternParts: [":lang", ":locale", "other", ":slug"],
params: ["lang", "locale", "slug"],
rootParamNames: ["lang", "locale"],
routeSegments: ["[lang]", "[locale]", "other", "[slug]"],
layoutTreePositions: [2],
},
] as any[];
const code = generateRscEntry("/tmp/test/app", routes);
// The user-declared rootParamNames must flow through to the route's entry,
// narrower than the full leaf params list. The typed RSC handler owns
// setting the per-request root params from this route shape.
expect(code).toContain('rootParamNames: ["lang","locale"]');
expect(code).not.toContain('rootParamNames: ["lang","locale","slug"]');
expect(code).toContain("rootParamNamesByPattern: rootParamNamesMap");
expect(code).not.toContain("__setRootParams(__pickRootParams(params, route.rootParamNames));");
expect(code).toContain("clearAppRequestContext as __clearRequestContext");
expect(code).toContain("server/app-request-context.js");
expect(code).not.toContain("function __clearRequestContext() {");
});
it("root params runtime getter returns current request values", async () => {
const { getRootParam, pickRootParams, setRootParams } =
await import("../packages/vinext/src/shims/root-params.js");
expect(pickRootParams({ lang: "en", locale: "us", slug: "post" }, ["lang", "locale"])).toEqual({
lang: "en",
locale: "us",
});
setRootParams({ lang: "en", locale: "us" });
await expect(getRootParam("lang")).resolves.toBe("en");
await expect(getRootParam("locale")).resolves.toBe("us");
await expect(getRootParam("slug")).resolves.toBeUndefined();
setRootParams(null);
});
it("generated code threads intercept layout modules through slot overrides", () => {
const routeWithInterceptLayouts: AppRoute = {
errorPath: null,
forbiddenPaths: [],
forbiddenPath: null,
isDynamic: false,
layoutErrorPaths: [null],
layouts: ["/tmp/test/app/layout.tsx"],
layoutTreePositions: [0],
loadingPath: null,
notFoundPath: null,
notFoundPaths: [null],
pagePath: "/tmp/test/app/page.tsx",
parallelSlots: [
{
defaultPath: "/tmp/test/app/@modal/default.tsx",
errorPath: null,
interceptingRoutes: [
{
convention: ".",
layoutPaths: ["/tmp/test/app/@modal/(.)explicit-layout/layout.tsx"],
pagePath: "/tmp/test/app/@modal/(.)explicit-layout/deeper/page.tsx",
params: [],
targetPattern: "/explicit-layout/deeper",
sourceMatchPattern: "/",
},
],
key: "modal@@modal",
layoutIndex: 0,
layoutPath: "/tmp/test/app/@modal/layout.tsx",
loadingPath: null,
name: "modal",
hasPage: false,
ownerDir: "/tmp/test/app/@modal",
ownerTreePath: "/",
pagePath: null,
routeSegments: null,
},
],
params: [],
pattern: "/",
patternParts: [],
routePath: null,
routeSegments: [],
templates: [],
templateTreePositions: [],
unauthorizedPaths: [],
unauthorizedPath: null,
siblingIntercepts: [],
};
const code = generateRscEntry("/tmp/test/app", [routeWithInterceptLayouts]);
// Intercept-layout modules are wired into the route's intercept entry as
// lazy loaders: `interceptLayouts` holds `null` placeholders and the
// `__loadInterceptLayouts` array carries the `load_N` import thunks
// (load_N/mod_N are the generator's alias schemes — a module reference, not
// the original layout path string).
expect(code).toContain("interceptLayouts: [null]");
expect(code).toMatch(/__loadInterceptLayouts:\s*\[load_\d+\]/);
expect(code).not.toMatch(/interceptLayouts:\s*\[\s*"\/tmp\/test\/app/);
expect(code).not.toMatch(/__loadInterceptLayouts:\s*\[\s*"\/tmp\/test\/app/);
});
it("generated code seeds root params around prerender generateStaticParams", () => {
const routeWithRootParams: AppRoute = {
errorPath: null,
forbiddenPath: null,
forbiddenPaths: [],
isDynamic: true,
layoutErrorPaths: [null],
layouts: ["/tmp/test/app/[locale]/layout.tsx"],
layoutTreePositions: [1],
loadingPath: null,
notFoundPath: null,
notFoundPaths: [null],
pagePath: "/tmp/test/app/[locale]/blog/[slug]/page.tsx",
parallelSlots: [],
params: ["locale", "slug"],
pattern: "/:locale/blog/:slug",
patternParts: [":locale", "blog", ":slug"],
rootParamNames: ["locale"],
routePath: null,
routeSegments: ["[locale]", "blog", "[slug]"],
templates: [],
templateTreePositions: [],
unauthorizedPaths: [],
unauthorizedPath: null,
siblingIntercepts: [],
};
const code = generateRscEntry("/tmp/test/app", [routeWithRootParams]);
// The user-declared dynamic-segment names must flow into the generated
// entry so prerender static-params know which params are root-scoped.
expect(code).toContain('"/:locale/blog/:slug"');
expect(code).toContain('["locale"]');
});
it("generated code exposes prerender cache seeding from the RSC module graph", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
expect(code).toMatch(/await import\("[^"]*seed-cache\.js"\)/);
expect(code).not.toMatch(
/import \{\s*seedMemoryCacheFromPrerender as __seedMemoryCacheFromPrerender/,
);
expect(code).toContain("isrSetPrerenderedAppPage as __isrSetPrerenderedAppPage");
expect(code).toContain("export async function seedMemoryCacheFromPrerender(serverDir)");
expect(code).toContain("buildAppPageHtmlKey(pathname)");
expect(code).toContain("return __isrHtmlKey(pathname)");
expect(code).toContain("buildAppPageRscKey(pathname)");
expect(code).toContain("return __isrRscKey(pathname)");
expect(code).toContain("writeAppPageEntry(key, data, metadata)");
expect(code).toContain("return __isrSetPrerenderedAppPage(key, data, metadata)");
});
it("generated code delegates server-action header handling to the typed handler", () => {
const code = generateRscEntry("/tmp/test/app", minimalRoutes);
expect(code).toContain("handleServerActionRequest({");
expect(code).toContain("actionId,");
});
});