diff --git a/packages/vinext/src/build/layout-classification-types.ts b/packages/vinext/src/build/layout-classification-types.ts new file mode 100644 index 000000000..ceb50def0 --- /dev/null +++ b/packages/vinext/src/build/layout-classification-types.ts @@ -0,0 +1,50 @@ +/** + * Shared types for the layout classification pipeline. + * + * Kept in a leaf module so both `report.ts` (which implements segment-config + * classification) and `layout-classification.ts` (which composes the full + * pipeline) can import them without forming a cycle. + * + * The wire contract between build and runtime is intentionally narrow: the + * runtime only cares about the `"static" | "dynamic"` decision for a layout. + * Reasons live in a sidecar structure so operators can trace how each + * decision was made without bloating the hot-path payload. + */ + +/** + * Structured record of which classifier layer produced a decision and what + * evidence it used. Kept as a discriminated union so each layer can carry + * its own diagnostic shape without the consumer having to fall back to + * stringly-typed `reason` fields. + */ +export type ClassificationReason = + | { + layer: "segment-config"; + key: "dynamic" | "revalidate"; + value: string | number; + } + | { + layer: "module-graph"; + result: "static" | "needs-probe"; + firstShimMatch?: string; + } + | { + layer: "runtime-probe"; + outcome: "static" | "dynamic"; + error?: string; + } + | { layer: "no-classifier" }; + +/** + * Build-time classification outcome for a single layout. Tagged with `kind` + * so callers can branch exhaustively and carry diagnostic reasons alongside + * the decision. + * + * `absent` means no classifier layer had anything to say — the caller should + * defer to the next layer (or to the runtime probe). + */ +export type LayoutBuildClassification = + | { kind: "absent" } + | { kind: "static"; reason: ClassificationReason } + | { kind: "dynamic"; reason: ClassificationReason } + | { kind: "needs-probe"; reason: ClassificationReason }; diff --git a/packages/vinext/src/build/layout-classification.ts b/packages/vinext/src/build/layout-classification.ts index e564bb529..cee4a2509 100644 --- a/packages/vinext/src/build/layout-classification.ts +++ b/packages/vinext/src/build/layout-classification.ts @@ -7,13 +7,31 @@ * * Layer 3 (probe-based runtime detection) is handled separately in * `app-page-execution.ts` at request time. + * + * Every result is carried as a `LayoutBuildClassification` tagged variant so + * operators can trace which layer produced a decision via the structured + * `ClassificationReason` sidecar without that metadata leaking onto the wire. */ import { classifyLayoutSegmentConfig } from "./report.js"; import { createAppPageTreePath } from "../server/app-page-route-wiring.js"; +import type { + ClassificationReason, + LayoutBuildClassification, +} from "./layout-classification-types.js"; + +export type { + ClassificationReason, + LayoutBuildClassification, +} from "./layout-classification-types.js"; export type ModuleGraphClassification = "static" | "needs-probe"; -export type LayoutClassificationResult = "static" | "dynamic" | "needs-probe"; + +export type ModuleGraphClassificationResult = { + result: ModuleGraphClassification; + /** First dynamic shim module ID encountered during BFS, when any. */ + firstShimMatch?: string; +}; export type ModuleInfoProvider = { getModuleInfo(id: string): { @@ -40,12 +58,16 @@ type RouteForClassification = { * BFS traversal of a layout's dependency tree. If any transitive import * resolves to a dynamic shim path (headers, cache, server), the layout * cannot be proven static at build time and needs a runtime probe. + * + * The returned object carries the classification plus the first matching + * shim module ID (when any). Operators use the shim ID via the debug + * channel to trace why a layout was flagged for probing. */ export function classifyLayoutByModuleGraph( layoutModuleId: string, dynamicShimPaths: ReadonlySet, moduleInfo: ModuleInfoProvider, -): ModuleGraphClassification { +): ModuleGraphClassificationResult { const visited = new Set(); const queue: string[] = [layoutModuleId]; let head = 0; @@ -56,7 +78,9 @@ export function classifyLayoutByModuleGraph( if (visited.has(currentId)) continue; visited.add(currentId); - if (dynamicShimPaths.has(currentId)) return "needs-probe"; + if (dynamicShimPaths.has(currentId)) { + return { result: "needs-probe", firstShimMatch: currentId }; + } const info = moduleInfo.getModuleInfo(currentId); if (!info) continue; @@ -69,7 +93,18 @@ export function classifyLayoutByModuleGraph( } } - return "static"; + return { result: "static" }; +} + +function moduleGraphReason(graphResult: ModuleGraphClassificationResult): ClassificationReason { + if (graphResult.firstShimMatch === undefined) { + return { layer: "module-graph", result: graphResult.result }; + } + return { + layer: "module-graph", + result: graphResult.result, + firstShimMatch: graphResult.firstShimMatch, + }; } /** @@ -80,13 +115,18 @@ export function classifyLayoutByModuleGraph( * * Shared layouts (same file appearing in multiple routes) are classified once * and deduplicated by layout ID. + * + * @internal Not called by production code. The `generateBundle` hook in + * `index.ts` calls `classifyLayoutByModuleGraph` directly and composes + * via the numeric-index manifest in `route-classification-manifest.ts`. + * Used only by `tests/layout-classification.test.ts`. */ export function classifyAllRouteLayouts( routes: readonly RouteForClassification[], dynamicShimPaths: ReadonlySet, moduleInfo: ModuleInfoProvider, -): Map { - const result = new Map(); +): Map { + const result = new Map(); for (const route of routes) { for (const layout of route.layouts) { @@ -97,17 +137,20 @@ export function classifyAllRouteLayouts( // Layer 1: segment config if (layout.segmentConfig) { const configResult = classifyLayoutSegmentConfig(layout.segmentConfig.code); - if (configResult !== null) { + if (configResult.kind !== "absent") { result.set(layoutId, configResult); continue; } } // Layer 2: module graph - result.set( - layoutId, - classifyLayoutByModuleGraph(layout.moduleId, dynamicShimPaths, moduleInfo), + const graphResult = classifyLayoutByModuleGraph( + layout.moduleId, + dynamicShimPaths, + moduleInfo, ); + const reason = moduleGraphReason(graphResult); + result.set(layoutId, { kind: graphResult.result, reason }); } } diff --git a/packages/vinext/src/build/report.ts b/packages/vinext/src/build/report.ts index 70b30343f..a40e0f54e 100644 --- a/packages/vinext/src/build/report.ts +++ b/packages/vinext/src/build/report.ts @@ -23,6 +23,7 @@ import fs from "node:fs"; import path from "node:path"; import type { Route } from "../routing/pages-router.js"; import type { AppRoute } from "../routing/app-router.js"; +import type { LayoutBuildClassification } from "./layout-classification-types.js"; import type { PrerenderResult } from "./prerender.js"; // ─── Types ──────────────────────────────────────────────────────────────────── @@ -609,33 +610,48 @@ function findMatchingToken( // ─── Layout segment config classification ──────────────────────────────────── -/** - * Classification result for layout segment config analysis. - * "static" means the layout is confirmed static via segment config. - * "dynamic" means the layout is confirmed dynamic via segment config. - */ -export type LayoutClassification = "static" | "dynamic"; - /** * Classifies a layout file by its segment config exports (`dynamic`, `revalidate`). * - * Returns `"static"` or `"dynamic"` when the config is decisive, or `null` - * when no segment config is present (deferring to module graph analysis). + * Returns a tagged `LayoutBuildClassification` carrying both the decision and + * the specific segment-config field that produced it. `{ kind: "absent" }` + * means no segment config is present and the caller should defer to the next + * layer (module graph analysis). * * Unlike page classification, positive `revalidate` values are not meaningful * for layout skip decisions — ISR is a page-level concept. Only the extremes * (`revalidate = 0` → dynamic, `revalidate = Infinity` → static) are decisive. */ -export function classifyLayoutSegmentConfig(code: string): LayoutClassification | null { +export function classifyLayoutSegmentConfig(code: string): LayoutBuildClassification { const dynamicValue = extractExportConstString(code, "dynamic"); - if (dynamicValue === "force-dynamic") return "dynamic"; - if (dynamicValue === "force-static" || dynamicValue === "error") return "static"; + if (dynamicValue === "force-dynamic") { + return { + kind: "dynamic", + reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" }, + }; + } + if (dynamicValue === "force-static" || dynamicValue === "error") { + return { + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: dynamicValue }, + }; + } const revalidateValue = extractExportConstNumber(code, "revalidate"); - if (revalidateValue === Infinity) return "static"; - if (revalidateValue === 0) return "dynamic"; + if (revalidateValue === Infinity) { + return { + kind: "static", + reason: { layer: "segment-config", key: "revalidate", value: Infinity }, + }; + } + if (revalidateValue === 0) { + return { + kind: "dynamic", + reason: { layer: "segment-config", key: "revalidate", value: 0 }, + }; + } - return null; + return { kind: "absent" }; } // ─── Route classification ───────────────────────────────────────────────────── diff --git a/packages/vinext/src/build/route-classification-manifest.ts b/packages/vinext/src/build/route-classification-manifest.ts new file mode 100644 index 000000000..b6f637f82 --- /dev/null +++ b/packages/vinext/src/build/route-classification-manifest.ts @@ -0,0 +1,246 @@ +/** + * Build-time layout classification manifest. + * + * Bridges the classifier in `./layout-classification.ts` with the RSC entry + * codegen so that the per-layout static/dynamic classifications produced at + * build time are visible to the runtime probe loop in + * `server/app-page-execution.ts`. + * + * The runtime probe looks up entries by numeric `layoutIndex`, so this module + * is responsible for flattening the classifier's string-keyed layout IDs into + * a per-route, index-keyed structure that can be emitted from codegen. + */ + +import fs from "node:fs"; +import type { AppRoute } from "../routing/app-router.js"; +import type { + ClassificationReason, + LayoutBuildClassification, +} from "./layout-classification-types.js"; +import { classifyLayoutSegmentConfig } from "./report.js"; + +export type Layer1Class = "static" | "dynamic"; + +export type RouteManifestEntry = { + /** Route pattern for diagnostics (e.g. "/blog/:slug"). */ + pattern: string; + /** Absolute file paths for each layout, ordered root → leaf. */ + layoutPaths: string[]; + /** Layer 1 (segment config) results keyed by numeric layout index. */ + layer1: Map; + /** + * Structured reasons for every Layer 1 decision, keyed by the same layout + * index. Always populated in lockstep with `layer1` so the debug channel + * can surface which segment-config field produced the decision. + */ + layer1Reasons: Map; +}; + +export type RouteClassificationManifest = { + routes: RouteManifestEntry[]; +}; + +/** + * Reads each layout's source at build time and runs Layer 1 segment-config + * classification. Fails loudly if any layout file is missing — a missing + * layout means the routing scan and the filesystem have drifted, and shipping + * a build in that state would silently break layout rendering. + */ +export function collectRouteClassificationManifest( + routes: readonly AppRoute[], +): RouteClassificationManifest { + const manifestRoutes: RouteManifestEntry[] = []; + const sourceCache = new Map(); + + for (const route of routes) { + const layer1 = new Map(); + const layer1Reasons = new Map(); + + for (let layoutIndex = 0; layoutIndex < route.layouts.length; layoutIndex++) { + const layoutPath = route.layouts[layoutIndex]!; + let source = sourceCache.get(layoutPath); + if (source === undefined) { + try { + source = fs.readFileSync(layoutPath, "utf8"); + sourceCache.set(layoutPath, source); + } catch (cause) { + throw new Error( + `vinext: failed to read layout for route ${route.pattern} at ${layoutPath}`, + { cause }, + ); + } + } + const result = classifyLayoutSegmentConfig(source); + if (result.kind === "static" || result.kind === "dynamic") { + layer1.set(layoutIndex, result.kind); + layer1Reasons.set(layoutIndex, result.reason); + } + } + + manifestRoutes.push({ + pattern: route.pattern, + layoutPaths: [...route.layouts], + layer1, + layer1Reasons, + }); + } + + return { routes: manifestRoutes }; +} + +/** + * Merge output entry. `mergeLayersForRoute` never emits the `absent` variant + * of `LayoutBuildClassification`, so this narrows the type and lets + * downstream callers read `.reason` without branching on `kind`. + */ +type MergedLayoutClassification = Exclude; + +/** + * Merges Layer 1 (segment config) and Layer 2 (module graph) into a single + * per-route map, applying the Layer-1-wins priority rule. + * + * Layer 1 always takes priority over Layer 2 for the same layout index: + * segment config is a user-authored guarantee, so a layout that explicitly + * says `force-dynamic` must never be demoted to "static" because its module + * graph happened to be clean. + */ +function mergeLayersForRoute( + route: RouteManifestEntry, + layer2: ReadonlyMap | undefined, +): Map { + const merged = new Map(); + + if (layer2) { + for (const layoutIdx of layer2.keys()) { + merged.set(layoutIdx, { + kind: "static", + reason: { layer: "module-graph", result: "static" }, + }); + } + } + + for (const [layoutIdx, value] of route.layer1) { + const reason = route.layer1Reasons.get(layoutIdx); + if (reason === undefined) { + throw new Error( + `vinext: layout ${layoutIdx} in route ${route.pattern} has a Layer 1 decision without a reason`, + ); + } + merged.set(layoutIdx, { kind: value, reason }); + } + + return merged; +} + +function serializeReasonExpression(reason: ClassificationReason): string { + switch (reason.layer) { + case "segment-config": { + // Infinity must be checked first: JSON.stringify(Infinity) produces "null". + const value = reason.value === Infinity ? "Infinity" : JSON.stringify(reason.value); + return `{ layer: "segment-config", key: ${JSON.stringify(reason.key)}, value: ${value} }`; + } + case "module-graph": { + const props = [`layer: "module-graph"`, `result: ${JSON.stringify(reason.result)}`]; + if (reason.firstShimMatch !== undefined) { + props.push(`firstShimMatch: ${JSON.stringify(reason.firstShimMatch)}`); + } + return `{ ${props.join(", ")} }`; + } + // The two arms below are not reachable from the current build-time pipeline + // (only segment-config and module-graph reasons flow into this function). + // They are present for type exhaustiveness and so that #843's debug sidecar + // can extend this function to cover all ClassificationReason variants without + // a separate serializer. Narrowing the parameter type to only the build-time + // variants requires propagating through LayoutBuildClassification in report.ts; + // deferred to a follow-up. + case "runtime-probe": { + const props = [`layer: "runtime-probe"`, `outcome: ${JSON.stringify(reason.outcome)}`]; + if (reason.error !== undefined) { + props.push(`error: ${JSON.stringify(reason.error)}`); + } + return `{ ${props.join(", ")} }`; + } + case "no-classifier": + return `{ layer: "no-classifier" }`; + } +} + +/** + * Builds a JavaScript arrow-function expression that dispatches route index + * to a pre-computed `Map` of build-time + * classifications. The returned string is suitable for embedding into the + * generated RSC entry via `generateBundle`. + * + * `layer2PerRoute` is typed to only carry `"static"` entries — the + * module-graph classifier can only prove static, so "needs-probe" results + * are omitted by the caller before this map is constructed. + */ +export function buildGenerateBundleReplacement( + manifest: RouteClassificationManifest, + layer2PerRoute: ReadonlyMap>, +): string { + const cases: string[] = []; + + for (let routeIdx = 0; routeIdx < manifest.routes.length; routeIdx++) { + const route = manifest.routes[routeIdx]!; + const merged = mergeLayersForRoute(route, layer2PerRoute.get(routeIdx)); + + if (merged.size === 0) continue; + + const entries = [...merged.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([idx, value]) => `[${idx}, ${JSON.stringify(value.kind)}]`) + .join(", "); + cases.push(` case ${routeIdx}: return new Map([${entries}]);`); + } + + return [ + "(routeIdx) => {", + " switch (routeIdx) {", + ...cases, + " default: return null;", + " }", + " }", + ].join("\n"); +} + +/** + * Sibling of `buildGenerateBundleReplacement`: emits a dispatch function + * that returns `Map` per route. + * + * The runtime consults this map only when `VINEXT_DEBUG_CLASSIFICATION` is + * set, and the plugin only patches this dispatcher into the built bundle when + * that env var is present at build time. + * + * Layer 1 priority applies the same way as in `buildGenerateBundleReplacement`: + * a segment-config reason must override a module-graph reason for the same + * layout index. + */ +export function buildReasonsReplacement( + manifest: RouteClassificationManifest, + layer2PerRoute: ReadonlyMap>, +): string { + const cases: string[] = []; + + for (let routeIdx = 0; routeIdx < manifest.routes.length; routeIdx++) { + const route = manifest.routes[routeIdx]!; + const merged = mergeLayersForRoute(route, layer2PerRoute.get(routeIdx)); + + if (merged.size === 0) continue; + + const entries = [...merged.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([idx, value]) => `[${idx}, ${serializeReasonExpression(value.reason)}]`) + .join(", "); + cases.push(` case ${routeIdx}: return new Map([${entries}]);`); + } + + return [ + "(routeIdx) => {", + " switch (routeIdx) {", + ...cases, + " default: return null;", + " }", + " }", + ].join("\n"); +} diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 7b0f16a7d..e7de9fc64 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -183,7 +183,7 @@ export function generateRscEntry( } // Build route table as serialized JS - const routeEntries = routes.map((route) => { + const routeEntries = routes.map((route, routeIdx) => { const layoutVars = route.layouts.map((l) => getImportVar(l)); const templateVars = route.templates.map((t) => getImportVar(t)); const notFoundVars = (route.notFoundPaths || []).map((nf) => (nf ? getImportVar(nf) : "null")); @@ -214,6 +214,7 @@ ${interceptEntries.join(",\n")} ep ? getImportVar(ep) : "null", ); return ` { + __buildTimeClassifications: __VINEXT_CLASS(${routeIdx}), // evaluated once at module load pattern: ${JSON.stringify(route.pattern)}, patternParts: ${JSON.stringify(route.patternParts)}, isDynamic: ${route.isDynamic}, @@ -743,6 +744,15 @@ async function __ensureInstrumentation() { : "" } +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ ${routeEntries.join(",\n")} ]; @@ -2454,6 +2464,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index b1f8b14aa..581af4639 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -17,6 +17,12 @@ import { createDirectRunner } from "./server/dev-module-runner.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { generateBrowserEntry } from "./entries/app-browser-entry.js"; +import { + buildGenerateBundleReplacement, + collectRouteClassificationManifest, + type RouteClassificationManifest, +} from "./build/route-classification-manifest.js"; +import { classifyLayoutByModuleGraph } from "./build/layout-classification.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { findNextConfigPath, @@ -495,6 +501,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; + // Build-time layout classification manifest, captured in the RSC virtual + // module's load hook and consumed in generateBundle to patch the generated + // `__VINEXT_CLASS` stub with a real dispatch table. + let rscClassificationManifest: RouteClassificationManifest | null = null; + // Resolve shim paths - works both from source (.ts) and built (.js) const shimsDir = path.resolve(__dirname, "shims"); @@ -1594,6 +1605,14 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const metaRoutes = scanMetadataFiles(appDir); // Check for global-error.tsx at app root const globalErrorPath = findFileWithExts(appDir, "global-error", fileMatcher); + // Collect Layer 1 (segment config) classifications for all layouts. + // Layer 2 (module graph) runs later in generateBundle once Rollup's + // module info is available. + // Invariant: rscClassificationManifest must be built from the same + // `routes` value passed to generateRscEntry below so that layout + // indices in the manifest correspond 1:1 to the route.layouts arrays + // used during codegen. generateBundle clears this after patching. + rscClassificationManifest = collectRouteClassificationManifest(routes); return generateRscEntry( appDir, routes, @@ -1626,6 +1645,157 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { return generateGoogleFontsVirtualModule(id, _fontGoogleShimPath); } }, + + // Layer 2 build-time layout classification. The generated RSC entry + // emits a `function __VINEXT_CLASS(routeIdx) { return null; }` stub; + // this hook patches it with a switch-statement dispatch table so the + // runtime probe loop in app-page-execution.ts can skip the Layer 3 + // per-layout dynamic-isolation probe for layouts we proved static or + // dynamic at build time. + // + // @vitejs/plugin-rsc runs the RSC environment build in two phases: + // a scan phase that discovers client references, and a final build + // phase that emits the real RSC entry. We only patch when we actually + // see the stub in a chunk — the scan phase produces a tiny stub chunk + // that does not contain our code. + generateBundle(_options, bundle) { + // Only run in the RSC environment. SSR/client builds never contain + // the __VINEXT_CLASS stub so there is nothing to patch there, and + // pulling ModuleInfo from the wrong graph would give nonsense results. + if (this.environment?.name !== "rsc") return; + if (!rscClassificationManifest) return; + + // The `?` after the semicolon is intentional: Rolldown may or may not + // emit the trailing semicolon depending on minification settings. + // This regex relies on `__VINEXT_CLASS` retaining its name, which holds + // because RSC entry chunk bindings are not subject to scope-hoisting renames. + const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/; + + // Skip the scan-phase build where the RSC entry code has been + // tree-shaken out entirely. In the real RSC build the chunk that + // carries our runtime code will reference `__VINEXT_CLASS` via the + // per-route literal `__buildTimeClassifications: __VINEXT_CLASS(N)`, + // which Rolldown emits verbatim. + // + // If we see a chunk that mentions __VINEXT_CLASS but none of them + // contain the stub body we recognise, something upstream reshaped the + // generated source and we would silently degrade back to the Layer 3 + // runtime probe. Fail loudly instead so regressions surface at build + // time rather than as a mysterious perf cliff at request time. + const chunksMentioningStub: Array<{ + chunk: Extract<(typeof bundle)[string], { type: "chunk" }>; + fileName: string; + }> = []; + const chunksWithStubBody: Array<{ + chunk: Extract<(typeof bundle)[string], { type: "chunk" }>; + fileName: string; + }> = []; + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk") continue; + if (!chunk.code.includes("__VINEXT_CLASS")) continue; + chunksMentioningStub.push({ chunk, fileName: chunk.fileName }); + if (stubRe.test(chunk.code)) { + chunksWithStubBody.push({ chunk, fileName: chunk.fileName }); + } + } + + if (chunksMentioningStub.length === 0) return; + if (chunksWithStubBody.length === 0) { + throw new Error( + `vinext: build-time classification — __VINEXT_CLASS is referenced in ${chunksMentioningStub + .map((c) => c.fileName) + .join( + ", ", + )} but no chunk contains the stub body. The generator and generateBundle have drifted.`, + ); + } + if (chunksWithStubBody.length > 1) { + throw new Error( + `vinext: build-time classification — expected __VINEXT_CLASS stub in exactly one RSC chunk, found ${chunksWithStubBody.length}`, + ); + } + + // Rolldown stores module IDs as canonicalized filesystem paths + // (fs.realpathSync.native). On macOS, /var/folders/... becomes + // /private/var/folders/..., so raw paths collected at routing-scan + // time won't match module-graph keys. Canonicalize everything we + // hand to the classifier and everything we ask the graph for. + const canonicalize = (p: string): string => tryRealpathSync(p) ?? p; + + const dynamicShimPaths: ReadonlySet = new Set( + [ + resolveShimModulePath(shimsDir, "headers"), + resolveShimModulePath(shimsDir, "server"), + resolveShimModulePath(shimsDir, "cache"), + ].map(canonicalize), + ); + + // Adapter: the classifier in `build/layout-classification.ts` uses + // `dynamicImportedIds` (matches the old-Rollup field name we used when + // we wrote it). Rolldown's current ModuleInfo exposes it as + // `dynamicallyImportedIds` (the new Rollup field name). Keep the + // translation in one place so future call sites don't have to remember. + const moduleInfo = { + getModuleInfo: (moduleId: string) => { + const info = this.getModuleInfo(moduleId); + if (!info) return null; + return { + importedIds: info.importedIds ?? [], + dynamicImportedIds: info.dynamicallyImportedIds ?? [], + }; + }, + }; + + const layer2PerRoute = new Map>(); + const graphCache = new Map>(); + for (let routeIdx = 0; routeIdx < rscClassificationManifest.routes.length; routeIdx++) { + const route = rscClassificationManifest.routes[routeIdx]!; + const perRoute = new Map(); + for (let layoutIdx = 0; layoutIdx < route.layoutPaths.length; layoutIdx++) { + // Skip layouts already decided by Layer 1 — segment config is + // authoritative, so there is no need to walk the module graph. + if (route.layer1.has(layoutIdx)) continue; + const layoutModuleId = canonicalize(route.layoutPaths[layoutIdx]!); + // If the layout module itself is not in the graph, we have no + // evidence either way — do NOT claim it static, or we would skip + // the runtime probe for a layout we never actually analysed. + // `classifyLayoutByModuleGraph` returns "static" for an empty + // traversal, so the seed presence check has to happen here. + if (!moduleInfo.getModuleInfo(layoutModuleId)) continue; + let graphResult = graphCache.get(layoutModuleId); + if (graphResult === undefined) { + graphResult = classifyLayoutByModuleGraph( + layoutModuleId, + dynamicShimPaths, + moduleInfo, + ); + graphCache.set(layoutModuleId, graphResult); + } + if (graphResult.result === "static") { + perRoute.set(layoutIdx, "static"); + } + } + if (perRoute.size > 0) { + layer2PerRoute.set(routeIdx, perRoute); + } + } + + const replacement = buildGenerateBundleReplacement( + rscClassificationManifest, + layer2PerRoute, + ); + const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`; + const target = chunksWithStubBody[0]!.chunk; + target.code = target.code.replace(stubRe, patchedBody); + // The patched body is longer than the stub, so any existing source map + // would be stale. RSC entry source maps are not served or consumed, so + // nulling the map is safe and prevents stale-map confusion in tooling. + target.map = null; + // Consume the manifest exactly once per RSC entry load. Clearing here + // prevents a stale manifest from leaking into a subsequent generateBundle + // call if the load hook is not re-triggered (e.g., in non-standard rebuild paths). + rscClassificationManifest = null; + }, }, // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 83c752705..fb426064d 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -406,8 +406,18 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -431,6 +441,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -454,6 +465,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -477,6 +489,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -2131,6 +2144,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -2634,8 +2648,18 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -2659,6 +2683,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -2682,6 +2707,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -2705,6 +2731,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -4365,6 +4392,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -4869,8 +4897,18 @@ import * as mod_11 from "/tmp/test/app/global-error.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -4894,6 +4932,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -4917,6 +4956,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -4940,6 +4980,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -6594,6 +6635,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -7127,8 +7169,18 @@ async function __ensureInstrumentation() { return __instrumentationInitPromise; } +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -7152,6 +7204,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -7175,6 +7228,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -7198,6 +7252,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -8855,6 +8910,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -9359,8 +9415,18 @@ import * as mod_11 from "/tmp/test/app/sitemap.ts"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -9384,6 +9450,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -9407,6 +9474,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -9430,6 +9498,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -11090,6 +11159,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -11593,8 +11663,18 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + __buildTimeClassifications: __VINEXT_CLASS(0), // evaluated once at module load pattern: "/", patternParts: [], isDynamic: false, @@ -11618,6 +11698,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(1), // evaluated once at module load pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -11641,6 +11722,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(2), // evaluated once at module load pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -11664,6 +11746,7 @@ const routes = [ unauthorized: null, }, { + __buildTimeClassifications: __VINEXT_CLASS(3), // evaluated once at module load pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -13685,6 +13768,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 08da34dd0..4266f004c 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3645,6 +3645,27 @@ describe("App Router next.config.js features (generateRscEntry)", () => { }); }); }); + + describe("build-time classification dispatch stub", () => { + it("declares a __VINEXT_CLASS dispatch function", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + expect(code).toContain("function __VINEXT_CLASS(routeIdx)"); + }); + + it("threads a numeric route index into each route's classification wiring", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + // minimalRoutes has three routes, so the generator should emit calls + // __VINEXT_CLASS(0), __VINEXT_CLASS(1), __VINEXT_CLASS(2). + for (let i = 0; i < minimalRoutes.length; i++) { + expect(code).toContain(`__VINEXT_CLASS(${i})`); + } + }); + + it("no longer hardcodes buildTimeClassifications to null", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + expect(code).not.toContain("buildTimeClassifications: null"); + }); + }); }); describe("App Router middleware with NextRequest", () => { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index f969b6172..19311cc33 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -744,35 +744,52 @@ describe("printBuildReport respects pageExtensions", () => { // ─── classifyLayoutSegmentConfig ───────────────────────────────────────────── describe("classifyLayoutSegmentConfig", () => { - it('returns "static" for export const dynamic = "force-static"', () => { - expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toBe("static"); + it("returns kind=static with segment-config reason for force-static", () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: "force-static" }, + }); }); - it('returns "static" for export const dynamic = "error" (enforces static)', () => { - expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toBe("static"); + it('returns kind=static with segment-config reason for dynamic = "error"', () => { + expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: "error" }, + }); }); - it('returns "dynamic" for export const dynamic = "force-dynamic"', () => { - expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toBe("dynamic"); + it("returns kind=dynamic with segment-config reason for force-dynamic", () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toEqual({ + kind: "dynamic", + reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" }, + }); }); - it('returns "dynamic" for export const revalidate = 0', () => { - expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toBe("dynamic"); + it("returns kind=dynamic with revalidate reason for revalidate = 0", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toEqual({ + kind: "dynamic", + reason: { layer: "segment-config", key: "revalidate", value: 0 }, + }); }); - it('returns "static" for export const revalidate = Infinity', () => { - expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toBe("static"); + it("returns kind=static with revalidate reason for revalidate = Infinity", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "revalidate", value: Infinity }, + }); }); - it("returns null for no config (defers to module graph)", () => { + it("returns kind=absent when no config is present (defers to module graph)", () => { expect( classifyLayoutSegmentConfig( "export default function Layout({ children }) { return children; }", ), - ).toBeNull(); + ).toEqual({ kind: "absent" }); }); - it("returns null for positive revalidate (ISR is a page concept)", () => { - expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toBeNull(); + it("returns kind=absent for positive revalidate (ISR is a page concept)", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toEqual({ + kind: "absent", + }); }); }); diff --git a/tests/build-time-classification-integration.test.ts b/tests/build-time-classification-integration.test.ts new file mode 100644 index 000000000..340f31534 --- /dev/null +++ b/tests/build-time-classification-integration.test.ts @@ -0,0 +1,249 @@ +/** + * Build-time layout classification integration tests. + * + * These tests build a real App Router fixture through the full Vite pipeline, + * then extract the generated __VINEXT_CLASS dispatch function from the emitted + * RSC chunk and evaluate it. They verify that Fix 2 (wiring the build-time + * classifier into the plugin's generateBundle hook) actually produces a + * populated dispatch table at the end of the build pipeline — previously every + * route fell back to the Layer 3 runtime probe because the plugin never ran + * the classifier. + */ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import vm from "node:vm"; +import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test"; + +const FIXTURE_PREFIX = "vinext-class-integration-"; + +type Dispatch = (routeIdx: number) => Map | null; + +type BuiltFixture = { + chunkSource: string; + dispatch: Dispatch; + routeIndexByPattern: Map; +}; + +async function writeFile(file: string, source: string): Promise { + await fsp.mkdir(path.dirname(file), { recursive: true }); + await fsp.writeFile(file, source, "utf8"); +} + +/** + * Extracts the __VINEXT_CLASS function body from the RSC chunk source and + * evaluates it to a callable dispatch function. Throws if the stub is still + * the untouched `return null` form — the caller is expected to have patched + * it via the plugin's generateBundle hook. + */ +function extractDispatch(chunkSource: string): Dispatch { + const stubRe = /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/; + if (stubRe.test(chunkSource)) { + throw new Error("__VINEXT_CLASS was not patched — still returns null unconditionally"); + } + + // Non-greedy match: assumes the inner dispatch body does not contain + // ')(routeIdx)' as a substring. Coupled to the codegen shape in + // route-classification-manifest.ts buildGenerateBundleReplacement. + const re = + /function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{\s*return\s+(\([\s\S]*?\))\(routeIdx\);\s*\}/; + const match = re.exec(chunkSource); + if (!match) { + throw new Error("Could not locate patched __VINEXT_CLASS in chunk source"); + } + + // Use vm.runInThisContext so the resulting Map instances share their + // prototype with the test process — `instanceof Map` would otherwise + // fail across v8 contexts. + const raw: unknown = vm.runInThisContext(match[1]!); + if (typeof raw !== "function") { + throw new Error("Patched __VINEXT_CLASS body did not evaluate to a function"); + } + return (routeIdx: number) => { + const result: unknown = Reflect.apply(raw, null, [routeIdx]); + if (result === null) return null; + if (result instanceof Map) return result; + throw new Error( + `Dispatch returned unexpected value for routeIdx ${routeIdx}: ${JSON.stringify(result)}`, + ); + }; +} + +/** + * Extracts the per-route route indices emitted in the `routes = [...]` table + * by matching `__VINEXT_CLASS(N)` call expressions alongside each pattern. + * Maps pattern strings (stable across test edits) to numeric indices. + */ +function extractRouteIndexByPattern(chunkSource: string): Map { + const result = new Map(); + const re = /__buildTimeClassifications:\s*__VINEXT_CLASS\((\d+)\)[^,]*,\s*pattern:\s*"([^"]+)"/g; + let match: RegExpExecArray | null; + while ((match = re.exec(chunkSource)) !== null) { + result.set(match[2]!, Number(match[1]!)); + } + if (result.size === 0) { + throw new Error("No route entries with __VINEXT_CLASS + pattern found in chunk source"); + } + return result; +} + +async function buildMinimalFixture(): Promise { + const workspaceRoot = path.resolve(import.meta.dirname, ".."); + const workspaceNodeModules = path.join(workspaceRoot, "node_modules"); + + const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), FIXTURE_PREFIX)); + + // Root layout — plain JSX, no segment config, no dynamic shim imports. + // Layer 2 should prove this "static". + await writeFile( + path.join(tmpDir, "app", "layout.tsx"), + `export default function RootLayout({ children }) { + return {children}; +}`, + ); + + // "/" — force-dynamic layout above a plain page. + await writeFile( + path.join(tmpDir, "app", "page.tsx"), + `export default function Home() { return
home
; }`, + ); + + // "/dyn" — nested layout that uses next/headers, should remain unclassified + // (Layer 2 returns "needs-probe", filtered out). + await writeFile( + path.join(tmpDir, "app", "dyn", "layout.tsx"), + `import { headers } from "next/headers"; +export default async function DynLayout({ children }) { + const h = await headers(); + void h; + return
{children}
; +}`, + ); + await writeFile( + path.join(tmpDir, "app", "dyn", "page.tsx"), + `export default function DynPage() { return
dyn
; }`, + ); + + // "/force-dyn" — segment config force-dynamic at the layout. + await writeFile( + path.join(tmpDir, "app", "force-dyn", "layout.tsx"), + `export const dynamic = "force-dynamic"; +export default function ForceDynLayout({ children }) { + return
{children}
; +}`, + ); + await writeFile( + path.join(tmpDir, "app", "force-dyn", "page.tsx"), + `export default function ForceDynPage() { return
fd
; }`, + ); + + // "/force-static" — segment config force-static at the layout. + await writeFile( + path.join(tmpDir, "app", "force-static", "layout.tsx"), + `export const dynamic = "force-static"; +export default function ForceStaticLayout({ children }) { + return
{children}
; +}`, + ); + await writeFile( + path.join(tmpDir, "app", "force-static", "page.tsx"), + `export default function ForceStaticPage() { return
fs
; }`, + ); + + // Symlink workspace node_modules so vinext, react, react-dom resolve. + await fsp.symlink(workspaceNodeModules, path.join(tmpDir, "node_modules"), "junction"); + + const outDir = await fsp.mkdtemp(path.join(os.tmpdir(), `${FIXTURE_PREFIX}out-`)); + const rscOutDir = path.join(outDir, "server"); + const ssrOutDir = path.join(outDir, "server", "ssr"); + const clientOutDir = path.join(outDir, "client"); + + const { default: vinext } = await import( + pathToFileURL(path.join(workspaceRoot, "packages/vinext/src/index.ts")).href + ); + const { createBuilder } = await import("vite"); + const builder = await createBuilder({ + root: tmpDir, + configFile: false, + plugins: [vinext({ appDir: tmpDir, rscOutDir, ssrOutDir, clientOutDir })], + logLevel: "silent", + }); + await builder.buildApp(); + + // The RSC entry is emitted as either server/index.js or server/index.mjs + // depending on whether the fixture has a package.json with "type": "module". + // Our bespoke fixture has no package.json at all, so Vite falls back to .mjs. + const chunkDir = path.join(outDir, "server"); + const entries = await fsp.readdir(chunkDir); + const chunkFile = entries.find((f) => /^index\.m?js$/.test(f)); + if (!chunkFile) { + throw new Error(`No RSC entry chunk found in ${chunkDir}. Contents: ${entries.join(", ")}`); + } + const chunkSource = await fsp.readFile(path.join(chunkDir, chunkFile), "utf8"); + + return { + chunkSource, + dispatch: extractDispatch(chunkSource), + routeIndexByPattern: extractRouteIndexByPattern(chunkSource), + }; +} + +describe("build-time classification integration", () => { + let built: BuiltFixture; + + beforeAll(async () => { + built = await buildMinimalFixture(); + }, 120_000); + + afterAll(() => { + // tmpdirs are left for post-mortem debugging; the test harness cleans + // os.tmpdir() periodically. Matching the pattern used by buildAppFixture. + }); + + it("patches __VINEXT_CLASS with a populated switch statement", () => { + // The untouched stub body is `{ return null; }`; the patched body must + // contain a switch dispatcher. + expect(built.chunkSource).toMatch(/function\s+__VINEXT_CLASS\s*\(routeIdx\)\s*\{[^}]*switch/); + }); + + it("classifies the force-dynamic layout at build time", () => { + const routeIdx = built.routeIndexByPattern.get("/force-dyn"); + expect(routeIdx).toBeDefined(); + const map = built.dispatch(routeIdx!); + expect(map).toBeInstanceOf(Map); + // Layout index 1 is the nested `/force-dyn/layout.tsx`; index 0 is root. + expect(map!.get(1)).toBe("dynamic"); + }); + + it("classifies the force-static layout at build time", () => { + const routeIdx = built.routeIndexByPattern.get("/force-static"); + expect(routeIdx).toBeDefined(); + const map = built.dispatch(routeIdx!); + expect(map).toBeInstanceOf(Map); + expect(map!.get(1)).toBe("static"); + }); + + it("omits layouts that import next/headers from the build-time map", () => { + const routeIdx = built.routeIndexByPattern.get("/dyn"); + expect(routeIdx).toBeDefined(); + const map = built.dispatch(routeIdx!); + // The nested layout at index 1 pulls in next/headers, so Layer 2 returns + // "needs-probe" — it must be filtered out and fall back to Layer 3 at + // request time. + if (map) { + expect(map.has(1)).toBe(false); + } + }); + + it("classifies layouts with no segment config and no dynamic shims as static", () => { + // The root layout at index 0 is pure JSX — Layer 2 should prove it static. + // This assertion holds for every route in the fixture since they all share + // the root layout. + const routeIdx = built.routeIndexByPattern.get("/"); + expect(routeIdx).toBeDefined(); + const map = built.dispatch(routeIdx!); + expect(map).toBeInstanceOf(Map); + expect(map!.get(0)).toBe("static"); + }); +}); diff --git a/tests/layout-classification.test.ts b/tests/layout-classification.test.ts index 96a6cd7e7..7f2be0594 100644 --- a/tests/layout-classification.test.ts +++ b/tests/layout-classification.test.ts @@ -36,48 +36,50 @@ const DYNAMIC_SHIMS = new Set(["/shims/headers", "/shims/cache", "/shims/server" // ─── classifyLayoutByModuleGraph ───────────────────────────────────────────── describe("classifyLayoutByModuleGraph", () => { - it('returns "static" when layout has no transitive dynamic shim imports', () => { + 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: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph); + expect(result.result).toBe("static"); + expect(result.firstShimMatch).toBeUndefined(); }); - it('returns "needs-probe" when headers shim is transitively imported', () => { + 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: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph); + expect(result.result).toBe("needs-probe"); + expect(result.firstShimMatch).toBe("/shims/headers"); }); - it('returns "needs-probe" when cache shim (noStore) is transitively imported', () => { + it('returns result="needs-probe" when cache shim (noStore) is imported', () => { const graph = createFakeModuleGraph({ "/app/layout.tsx": { importedIds: ["/shims/cache"] }, "/shims/cache": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + const result = classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph); + expect(result.result).toBe("needs-probe"); + expect(result.firstShimMatch).toBe("/shims/cache"); }); - it('returns "needs-probe" when server shim (connection) is transitively imported', () => { + 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: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + 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", () => { @@ -87,7 +89,9 @@ describe("classifyLayoutByModuleGraph", () => { "/b.ts": { importedIds: ["/a.ts"] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe( + "static", + ); }); it("detects dynamic shim through deep transitive chains", () => { @@ -99,9 +103,9 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( - "needs-probe", - ); + 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())", () => { @@ -114,22 +118,24 @@ describe("classifyLayoutByModuleGraph", () => { "/shims/headers": { importedIds: [] }, }); - expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph)).toBe( + expect(classifyLayoutByModuleGraph("/app/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe( "needs-probe", ); }); - it('returns "static" when module info is null (unknown module)', () => { + it('returns result="static" when module info is null (unknown module)', () => { const graph = createFakeModuleGraph({}); - expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph)).toBe("static"); + expect(classifyLayoutByModuleGraph("/unknown/layout.tsx", DYNAMIC_SHIMS, graph).result).toBe( + "static", + ); }); }); // ─── classifyAllRouteLayouts ───────────────────────────────────────────────── describe("classifyAllRouteLayouts", () => { - it("segment config takes priority over module graph", () => { + 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"] }, @@ -150,7 +156,10 @@ describe("classifyAllRouteLayouts", () => { ]; const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); - expect(result.get("layout:/")).toBe("static"); + expect(result.get("layout:/")).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: "force-static" }, + }); }); it("deduplicates shared layout files across routes", () => { @@ -176,12 +185,22 @@ describe("classifyAllRouteLayouts", () => { const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); // Root layout appears in both routes but should only be classified once - expect(result.get("layout:/")).toBe("static"); - expect(result.get("layout:/blog")).toBe("needs-probe"); + 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", () => { + it("returns dynamic for force-dynamic segment config with a segment-config reason", () => { const graph = createFakeModuleGraph({ "/app/layout.tsx": { importedIds: [] }, }); @@ -200,10 +219,13 @@ describe("classifyAllRouteLayouts", () => { ]; const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); - expect(result.get("layout:/")).toBe("dynamic"); + 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 returns null", () => { + it("falls through to module graph when segment config is absent", () => { const graph = createFakeModuleGraph({ "/app/layout.tsx": { importedIds: [] }, }); @@ -222,7 +244,10 @@ describe("classifyAllRouteLayouts", () => { ]; const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); - expect(result.get("layout:/")).toBe("static"); + expect(result.get("layout:/")).toEqual({ + kind: "static", + reason: { layer: "module-graph", result: "static" }, + }); }); it("classifies layouts without segment configs using module graph only", () => { @@ -239,6 +264,13 @@ describe("classifyAllRouteLayouts", () => { ]; const result = classifyAllRouteLayouts(routes, DYNAMIC_SHIMS, graph); - expect(result.get("layout:/")).toBe("needs-probe"); + expect(result.get("layout:/")).toEqual({ + kind: "needs-probe", + reason: { + layer: "module-graph", + result: "needs-probe", + firstShimMatch: "/shims/cache", + }, + }); }); }); diff --git a/tests/route-classification-manifest.test.ts b/tests/route-classification-manifest.test.ts new file mode 100644 index 000000000..b86c5416f --- /dev/null +++ b/tests/route-classification-manifest.test.ts @@ -0,0 +1,387 @@ +/** + * Tests for the build-time layout classification manifest helpers. + * + * These helpers bridge the classifier (src/build/layout-classification.ts) + * with the RSC entry codegen so that the per-layout static/dynamic + * classifications computed at build time actually reach the runtime probe in + * app-page-execution.ts. + */ +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import vm from "node:vm"; +import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; +import { + buildGenerateBundleReplacement, + buildReasonsReplacement, + collectRouteClassificationManifest, +} from "../packages/vinext/src/build/route-classification-manifest.js"; +import type { ClassificationReason } from "../packages/vinext/src/build/layout-classification-types.js"; + +type MinimalAppRoute = { + pattern: string; + pagePath: string | null; + routePath: string | null; + layouts: string[]; + templates: string[]; + parallelSlots: []; + loadingPath: null; + errorPath: null; + layoutErrorPaths: (string | null)[]; + notFoundPath: null; + notFoundPaths: (string | null)[]; + forbiddenPath: null; + unauthorizedPath: null; + routeSegments: string[]; + layoutTreePositions: number[]; + isDynamic: boolean; + params: string[]; + patternParts: string[]; +}; + +function makeRoute(partial: Partial & { layouts: string[] }): MinimalAppRoute { + return { + pattern: "/", + pagePath: null, + routePath: null, + templates: [], + parallelSlots: [], + loadingPath: null, + errorPath: null, + layoutErrorPaths: partial.layouts.map(() => null), + notFoundPath: null, + notFoundPaths: partial.layouts.map(() => null), + forbiddenPath: null, + unauthorizedPath: null, + routeSegments: [], + layoutTreePositions: partial.layouts.map((_, idx) => idx), + isDynamic: false, + params: [], + patternParts: [], + ...partial, + }; +} + +let tmpDir: string; + +beforeEach(async () => { + tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "vinext-classification-manifest-")); +}); + +afterEach(async () => { + await fsp.rm(tmpDir, { recursive: true, force: true }); +}); + +async function writeLayout(name: string, source: string): Promise { + const file = path.join(tmpDir, name); + await fsp.writeFile(file, source, "utf8"); + return file; +} + +describe("collectRouteClassificationManifest", () => { + it("reads force-dynamic layouts as dynamic", async () => { + const layout = await writeLayout( + "layout-dyn.tsx", + `export const dynamic = "force-dynamic";\nexport default function L({children}){return children;}`, + ); + const routes = [makeRoute({ pattern: "/", layouts: [layout] })]; + + const manifest = collectRouteClassificationManifest(routes); + + expect(manifest.routes[0].layer1.get(0)).toBe("dynamic"); + expect(manifest.routes[0].layer1Reasons.get(0)).toEqual({ + layer: "segment-config", + key: "dynamic", + value: "force-dynamic", + }); + }); + + it("reads force-static layouts as static", async () => { + const layout = await writeLayout( + "layout-static.tsx", + `export const dynamic = "force-static";\nexport default function L({children}){return children;}`, + ); + const routes = [makeRoute({ pattern: "/", layouts: [layout] })]; + + const manifest = collectRouteClassificationManifest(routes); + + expect(manifest.routes[0].layer1.get(0)).toBe("static"); + }); + + it("leaves layouts without segment config unclassified", async () => { + const layout = await writeLayout( + "layout-plain.tsx", + `export default function L({children}){return
{children}
;}`, + ); + const routes = [makeRoute({ pattern: "/", layouts: [layout] })]; + + const manifest = collectRouteClassificationManifest(routes); + + expect(manifest.routes[0].layer1.has(0)).toBe(false); + }); + + it("reads revalidate = 0 as dynamic", async () => { + const layout = await writeLayout( + "layout-reval0.tsx", + `export const revalidate = 0;\nexport default function L({children}){return children;}`, + ); + const routes = [makeRoute({ pattern: "/", layouts: [layout] })]; + + const manifest = collectRouteClassificationManifest(routes); + + expect(manifest.routes[0].layer1.get(0)).toBe("dynamic"); + }); + + it("throws when a layout file is missing, naming the route and path", () => { + const missingPath = path.join(tmpDir, "nonexistent", "layout.tsx"); + const routes = [makeRoute({ pattern: "/blog", layouts: [missingPath] })]; + + expect(() => collectRouteClassificationManifest(routes)).toThrow(/\/blog/); + expect(() => collectRouteClassificationManifest(routes)).toThrow(/nonexistent/); + }); +}); + +describe("buildGenerateBundleReplacement", () => { + function evalDispatch(source: string): (routeIdx: number) => unknown { + // The helper returns a function expression suitable for: + // function __VINEXT_CLASS(routeIdx) { return ()(routeIdx); } + // Use vm.runInThisContext so the resulting Map instances share their + // prototype with the test process — `instanceof Map` would otherwise + // fail across v8 contexts. + const fn: unknown = vm.runInThisContext(`(${source})`); + if (typeof fn !== "function") { + throw new Error("buildGenerateBundleReplacement did not produce a function expression"); + } + return (routeIdx: number) => Reflect.apply(fn, null, [routeIdx]); + } + + function makeManifest( + entries: Array<{ layer1?: Array<[number, "static" | "dynamic"]> }>, + ): Parameters[0] { + return { + routes: entries.map((e, idx) => { + const layer1 = new Map(e.layer1 ?? []); + const layer1Reasons = new Map(); + for (const [layoutIdx, kind] of layer1) { + layer1Reasons.set(layoutIdx, { + layer: "segment-config", + key: "dynamic", + value: kind === "dynamic" ? "force-dynamic" : "force-static", + }); + } + return { + pattern: `/route-${idx}`, + layoutPaths: [], + layer1, + layer1Reasons, + }; + }), + }; + } + + it("returns a function expression that evaluates to a dispatch function", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + const replacement = buildGenerateBundleReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + + expect(typeof dispatch).toBe("function"); + const result = dispatch(0); + expect(result).toBeInstanceOf(Map); + }); + + function asMap(value: unknown): Map { + if (!(value instanceof Map)) { + throw new Error(`Expected Map, got ${String(value)}`); + } + return value; + } + + it("merges Layer 1 and Layer 2 into the dispatch function's Map", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + const layer2 = new Map>([[0, new Map([[1, "static"]])]]); + const replacement = buildGenerateBundleReplacement(manifest, layer2); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + expect(result.get(0)).toBe("dynamic"); + expect(result.get(1)).toBe("static"); + }); + + it("preserves Layer 1 priority over Layer 2 for the same layout index", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + // Layer 2 proves "static" for index 0 — but Layer 1 said "dynamic", so + // Layer 1 must win. This guards against the classifier silently demoting + // a force-dynamic layout because the module graph happened to be clean. + const layer2 = new Map>([[0, new Map([[0, "static"]])]]); + const replacement = buildGenerateBundleReplacement(manifest, layer2); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + expect(result.get(0)).toBe("dynamic"); + }); + + it("returns null from dispatch for unknown route indices", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + const replacement = buildGenerateBundleReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + + expect(dispatch(999)).toBeNull(); + }); + + it("returns null from dispatch for routes with no classifications", () => { + // Route 0 has Layer 1 data; route 1 has nothing in Layer 1 or Layer 2. + // A route with no merged entries should fall through to the default case. + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }, { layer1: [] }]); + const replacement = buildGenerateBundleReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + + expect(dispatch(0)).toBeInstanceOf(Map); + expect(dispatch(1)).toBeNull(); + }); + + it("throws when layer1 and layer1Reasons are out of sync", () => { + // This invariant is enforced in mergeLayersForRoute: every entry in layer1 + // must have a corresponding entry in layer1Reasons. collectRouteClassificationManifest + // always populates them in lockstep, but callers constructing RouteManifestEntry + // manually could violate this. + const brokenManifest: Parameters[0] = { + routes: [ + { + pattern: "/broken", + layoutPaths: [], + layer1: new Map([[0, "dynamic"]]), + layer1Reasons: new Map(), // missing reason for layoutIdx 0 + }, + ], + }; + + expect(() => buildGenerateBundleReplacement(brokenManifest, new Map())).toThrow( + /Layer 1 decision without a reason/, + ); + }); +}); + +describe("buildReasonsReplacement", () => { + function evalDispatch(source: string): (routeIdx: number) => unknown { + const fn: unknown = vm.runInThisContext(`(${source})`); + if (typeof fn !== "function") { + throw new Error("buildReasonsReplacement did not produce a function expression"); + } + return (routeIdx: number) => Reflect.apply(fn, null, [routeIdx]); + } + + function asMap(value: unknown): Map { + if (!(value instanceof Map)) { + throw new Error(`Expected Map, got ${String(value)}`); + } + return value; + } + + function makeManifest( + entries: Array<{ layer1?: Array<[number, "static" | "dynamic"]> }>, + ): Parameters[0] { + return { + routes: entries.map((e, idx) => { + const layer1 = new Map(e.layer1 ?? []); + const layer1Reasons = new Map(); + for (const [layoutIdx, kind] of layer1) { + layer1Reasons.set(layoutIdx, { + layer: "segment-config", + key: "dynamic", + value: kind === "dynamic" ? "force-dynamic" : "force-static", + }); + } + return { + pattern: `/route-${idx}`, + layoutPaths: [], + layer1, + layer1Reasons, + }; + }), + }; + } + + it("returns segment-config reasons for Layer 1 decisions", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + const replacement = buildReasonsReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + expect(result.get(0)).toEqual({ + layer: "segment-config", + key: "dynamic", + value: "force-dynamic", + }); + }); + + it("preserves Infinity in segment-config reasons", () => { + const manifest = { + routes: [ + { + pattern: "/route-0", + layoutPaths: [], + layer1: new Map([[0, "static"]]), + layer1Reasons: new Map([ + [0, { layer: "segment-config", key: "revalidate", value: Infinity }], + ]), + }, + ], + }; + const replacement = buildReasonsReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + expect(result.get(0)).toEqual({ + layer: "segment-config", + key: "revalidate", + value: Infinity, + }); + }); + + it("returns module-graph reasons for Layer 2 static decisions", () => { + const manifest = makeManifest([{ layer1: [] }]); + const layer2 = new Map>([[0, new Map([[3, "static"]])]]); + const replacement = buildReasonsReplacement(manifest, layer2); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + expect(result.get(3)).toEqual({ + layer: "module-graph", + result: "static", + }); + }); + + it("preserves Layer 1 reason priority over Layer 2 for the same layout index", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }]); + const layer2 = new Map>([[0, new Map([[0, "static"]])]]); + const replacement = buildReasonsReplacement(manifest, layer2); + + const dispatch = evalDispatch(replacement); + const result = asMap(dispatch(0)); + + // Layer 1 wins — the reason carried must be the segment-config reason, + // not the module-graph reason. + expect(result.get(0)).toEqual({ + layer: "segment-config", + key: "dynamic", + value: "force-dynamic", + }); + }); + + it("returns null for routes with no classifications", () => { + const manifest = makeManifest([{ layer1: [[0, "dynamic"]] }, { layer1: [] }]); + const replacement = buildReasonsReplacement(manifest, new Map()); + + const dispatch = evalDispatch(replacement); + + expect(dispatch(1)).toBeNull(); + }); +});