Files
cloudflare__vinext/tests/layout-classification.test.ts
Nathan Nguyen bc03bf1c71 feat(build): wire build-time layout classification into RSC entry (#842)
* feat(build): wire build-time layout classification into the generated RSC entry

Introduce a Rollup generateBundle hook that patches the __VINEXT_CLASS
stub in the generated RSC entry with a real dispatch table built from
Layer 1 segment-config analysis and Layer 2 module-graph classification.
The runtime probe loop in app-page-execution.ts consults this table and
skips the dynamic-isolation probe for layouts we proved static or
dynamic at build time.

Add route-classification-manifest.ts as the codegen glue between the
classifier and the entry template, and flow buildTimeClassifications
through renderAppPageLifecycle so the runtime probe can honor the
build-time decision. Fail loudly if generateBundle sees __VINEXT_CLASS
referenced without the recognized stub body, so generator and plugin
cannot silently drift.

* refactor(build): address Copilot review comments on PR 842

Three targeted cleanups from code review:

- Replace inline canonicalize() with tryRealpathSync(p) ?? p, which is
  already imported and does the same thing (realpathSync.native + fallback)

- Memoize readFileSync calls in collectRouteClassificationManifest so
  shared layouts (especially the root layout) are read from disk only once
  per build rather than once per route

- Cache classifyLayoutByModuleGraph results by canonicalized module ID in
  generateBundle so shared layouts are BFS-traversed only once per build
  rather than once per (routeIdx, layoutIdx) pair

* refactor: address review comments on build classification PR

- Use layer2.keys() instead of destructuring with unused _value
- Add comment to stub regex explaining intentional semicolon tolerance
- Add comment to __buildTimeClassifications clarifying module-load evaluation
- Add coupling comment to integration test regex explaining non-greedy assumption

* test: update entry-templates snapshots for module-load comment

* refactor: address second round of review comments

- Expand stubRe comment to note __VINEXT_CLASS name-retention assumption
- Null out rscClassificationManifest after generateBundle consumes it
- Fix buildGenerateBundleReplacement docstring to reflect type-level guarantee

* refactor: address third round of review comments

- Remove routeIdx from generated route objects (dead runtime property,
  only needed as codegen loop variable for __VINEXT_CLASS(N) call)
- Null target.map after patching target.code in generateBundle
- Add Infinity-before-JSON.stringify comment in serializeReasonExpression
- Fix integration test stubRe to tolerate optional semicolon like the plugin
- Update extractRouteIndexByPattern to anchor on __VINEXT_CLASS(N) instead
  of the removed routeIdx property
- Update snapshots

* refactor: address subagent review findings

- Mark classifyAllRouteLayouts as @internal test-only (production code
  calls classifyLayoutByModuleGraph directly via the manifest pipeline)
- Document unreachable runtime-probe/no-classifier arms in
  serializeReasonExpression with explanation of why narrowing is deferred
- Add manifest lifecycle invariant comment to load hook explaining the
  1:1 correspondence between manifest layout indices and codegen routes
- Add test for layer1/layer1Reasons mismatch guard in mergeLayersForRoute

* refactor: hoist build-time classification helpers and rename layer1 binding

`canonicalize` and `dynamicShimPaths` were constructed inside the RSC
generateBundle hook, so they were rebuilt on every invocation even though
shimsDir is fixed for the plugin's lifetime and tryRealpathSync is
deterministic against the package's own shim files. The cost is small
because generateBundle runs once per build, but the hook body grew long
enough that hoisting the closure and the shim-path Set up next to the
shimsDir definition makes the bundle hook read more like the request-time
behaviour it is wiring up.

Separately, mergeLayersForRoute destructured Layer 1 entries as
[layoutIdx, value] and then wrote { kind: value, reason }. Since the
binding is always the Layer1Class kind, renaming it to `kind` lets the
merge use object-shorthand and removes the value-vs-kind shadowing.

No behaviour change; same realpath calls, same merge result, same
generated dispatch table. Targeted unit and integration suites still pass.

* Preserve module-graph reasons in classification manifest

* Share module-graph static reason type
2026-04-20 10:49:50 +01:00

277 lines
9.1 KiB
TypeScript

/**
* Layout classification tests — module graph traversal and combined
* (segment config + module graph) classification for static/dynamic
* layout detection.
*/
import { describe, expect, it } from "vite-plus/test";
import {
classifyLayoutByModuleGraph,
classifyAllRouteLayouts,
type ModuleInfoProvider,
} from "../packages/vinext/src/build/layout-classification.js";
// ─── Helpers ─────────────────────────────────────────────────────────────────
/**
* Builds a fake module graph for testing. Each key is a module ID,
* and the value lists its static and dynamic imports.
*/
function createFakeModuleGraph(
graph: Record<string, { importedIds?: string[]; dynamicImportedIds?: string[] }>,
): ModuleInfoProvider {
return {
getModuleInfo(id: string) {
const entry = graph[id];
if (!entry) return null;
return {
importedIds: entry.importedIds ?? [],
dynamicImportedIds: entry.dynamicImportedIds ?? [],
};
},
};
}
const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server"]);
// ─── classifyLayoutByModuleGraph ─────────────────────────────────────────────
describe("classifyLayoutByModuleGraph", () => {
it('returns result="static" without a shim match when layout has no dynamic imports', () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/components/nav.tsx"] },
"/components/nav.tsx": { importedIds: [] },
});
const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph);
expect(result.result).toBe("static");
expect(result.firstShimMatch).toBeUndefined();
});
it('returns result="needs-probe" with the first shim match when headers shim is imported', () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/components/auth.tsx"] },
"/components/auth.tsx": { importedIds: ["/shims/headers"] },
"/shims/headers": { importedIds: [] },
});
const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph);
expect(result.result).toBe("needs-probe");
expect(result.firstShimMatch).toBe("/shims/headers");
});
it('returns result="needs-probe" when cache shim (noStore) is imported', () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/shims/cache"] },
"/shims/cache": { importedIds: [] },
});
const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph);
expect(result.result).toBe("needs-probe");
expect(result.firstShimMatch).toBe("/shims/cache");
});
it('returns result="needs-probe" when server shim (connection) is imported', () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/lib/data.ts"] },
"/lib/data.ts": { importedIds: ["/shims/server"] },
"/shims/server": { importedIds: [] },
});
const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph);
expect(result.result).toBe("needs-probe");
expect(result.firstShimMatch).toBe("/shims/server");
});
it("handles circular imports without infinite loop", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/a.ts"] },
"/a.ts": { importedIds: ["/b.ts"] },
"/b.ts": { importedIds: ["/a.ts"] },
});
expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe(
"static",
);
});
it("detects dynamic shim through deep transitive chains", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/a.ts"] },
"/a.ts": { importedIds: ["/b.ts"] },
"/b.ts": { importedIds: ["/c.ts"] },
"/c.ts": { importedIds: ["/shims/headers"] },
"/shims/headers": { importedIds: [] },
});
const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph);
expect(result.result).toBe("needs-probe");
expect(result.firstShimMatch).toBe("/shims/headers");
});
it("follows dynamicImportedIds (dynamic import())", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": {
importedIds: [],
dynamicImportedIds: ["/lazy.ts"],
},
"/lazy.ts": { importedIds: ["/shims/headers"] },
"/shims/headers": { importedIds: [] },
});
expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe(
"needs-probe",
);
});
it('returns result="static" when module info is null (unknown module)', () => {
const graph = createFakeModuleGraph({});
expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe(
"static",
);
});
});
// ─── classifyAllRouteLayouts ─────────────────────────────────────────────────
describe("classifyAllRouteLayouts", () => {
it("segment config takes priority over module graph and carries a segment-config reason", () => {
// Layout imports headers shim, but segment config says force-static
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/shims/headers"] },
"/shims/headers": { importedIds: [] },
});
const routes = [
{
layouts: [
{
moduleId: "/app/layout.tsx",
treePosition: 0,
segmentConfig: { code: 'export const dynamic = "force-static";' },
},
],
routeSegments: ["blog"],
},
];
const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph);
expect(result.get("layout:/")).toEqual({
kind: "static",
reason: { layer: "segment-config", key: "dynamic", value: "force-static" },
});
});
it("deduplicates shared layout files across routes", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: [] },
"/app/blog/layout.tsx": { importedIds: ["/shims/headers"] },
"/shims/headers": { importedIds: [] },
});
const routes = [
{
layouts: [
{ moduleId: "/app/layout.tsx", treePosition: 0 },
{ moduleId: "/app/blog/layout.tsx", treePosition: 1 },
],
routeSegments: ["blog"],
},
{
layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }],
routeSegments: ["about"],
},
];
const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph);
// Root layout appears in both routes but should only be classified once
expect(result.get("layout:/")).toEqual({
kind: "static",
reason: { layer: "module-graph", result: "static" },
});
expect(result.get("layout:/blog")).toEqual({
kind: "needs-probe",
reason: {
layer: "module-graph",
result: "needs-probe",
firstShimMatch: "/shims/headers",
},
});
expect(result.size).toBe(2);
});
it("returns dynamic for force-dynamic segment config with a segment-config reason", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: [] },
});
const routes = [
{
layouts: [
{
moduleId: "/app/layout.tsx",
treePosition: 0,
segmentConfig: { code: 'export const dynamic = "force-dynamic";' },
},
],
routeSegments: [],
},
];
const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph);
expect(result.get("layout:/")).toEqual({
kind: "dynamic",
reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" },
});
});
it("falls through to module graph when segment config is absent", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: [] },
});
const routes = [
{
layouts: [
{
moduleId: "/app/layout.tsx",
treePosition: 0,
segmentConfig: { code: "export default function Layout() {}" },
},
],
routeSegments: [],
},
];
const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph);
expect(result.get("layout:/")).toEqual({
kind: "static",
reason: { layer: "module-graph", result: "static" },
});
});
it("classifies layouts without segment configs using module graph only", () => {
const graph = createFakeModuleGraph({
"/app/layout.tsx": { importedIds: ["/shims/cache"] },
"/shims/cache": { importedIds: [] },
});
const routes = [
{
layouts: [{ moduleId: "/app/layout.tsx", treePosition: 0 }],
routeSegments: [],
},
];
const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph);
expect(result.get("layout:/")).toEqual({
kind: "needs-probe",
reason: {
layer: "module-graph",
result: "needs-probe",
firstShimMatch: "/shims/cache",
},
});
});
});