Files
cloudflare__vinext/tests/nextjs-compat/react-max-headers-length.test.ts
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

97 lines
3.8 KiB
TypeScript

/**
* Next.js Compatibility Tests: reactMaxHeadersLength
*
* Ported from Next.js: test/e2e/app-dir/react-max-headers-length/react-max-headers-length.test.ts
* https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/react-max-headers-length/react-max-headers-length.test.ts
*
* `reactMaxHeadersLength` caps the total length of the preload `Link` header
* emitted during App Router SSR. React drops whole entries once the limit is
* exceeded (default 6000; `0` disables emission entirely). See
* cloudflare/vinext#1552.
*
* The upstream fixture asserts against React `ReactDOM.preload()` hints. vinext
* emits its preload `Link` header from the App Router font pipeline, so this
* route owns a `next/font` layout fixture and exercises the same
* cap/truncation contract against that header.
*/
import { describe, it, expect, afterEach } from "vite-plus/test";
import type { ViteDevServer } from "vite-plus";
import { APP_FIXTURE_DIR, startFixtureServer } from "../helpers.js";
const ROUTE = "/nextjs-compat/react-max-headers-length";
/** Split a `Link` header into its individual entries. */
function linkEntries(header: string): string[] {
return header.split(", ").filter((entry) => entry.length > 0);
}
describe("Next.js compat: reactMaxHeadersLength", () => {
let server: ViteDevServer | undefined;
afterEach(async () => {
await server?.close();
server = undefined;
delete process.env.TEST_REACT_MAX_HEADERS_LENGTH;
});
async function fetchLinkHeader(value: number | undefined): Promise<string | null> {
if (typeof value === "number") {
process.env.TEST_REACT_MAX_HEADERS_LENGTH = String(value);
} else {
delete process.env.TEST_REACT_MAX_HEADERS_LENGTH;
}
const started = await startFixtureServer(APP_FIXTURE_DIR, { appRouter: true });
server = started.server;
const res = await fetch(`${started.baseUrl}${ROUTE}`);
expect(res.status).toBe(200);
return res.headers.get("Link");
}
it("emits an uncapped Link header when reactMaxHeadersLength is unset", async () => {
const header = await fetchLinkHeader(undefined);
// Default cap is 6000; the fixture's font preloads stay well under it, so
// the full header is emitted.
expect(header).not.toBeNull();
expect(header!.length).toBeLessThanOrEqual(6000);
expect(header).toMatch(/rel=preload/);
}, 60_000);
it("does not emit a Link header when reactMaxHeadersLength is 0", async () => {
const header = await fetchLinkHeader(0);
expect(header).toBeNull();
}, 60_000);
it("truncates the Link header to whole entries within a small cap (200)", async () => {
const full = await fetchLinkHeader(undefined);
expect(full).not.toBeNull();
await server?.close();
server = undefined;
const capped = await fetchLinkHeader(200);
expect(capped).not.toBeNull();
// Respects the cap...
expect(capped!.length).toBeLessThanOrEqual(200);
// ...by dropping whole entries (each surviving entry is intact, never a
// partial slice of an entry).
const fullEntries = new Set(linkEntries(full!));
for (const entry of linkEntries(capped!)) {
expect(fullEntries.has(entry)).toBe(true);
}
// A smaller cap emits fewer entries than the uncapped header.
expect(linkEntries(capped!).length).toBeLessThan(fullEntries.size);
}, 60_000);
it("emits more entries for a larger cap than a smaller one", async () => {
const small = await fetchLinkHeader(200);
expect(small).not.toBeNull();
const smallCount = linkEntries(small!).length;
await server?.close();
server = undefined;
const large = await fetchLinkHeader(6000);
expect(large).not.toBeNull();
expect(large!.length).toBeLessThanOrEqual(6000);
expect(linkEntries(large!).length).toBeGreaterThanOrEqual(smallCount);
}, 60_000);
});