From ecc25d6b2fe25a9662baaf3ad5e70d09f75f136e Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:06:14 +1000 Subject: [PATCH 1/4] feat: X-Vinext-Router-Skip header optimization for static layouts --- .../src/build/layout-classification-types.ts | 50 ++ .../vinext/src/build/layout-classification.ts | 58 +- packages/vinext/src/build/report.ts | 46 +- .../build/route-classification-manifest.ts | 233 ++++++++ packages/vinext/src/entries/app-rsc-entry.ts | 91 ++- packages/vinext/src/index.ts | 171 ++++++ .../vinext/src/server/app-browser-entry.ts | 19 +- packages/vinext/src/server/app-elements.ts | 142 ++++- packages/vinext/src/server/app-page-cache.ts | 11 +- .../vinext/src/server/app-page-execution.ts | 34 ++ packages/vinext/src/server/app-page-render.ts | 37 +- .../vinext/src/server/app-page-skip-filter.ts | 318 ++++++++++ packages/vinext/src/shims/error-boundary.tsx | 5 +- .../entry-templates.test.ts.snap | 336 +++++++++-- tests/app-elements.test.ts | 240 ++++++++ tests/app-page-cache.test.ts | 156 +++++ tests/app-page-execution.test.ts | 134 +++++ tests/app-page-render.test.ts | 563 ++++++++++++++++++ tests/app-page-skip-filter.test.ts | 465 +++++++++++++++ tests/app-router.test.ts | 21 + tests/build-report.test.ts | 45 +- ...ld-time-classification-integration.test.ts | 253 ++++++++ tests/layout-classification.test.ts | 92 ++- tests/route-classification-manifest.test.ts | 366 ++++++++++++ 24 files changed, 3732 insertions(+), 154 deletions(-) create mode 100644 packages/vinext/src/build/layout-classification-types.ts create mode 100644 packages/vinext/src/build/route-classification-manifest.ts create mode 100644 packages/vinext/src/server/app-page-skip-filter.ts create mode 100644 tests/app-page-skip-filter.test.ts create mode 100644 tests/build-time-classification-integration.test.ts create mode 100644 tests/route-classification-manifest.test.ts 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..f3b79f45b 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, + }; } /** @@ -85,8 +120,8 @@ 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 +132,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..67df49fad --- /dev/null +++ b/packages/vinext/src/build/route-classification-manifest.ts @@ -0,0 +1,233 @@ +/** + * 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[] = []; + + 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: string; + try { + source = fs.readFileSync(layoutPath, "utf8"); + } 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, _value] of layer2) { + 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": { + 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(", ")} }`; + } + 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`. + * + * Layer 2 results must be filtered to only `"static"` before calling this + * function. The module-graph classifier can only prove static; "needs-probe" + * results must be omitted so the runtime probe takes over. + */ +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, so the bundle-size cost is bounded and the hot path pays nothing. + * + * 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 29be20f8c..c4be7cff7 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,9 @@ ${interceptEntries.join(",\n")} ep ? getImportVar(ep) : "null", ); return ` { + routeIdx: ${routeIdx}, + __buildTimeClassifications: __VINEXT_CLASS(${routeIdx}), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(${routeIdx}) : null, pattern: ${JSON.stringify(route.pattern)}, patternParts: ${JSON.stringify(route.patternParts)}, isDynamic: ${route.isDynamic}, @@ -394,6 +397,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from ${JSON.stringify(appElementsPath)}; import { buildAppPageElements as __buildAppPageElements, @@ -571,6 +576,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -743,6 +758,24 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ ${routeEntries.join(",\n")} ]; @@ -935,7 +968,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -1052,11 +1085,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -1412,6 +1447,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("\0", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -1819,6 +1864,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -2153,9 +2200,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -2191,6 +2235,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -2215,6 +2260,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -2273,6 +2320,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -2326,7 +2375,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -2422,8 +2471,28 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 88c7394db..7174a69a5 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -17,6 +17,13 @@ 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, + buildReasonsReplacement, + 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 +502,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"); @@ -1593,6 +1605,10 @@ 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. + rscClassificationManifest = collectRouteClassificationManifest(routes); return generateRscEntry( appDir, routes, @@ -1625,6 +1641,161 @@ 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; + + const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/; + const reasonsStubRe = + /function __VINEXT_CLASS_REASONS\(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}`, + ); + } + if (!reasonsStubRe.test(chunksWithStubBody[0]!.chunk.code)) { + throw new Error( + "vinext: build-time classification — __VINEXT_CLASS_REASONS stub is missing alongside __VINEXT_CLASS. The generator and generateBundle have drifted.", + ); + } + + // 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 => { + try { + return fs.realpathSync.native(p); + } catch { + return 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>(); + 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; + const graphResult = classifyLayoutByModuleGraph( + layoutModuleId, + dynamicShimPaths, + moduleInfo, + ); + if (graphResult.result === "static") { + perRoute.set(layoutIdx, "static"); + } + } + if (perRoute.size > 0) { + layer2PerRoute.set(routeIdx, perRoute); + } + } + + const replacement = buildGenerateBundleReplacement( + rscClassificationManifest, + layer2PerRoute, + ); + const reasonsReplacement = buildReasonsReplacement( + rscClassificationManifest, + layer2PerRoute, + ); + + const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`; + const patchedReasonsBody = `function __VINEXT_CLASS_REASONS(routeIdx) { return (${reasonsReplacement})(routeIdx); }`; + const target = chunksWithStubBody[0]!.chunk; + target.code = target.code + .replace(stubRe, patchedBody) + .replace(reasonsStubRe, patchedReasonsBody); + }, }, // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 4f58f3686..5e3e6ae39 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -53,11 +53,13 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + buildSkipHeaderValue, createAppPayloadCacheKey, getMountedSlotIdsHeader, normalizeAppElements, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, + X_VINEXT_ROUTER_SKIP_HEADER, type AppElements, type AppWireElements, type LayoutFlags, @@ -367,11 +369,18 @@ function getRequestState( } } -function createRscRequestHeaders(interceptionContext: string | null): Headers { +function createRscRequestHeaders( + interceptionContext: string | null, + layoutFlags: LayoutFlags, +): Headers { const headers = new Headers({ Accept: "text/x-component" }); if (interceptionContext !== null) { headers.set("X-Vinext-Interception-Context", interceptionContext); } + const skipValue = buildSkipHeaderValue(layoutFlags); + if (skipValue !== null) { + headers.set(X_VINEXT_ROUTER_SKIP_HEADER, skipValue); + } return headers; } @@ -974,10 +983,10 @@ async function main(): Promise { } if (!navResponse) { - const requestHeaders = createRscRequestHeaders(requestInterceptionContext); - if (mountedSlotsHeader) { - requestHeaders.set("X-Vinext-Mounted-Slots", mountedSlotsHeader); - } + const requestHeaders = createRscRequestHeaders( + requestInterceptionContext, + getBrowserRouterState().layoutFlags, + ); navResponse = await fetch(rscUrl, { headers: requestHeaders, credentials: "include", diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index b8bc42616..4e4fa039a 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import { isValidElement, type ReactNode } from "react"; const APP_INTERCEPTION_SEPARATOR = "\0"; @@ -16,7 +16,38 @@ export type AppWireElementValue = ReactNode | string | null; export type AppElements = Readonly>; export type AppWireElements = Readonly>; -/** Per-layout static/dynamic flags propagated in the RSC payload. `"s"` = static, `"d"` = dynamic. */ +/** + * Per-layout static/dynamic flags. `"s"` = static (skippable on next nav); + * `"d"` = dynamic (must always render). + * + * Lifecycle: + * + * 1. BUILD — classifyAllRouteLayouts (build/layout-classification.ts) runs + * Layer 1 (segment config) and Layer 2 (module graph) against + * each route's layouts at build time. Results are embedded in + * the generated entry via __VINEXT_CLASS. + * + * 2. PROBE — probeAppPageLayouts (server/app-page-execution.ts) consults + * build-time classifications. For "needs-probe" layouts, runs + * Layer 3 runtime probe via runWithIsolatedDynamicScope. Returns + * LayoutFlags for every layout in the route. + * + * 3. ATTACH — withLayoutFlags (this file) writes `__layoutFlags` into the + * outgoing App Router payload record. + * + * 4. WIRE — renderToReadableStream serializes the record as RSC row 0. + * + * 5. PARSE — readAppElementsMetadata (this file) extracts layoutFlags from + * the wire payload on the client side. + * + * 6. EMIT — buildSkipHeaderValue (this file) converts layoutFlags into + * the X-Vinext-Router-Skip header for the next client request. + * + * Each step is re-validated — LayoutFlags is NOT a trusted source; the server + * re-classifies every request in steps 1-2, and the filter in step 5-6 is + * applied defensively via `computeSkipDecision`, which only honors flags the + * server independently marked "s". + */ export type LayoutFlags = Readonly>; export type AppElementsMetadata = { @@ -107,6 +138,29 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { return normalized; } +export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip"; + +export function parseSkipHeader(header: string | null): ReadonlySet { + if (!header) return new Set(); + const ids = new Set(); + for (const part of header.split(",")) { + const trimmed = part.trim(); + if (trimmed.startsWith("layout:")) { + ids.add(trimmed); + } + } + return ids; +} + +/** See `LayoutFlags` type docblock in this file for lifecycle. */ +export function buildSkipHeaderValue(layoutFlags: LayoutFlags): string | null { + const staticIds: string[] = []; + for (const [id, flag] of Object.entries(layoutFlags)) { + if (flag === "s") staticIds.push(id); + } + return staticIds.length > 0 ? staticIds.join(",") : null; +} + function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { if (!value || typeof value !== "object" || Array.isArray(value)) return false; for (const v of Object.values(value)) { @@ -120,10 +174,94 @@ function parseLayoutFlags(value: unknown): LayoutFlags { return {}; } +/** + * Type predicate for a plain (non-null, non-array) record of app elements. + * Used to distinguish the App Router elements object from bare React elements + * at the render boundary. Delegates to React's canonical `isValidElement` so + * we don't depend on React's internal `$$typeof` marker scheme. + */ +export function isAppElementsRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + if (Array.isArray(value)) return false; + if (isValidElement(value)) return false; + return true; +} + +/** + * Pure: returns a new record with `__layoutFlags` attached. Owns the write + * boundary for the layout flags key so the write side sits next to + * `readAppElementsMetadata`. + * + * See `LayoutFlags` type docblock in this file for lifecycle. + */ +export function withLayoutFlags>( + elements: T, + layoutFlags: LayoutFlags, +): T & { [APP_LAYOUT_FLAGS_KEY]: LayoutFlags } { + return { ...elements, [APP_LAYOUT_FLAGS_KEY]: layoutFlags }; +} + +const EMPTY_SKIP_DECISION: ReadonlySet = new Set(); + +/** + * Pure: computes the authoritative set of layout ids that should be omitted + * from the outgoing payload. Defense-in-depth — an id is only included if the + * server independently classified it as `"s"` (static). Empty or missing + * `requested` yields a shared empty set so the hot path does not allocate. + * + * See `LayoutFlags` type docblock in this file for lifecycle. + */ +export function computeSkipDecision( + layoutFlags: LayoutFlags, + requested: ReadonlySet | undefined, +): ReadonlySet { + if (!requested || requested.size === 0) { + return EMPTY_SKIP_DECISION; + } + const decision = new Set(); + for (const id of requested) { + if (layoutFlags[id] === "s") { + decision.add(id); + } + } + return decision; +} + +/** + * The outgoing wire payload shape. Includes ReactNode values for the + * rendered tree plus metadata values like LayoutFlags attached under + * known keys (e.g. __layoutFlags). Distinct from AppElements / AppWireElements + * which only carry render-time values. + */ +export type AppOutgoingElements = Readonly>; + +/** + * Pure: builds the outgoing payload for the wire. Non-record inputs (e.g. a + * bare React element) are returned unchanged. Record inputs get a fresh copy + * with `__layoutFlags` attached. Never mutates `input.element`. + * + * Skip-header semantics intentionally live one layer up in + * `app-page-skip-filter.ts` so that the payload React serializes is always + * the CANONICAL record. The response branch applies the filter at the byte + * layer after the tee, which keeps the cache write path producing full + * bytes regardless of client skip state. + */ +export function buildOutgoingAppPayload(input: { + element: ReactNode | Readonly>; + layoutFlags: LayoutFlags; +}): ReactNode | AppOutgoingElements { + if (!isAppElementsRecord(input.element)) { + return input.element; + } + return withLayoutFlags({ ...input.element }, input.layoutFlags); +} + /** * Parses metadata from the wire payload. Accepts `Record` * because the RSC payload carries heterogeneous values (React elements, * strings, and plain objects like layout flags) under the same record type. + * + * See `LayoutFlags` type docblock in this file for lifecycle. */ export function readAppElementsMetadata( elements: Readonly>, diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index a9c8a2d6e..e8517c6dd 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -1,5 +1,8 @@ import type { CachedAppPageValue } from "../shims/cache.js"; import { buildAppPageCacheValue, type ISRCacheEntry } from "./isr-cache.js"; +import { wrapRscBytesForResponse } from "./app-page-skip-filter.js"; + +const EMPTY_SKIP_SET: ReadonlySet = new Set(); type AppPageDebugLogger = (event: string, detail: string) => void; type AppPageCacheGetter = (key: string) => Promise; @@ -22,6 +25,7 @@ export type BuildAppPageCachedResponseOptions = { isRscRequest: boolean; mountedSlotsHeader?: string | null; revalidateSeconds: number; + skipIds?: ReadonlySet; }; export type ReadAppPageCacheResponseOptions = { @@ -37,6 +41,7 @@ export type ReadAppPageCacheResponseOptions = { revalidateSeconds: number; renderFreshPageForCache: () => Promise; scheduleBackgroundRegeneration: AppPageBackgroundRegenerator; + skipIds?: ReadonlySet; }; export type FinalizeAppPageHtmlCacheResponseOptions = { @@ -106,7 +111,9 @@ export function buildAppPageCachedResponse( rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader; } - return new Response(cachedValue.rscData, { + const body = wrapRscBytesForResponse(cachedValue.rscData, options.skipIds ?? EMPTY_SKIP_SET); + + return new Response(body, { status, headers: rscHeaders, }); @@ -142,6 +149,7 @@ export async function readAppPageCacheResponse( isRscRequest: options.isRscRequest, mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: options.revalidateSeconds, + skipIds: options.skipIds, }); if (hitResponse) { @@ -198,6 +206,7 @@ export async function readAppPageCacheResponse( isRscRequest: options.isRscRequest, mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: options.revalidateSeconds, + skipIds: options.skipIds, }); if (staleResponse) { diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index d698f417d..cd03868ce 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -1,6 +1,8 @@ import type { LayoutFlags } from "./app-elements.js"; +import type { ClassificationReason } from "../build/layout-classification-types.js"; export type { LayoutFlags }; +export type { ClassificationReason }; export type AppPageSpecialError = | { kind: "redirect"; location: string; statusCode: number } @@ -31,6 +33,18 @@ export type ProbeAppPageLayoutsResult = { export type LayoutClassificationOptions = { /** Build-time classifications from segment config or module graph, keyed by layout index. */ buildTimeClassifications?: ReadonlyMap | null; + /** + * Per-layout classification reasons keyed by layout index. Populated by the + * generator only when `VINEXT_DEBUG_CLASSIFICATION` is set at runtime; the + * hot path never reads this and the wire payload is unchanged. + */ + buildTimeReasons?: ReadonlyMap | null; + /** + * Emits one log line per layout with the classification reason, keyed by + * layout ID. Set by the generator when `VINEXT_DEBUG_CLASSIFICATION` is + * active. When undefined, the probe loop skips debug emission entirely. + */ + debugClassification?: (layoutId: string, reason: ClassificationReason) => void; /** Maps layout index to its layout ID (e.g. "layout:/blog"). */ getLayoutId: (layoutIndex: number) => string; /** Runs a function with isolated dynamic usage tracking per layout. */ @@ -116,6 +130,7 @@ export async function buildAppPageSpecialErrorResponse( }); } +/** See `LayoutFlags` type docblock in app-elements.ts for lifecycle. */ export async function probeAppPageLayouts( options: ProbeAppPageLayoutsOptions, ): Promise { @@ -130,6 +145,12 @@ export async function probeAppPageLayouts( // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, // but still probe for special errors (redirects, not-found). layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + if (cls.debugClassification) { + cls.debugClassification( + cls.getLayoutId(layoutIndex), + cls.buildTimeReasons?.get(layoutIndex) ?? { layer: "no-classifier" }, + ); + } const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; continue; @@ -143,9 +164,22 @@ export async function probeAppPageLayouts( options.probeLayoutAt(layoutIndex), ); layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + if (cls.debugClassification) { + cls.debugClassification(cls.getLayoutId(layoutIndex), { + layer: "runtime-probe", + outcome: dynamicDetected ? "dynamic" : "static", + }); + } } catch (error) { // Probe failed — conservatively treat as dynamic. layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; + if (cls.debugClassification) { + cls.debugClassification(cls.getLayoutId(layoutIndex), { + layer: "runtime-probe", + outcome: "dynamic", + error: error instanceof Error ? error.message : String(error), + }); + } const errorResponse = await options.onLayoutError(error, layoutIndex); if (errorResponse) return errorResponse; } diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 672bb4694..9da780f49 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1,15 +1,22 @@ import type { ReactNode } from "react"; import type { CachedAppPageValue } from "../shims/cache.js"; +import { + buildOutgoingAppPayload, + computeSkipDecision, + type AppOutgoingElements, +} from "./app-elements.js"; import { finalizeAppPageHtmlCacheResponse, scheduleAppPageRscCacheWrite, } from "./app-page-cache.js"; +import { createSkipFilterTransform } from "./app-page-skip-filter.js"; import { buildAppPageFontLinkHeader, resolveAppPageSpecialError, teeAppPageRscStreamForCapture, type AppPageFontPreload, type AppPageSpecialError, + type LayoutClassificationOptions, } from "./app-page-execution.js"; import { probeAppPageBeforeRender } from "./app-page-probe.js"; import { @@ -30,6 +37,8 @@ import { type AppPageSsrHandler, } from "./app-page-stream.js"; +const EMPTY_SKIP_SET: ReadonlySet = new Set(); + type AppPageBoundaryOnError = ( error: unknown, requestInfo: unknown, @@ -84,7 +93,7 @@ export type RenderAppPageLifecycleOptions = { ) => Promise; renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; renderToReadableStream: ( - element: ReactNode | Record, + element: ReactNode | AppOutgoingElements, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; routeHasLocalBoundary: boolean; @@ -93,7 +102,9 @@ export type RenderAppPageLifecycleOptions = { scriptNonce?: string; mountedSlotsHeader?: string | null; waitUntil?: (promise: Promise) => void; - element: ReactNode | Record; + element: ReactNode | Readonly>; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; }; function buildResponseTiming( @@ -137,15 +148,26 @@ export async function renderAppPageLifecycle( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + classification: options.classification, }); if (preRenderResult.response) { return preRenderResult.response; } + const layoutFlags = preRenderResult.layoutFlags; + + // Always render the CANONICAL element. Skip semantics are applied on the + // egress branch only so the cache branch receives full bytes regardless of + // the client's skip header. See `app-page-skip-filter.ts`. + const outgoingElement = buildOutgoingAppPayload({ + element: options.element, + layoutFlags, + }); + const compileEnd = options.isProduction ? undefined : performance.now(); const baseOnError = options.createRscOnErrorHandler(options.cleanPathname, options.routePattern); const rscErrorTracker = createAppPageRscErrorTracker(baseOnError); - const rscStream = options.renderToReadableStream(options.element, { + const rscStream = options.renderToReadableStream(outgoingElement, { onError: rscErrorTracker.onRenderError, }); @@ -158,9 +180,16 @@ export async function renderAppPageLifecycle( revalidateSeconds !== Infinity && !options.isForceDynamic, ); - const rscForResponse = rscCapture.responseStream; const isrRscDataPromise = rscCapture.capturedRscDataPromise; + const skipIds = options.isRscRequest + ? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds) + : EMPTY_SKIP_SET; + const rscForResponse = + skipIds.size > 0 + ? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds)) + : rscCapture.responseStream; + if (options.isRscRequest) { const dynamicUsedDuringBuild = options.consumeDynamicUsage(); const rscResponsePolicy = resolveAppPageRscResponsePolicy({ diff --git a/packages/vinext/src/server/app-page-skip-filter.ts b/packages/vinext/src/server/app-page-skip-filter.ts new file mode 100644 index 000000000..bbeebb3d4 --- /dev/null +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -0,0 +1,318 @@ +/** + * Skip-filter for RSC wire-format payloads. + * + * Architectural role: applied to the egress RSC stream when the client sent + * `X-Vinext-Router-Skip`. The cache branch writes CANONICAL bytes (full + * payload) while the response branch rewrites row 0 to delete the skipped + * layout slot keys and drops orphaned child rows that are now unreferenced. + * + * This file is pure: all helpers take data in and return data out. The + * owning module (`app-page-render.ts`) is responsible for deciding when + * to apply the filter. The cache-read path (`app-page-cache.ts`) applies + * the same filter to cached canonical bytes via `wrapRscBytesForResponse`. + * + * Wire format reference (React 19.2.x, see `react-server-dom-webpack`): + * + * :\n + * + * Row 0 is the root model row. React's DFS post-order emission guarantees + * that a row's synchronous children are emitted before the row itself, + * while async children (thenables resolved later) appear after the row + * that references them. The filter buffers rows until row 0 arrives, + * computes the live id set from the surviving keys of row 0, emits the + * buffered rows in stream order (dropping rows not in the live set), then + * streams subsequent rows through a forward-pass live set. + */ + +const REFERENCE_PATTERN = /^\$(?:[LBFQWK@])?([0-9a-fA-F]+)$/; + +/** + * Pure: parses a single RSC reference string like `$L5` or `$ff` and returns + * the numeric row id it references. Returns null for escape sequences (`$$`) + * and non-row tagged forms (`$undefined`, `$NaN`, `$Z`, `$T`, etc.). + */ +export function parseRscReferenceString(value: string): number | null { + const match = REFERENCE_PATTERN.exec(value); + if (match === null) { + return null; + } + return Number.parseInt(match[1], 16); +} + +/** + * Pure: walks any JSON-shaped value and collects every numeric row reference + * it encounters into `into`. Non-row tagged strings are ignored. + */ +export function collectRscReferenceIds(value: unknown, into: Set): void { + if (typeof value === "string") { + const id = parseRscReferenceString(value); + if (id !== null) { + into.add(id); + } + return; + } + if (value === null || typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + collectRscReferenceIds(item, into); + } + return; + } + for (const item of Object.values(value)) { + collectRscReferenceIds(item, into); + } +} + +/** + * Pure: given a parsed row 0 record and the set of slot ids to skip, returns + * a rewritten row 0 (the same object shape minus the skipped keys) and the + * initial live id set seeded from the surviving keys' references. + */ +export function filterRow0( + row0: unknown, + skipIds: ReadonlySet, +): { rewritten: unknown; liveIds: Set } { + if (row0 === null || typeof row0 !== "object" || Array.isArray(row0)) { + const liveIds = new Set(); + collectRscReferenceIds(row0, liveIds); + return { rewritten: row0, liveIds }; + } + const rewritten: Record = {}; + for (const [key, value] of Object.entries(row0)) { + if (skipIds.has(key)) { + continue; + } + rewritten[key] = value; + } + const liveIds = new Set(); + collectRscReferenceIds(rewritten, liveIds); + return { rewritten, liveIds }; +} + +/** + * A row buffered while we wait for row 0. `kind: "row"` means we parsed a + * valid `:` prefix and should consult `liveIds`; `kind: "passthrough"` + * means the line did not parse as a row and must be emitted verbatim once + * we know what to do with the buffer (mirroring streaming-phase behavior + * for unrecognized lines). + */ +type PendingRow = { kind: "row"; id: number; raw: string } | { kind: "passthrough"; raw: string }; + +type FilterState = + | { phase: "initial"; carry: string; pending: PendingRow[] } + | { phase: "streaming"; carry: string; liveIds: Set } + | { phase: "passthrough"; carry: string }; + +const ROW_PREFIX_PATTERN = /^([0-9a-fA-F]+):/; +const JSON_START_PATTERN = /[[{]/; + +function parseRowIdFromRaw(raw: string): number | null { + const match = ROW_PREFIX_PATTERN.exec(raw); + if (match === null) { + return null; + } + return Number.parseInt(match[1], 16); +} + +function addRefsFromRaw(raw: string, into: Set): void { + const colonIndex = raw.indexOf(":"); + if (colonIndex < 0) { + return; + } + const payload = raw.slice(colonIndex + 1); + const jsonStart = payload.search(JSON_START_PATTERN); + if (jsonStart < 0) { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(payload.slice(jsonStart)); + } catch { + return; + } + collectRscReferenceIds(parsed, into); +} + +function emitRow( + controller: TransformStreamDefaultController, + encoder: TextEncoder, + raw: string, +): void { + controller.enqueue(encoder.encode(`${raw}\n`)); +} + +/** + * Creates a TransformStream that rewrites row 0 to omit the given `skipIds` + * and drops any rows that end up orphaned. Empty skipIds yields an identity + * transform so the hot path pays no parsing cost. + */ +export function createSkipFilterTransform( + skipIds: ReadonlySet, +): TransformStream { + if (skipIds.size === 0) { + return new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + } + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let state: FilterState = { phase: "initial", carry: "", pending: [] }; + + function promoteToStreaming( + controller: TransformStreamDefaultController, + row0Raw: string, + pending: readonly PendingRow[], + ): void { + const colonIndex = row0Raw.indexOf(":"); + const payload = row0Raw.slice(colonIndex + 1); + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + // Row 0 should always be a JSON object for App Router payloads. If + // parsing fails the skip filter cannot produce a correct result, so + // fall back to emitting the canonical stream unchanged. + for (const row of pending) { + emitRow(controller, encoder, row.raw); + } + state = { phase: "passthrough", carry: "" }; + return; + } + const { rewritten, liveIds } = filterRow0(parsed, skipIds); + const newRow0Raw = `0:${JSON.stringify(rewritten)}`; + + for (const row of pending) { + if (row.kind === "passthrough") { + emitRow(controller, encoder, row.raw); + continue; + } + if (row.id === 0) { + emitRow(controller, encoder, newRow0Raw); + continue; + } + if (liveIds.has(row.id)) { + emitRow(controller, encoder, row.raw); + addRefsFromRaw(row.raw, liveIds); + } + } + state = { phase: "streaming", carry: "", liveIds }; + } + + /** + * Drains complete rows out of the combined carry+chunk buffer and stores + * the trailing partial row (if any) on whichever state object is current + * at the END of the call. The state may be replaced mid-loop when row 0 + * arrives, so this function owns the carry assignment to keep callers + * free of JS LHS-evaluation hazards. + */ + function consumeBuffered( + controller: TransformStreamDefaultController, + buffer: string, + ): void { + let cursor = 0; + while (cursor < buffer.length) { + const newline = buffer.indexOf("\n", cursor); + if (newline < 0) { + state.carry = buffer.slice(cursor); + return; + } + const raw = buffer.slice(cursor, newline); + cursor = newline + 1; + + if (state.phase === "initial") { + const id = parseRowIdFromRaw(raw); + if (id === null) { + // Mirror streaming-phase behavior for unrecognized lines: pass them + // through verbatim. Buffered now, emitted in stream order once the + // pending queue is flushed by promoteToStreaming. + state.pending.push({ kind: "passthrough", raw }); + continue; + } + if (id === 0) { + const pendingSnapshot: PendingRow[] = [...state.pending, { kind: "row", id: 0, raw }]; + state.pending = []; + promoteToStreaming(controller, raw, pendingSnapshot); + // Fall through to the streaming-phase branch on the next iteration + // so the same buffer keeps draining under the new phase. cursor + // already points past row 0. + continue; + } + state.pending.push({ kind: "row", id, raw }); + continue; + } + + if (state.phase === "passthrough") { + emitRow(controller, encoder, raw); + continue; + } + + // streaming phase + const id = parseRowIdFromRaw(raw); + if (id === null) { + // Unrecognized row — pass through verbatim. + emitRow(controller, encoder, raw); + continue; + } + if (state.liveIds.has(id)) { + emitRow(controller, encoder, raw); + addRefsFromRaw(raw, state.liveIds); + } + } + state.carry = ""; + } + + return new TransformStream({ + transform(chunk, controller) { + const text = decoder.decode(chunk, { stream: true }); + consumeBuffered(controller, state.carry + text); + }, + flush(controller) { + const trailing = decoder.decode(); + const buffer = state.carry + trailing; + // Force any final partial row through the row-terminating path by + // synthesizing a newline. consumeBuffered will either complete the + // pending row 0 transition, or, if row 0 never arrived, leave us in + // the initial phase with the line buffered in state.pending. + if (buffer.length > 0) { + consumeBuffered(controller, `${buffer}\n`); + } + if (state.phase === "initial") { + // Row 0 never arrived. Emit every buffered line verbatim so the + // client sees the canonical (unfiltered) stream rather than + // silently losing rows. This branch is defensive — well-formed + // App Router responses always emit row 0. + for (const row of state.pending) { + emitRow(controller, encoder, row.raw); + } + state.pending = []; + } + }, + }); +} + +/** + * Cache-read helper: wraps an ArrayBuffer in either identity (no skip) or a + * ReadableStream piped through the skip filter. Callers pass the result + * directly to `new Response(...)` so the underlying bytes are never copied + * into a string. + */ +export function wrapRscBytesForResponse( + bytes: ArrayBuffer, + skipIds: ReadonlySet, +): BodyInit { + if (skipIds.size === 0) { + return bytes; + } + const source = new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(bytes)); + controller.close(); + }, + }); + return source.pipeThrough(createSkipFilterTransform(skipIds)); +} diff --git a/packages/vinext/src/shims/error-boundary.tsx b/packages/vinext/src/shims/error-boundary.tsx index cadcbdb92..dda4f88c3 100644 --- a/packages/vinext/src/shims/error-boundary.tsx +++ b/packages/vinext/src/shims/error-boundary.tsx @@ -1,8 +1,9 @@ "use client"; import React from "react"; -// oxlint-disable-next-line @typescript-eslint/no-require-imports -- next/navigation is shimmed -import { usePathname } from "next/navigation"; +// Import the local shim, not the public next/navigation alias. The built +// package may execute this file before the plugin's resolveId hook is active. +import { usePathname } from "./navigation.js"; export type ErrorBoundaryProps = { fallback: React.ComponentType<{ error: Error; reset: () => void }>; diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index b5fc7a232..38913428c 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -81,6 +81,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -689,7 +691,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -806,11 +808,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -1273,6 +1277,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -1538,6 +1552,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -1830,9 +1846,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -1868,6 +1881,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -1892,6 +1906,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -1950,6 +1966,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -2003,7 +2021,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -2099,8 +2117,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -2265,6 +2301,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -2873,7 +2911,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -2990,11 +3028,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -3463,6 +3503,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -3728,6 +3778,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -4020,9 +4072,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -4058,6 +4107,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -4082,6 +4132,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -4140,6 +4192,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -4193,7 +4247,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -4289,8 +4343,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -4455,6 +4527,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -5064,7 +5138,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -5181,11 +5255,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -5648,6 +5724,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -5913,6 +5999,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -6205,9 +6293,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -6243,6 +6328,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -6267,6 +6353,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -6325,6 +6413,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -6378,7 +6468,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -6474,8 +6564,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -6640,6 +6748,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -7278,7 +7388,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -7395,11 +7505,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -7865,6 +7977,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -8130,6 +8252,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -8422,9 +8546,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -8460,6 +8581,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -8484,6 +8606,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -8542,6 +8666,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -8595,7 +8721,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -8691,8 +8817,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -8857,6 +9001,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -9472,7 +9618,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -9589,11 +9735,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -10056,6 +10204,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -10321,6 +10479,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -10613,9 +10773,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -10651,6 +10808,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -10675,6 +10833,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -10733,6 +10893,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -10786,7 +10948,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -10882,8 +11044,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -11048,6 +11228,8 @@ import { import { APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY, createAppPayloadRouteId as __createAppPayloadRouteId, + parseSkipHeader as __parseSkipHeader, + X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER, } from "/packages/vinext/src/server/app-elements.js"; import { buildAppPageElements as __buildAppPageElements, @@ -11656,7 +11838,7 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request) { +async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -11773,11 +11955,13 @@ async function buildPageElements(route, params, routePath, opts, searchParams, i // dynamic, and this avoids false positives from React internals. if (hasSearchParams) markDynamicUsage(); } - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request?.headers?.get("x-vinext-mounted-slots"), - ); - const mountedSlotIds = __mountedSlotsHeader - ? new Set(__mountedSlotsHeader.split(" ")) + // Both mountedSlotsHeader (already normalized) and skipLayoutIds (already + // parsed) are threaded through from the handler scope so every call site + // shares one source of truth for these request-derived values. Reading the + // same headers in two places invites silent drift when a future refactor + // changes only one of them. + const mountedSlotIds = mountedSlotsHeader + ? new Set(mountedSlotsHeader.split(" ")) : null; return __buildAppPageElements({ @@ -12469,6 +12653,16 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __skipLayoutIds = isRscRequest + ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) + : new Set(); + // Read mounted-slots header once at the handler scope and thread it through + // every buildPageElements call site. Previously both the handler and + // buildPageElements read and normalized it independently, which invited + // silent drift if a future refactor changed only one path. + const __mountedSlotsHeader = __normalizeMountedSlotsHeader( + request.headers.get("x-vinext-mounted-slots"), + ); const interceptionContextHeader = request.headers.get("X-Vinext-Interception-Context")?.replaceAll("", "") || null; let cleanPathname = pathname.replace(/\\.rsc$/, ""); @@ -12872,6 +13066,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { url.searchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -13164,9 +13360,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { // force-dynamic: set no-store Cache-Control const isForceDynamic = dynamicConfig === "force-dynamic"; - const __mountedSlotsHeader = __normalizeMountedSlotsHeader( - request.headers.get("x-vinext-mounted-slots"), - ); // ── ISR cache read (production only) ───────────────────────────────────── // Read from cache BEFORE generateStaticParams and all rendering work. @@ -13202,6 +13395,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isrSet: __isrSet, mountedSlotsHeader: __mountedSlotsHeader, revalidateSeconds, + skipIds: __skipLayoutIds, renderFreshPageForCache: async function() { // Re-render the page to produce fresh HTML + RSC data for the cache // Use an empty headers context for background regeneration — not the original @@ -13226,6 +13420,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { new URLSearchParams(), isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -13284,6 +13480,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptSearchParams, isRscRequest, request, + __mountedSlotsHeader, + __skipLayoutIds, ); }, cleanPathname, @@ -13337,7 +13535,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request); + return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -13433,8 +13631,26 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const _asyncSearchParams = makeThenableParams(_probeSearchObj); return PageComponent({ params: _asyncLayoutParams, searchParams: _asyncSearchParams }); }, + classification: { + getLayoutId(index) { + const tp = route.layoutTreePositions?.[index] ?? 0; + return "layout:" + __createAppPageTreePath(route.routeSegments, tp); + }, + buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 841bfc039..fe388a8e4 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -7,11 +7,17 @@ import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + buildOutgoingAppPayload, + buildSkipHeaderValue, + computeSkipDecision, createAppPayloadCacheKey, createAppPayloadRouteId, + isAppElementsRecord, normalizeAppElements, + parseSkipHeader, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, + withLayoutFlags, } from "../packages/vinext/src/server/app-elements.js"; describe("app elements payload helpers", () => { @@ -149,3 +155,237 @@ describe("app elements payload helpers", () => { expect(metadata.layoutFlags).toEqual({}); }); }); + +describe("parseSkipHeader", () => { + it("returns empty set for null header", () => { + expect(parseSkipHeader(null)).toEqual(new Set()); + }); + + it("returns empty set for empty string", () => { + expect(parseSkipHeader("")).toEqual(new Set()); + }); + + it("parses a single layout ID", () => { + expect(parseSkipHeader("layout:/")).toEqual(new Set(["layout:/"])); + }); + + it("parses comma-separated layout IDs", () => { + const result = parseSkipHeader("layout:/,layout:/blog,layout:/blog/posts"); + expect(result).toEqual(new Set(["layout:/", "layout:/blog", "layout:/blog/posts"])); + }); + + it("trims whitespace around entries", () => { + const result = parseSkipHeader(" layout:/ , layout:/blog "); + expect(result).toEqual(new Set(["layout:/", "layout:/blog"])); + }); + + it("filters out non-layout entries", () => { + const result = parseSkipHeader("layout:/,page:/blog,template:/,route:/api,slot:modal"); + expect(result).toEqual(new Set(["layout:/"])); + }); + + it("handles mixed layout and non-layout entries", () => { + const result = parseSkipHeader("layout:/,garbage,layout:/blog,page:/x"); + expect(result).toEqual(new Set(["layout:/", "layout:/blog"])); + }); +}); + +describe("isAppElementsRecord", () => { + it("returns true for a plain record", () => { + expect(isAppElementsRecord({ "page:/": "x" })).toBe(true); + }); + + it("returns false for a React element", () => { + expect(isAppElementsRecord(React.createElement("div", null, "x"))).toBe(false); + }); + + it("returns false for null", () => { + expect(isAppElementsRecord(null)).toBe(false); + }); + + it("returns false for an array", () => { + expect(isAppElementsRecord([])).toBe(false); + }); + + it("returns false for a string", () => { + expect(isAppElementsRecord("string")).toBe(false); + }); + + it("returns false for undefined", () => { + expect(isAppElementsRecord(undefined)).toBe(false); + }); +}); + +describe("withLayoutFlags", () => { + it("attaches the __layoutFlags key with the supplied value", () => { + const input = { "page:/": "page" }; + const result = withLayoutFlags(input, { "layout:/": "s" }); + expect(result[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" }); + }); + + it("does not mutate the input", () => { + const input: Record = { "page:/": "page", "layout:/": "layout" }; + const snapshot = structuredClone(input); + const result = withLayoutFlags(input, { "layout:/": "d" }); + expect(result).not.toBe(input); + expect(input).toEqual(snapshot); + expect(Object.keys(input)).toEqual(Object.keys(snapshot)); + expect(APP_LAYOUT_FLAGS_KEY in input).toBe(false); + }); + + it("preserves other keys on the returned object", () => { + const input = { "page:/blog": "page", "layout:/": "layout" }; + const result = withLayoutFlags(input, { "layout:/": "s" }); + expect(result["page:/blog"]).toBe("page"); + expect(result["layout:/"]).toBe("layout"); + }); + + it("returns a new object with a different identity", () => { + const input = { "page:/": "page" }; + const result = withLayoutFlags(input, {}); + expect(result).not.toBe(input); + }); +}); + +describe("computeSkipDecision", () => { + it("returns empty set when requested is undefined", () => { + expect(computeSkipDecision({ "layout:/": "s" }, undefined)).toEqual(new Set()); + }); + + it("returns empty set when requested is empty", () => { + expect(computeSkipDecision({ "layout:/": "s" }, new Set())).toEqual(new Set()); + }); + + it("includes an id when the server classified it as static", () => { + const decision = computeSkipDecision({ "layout:/a": "s" }, new Set(["layout:/a"])); + expect(decision).toEqual(new Set(["layout:/a"])); + }); + + it("defense-in-depth: excludes an id the server classified as dynamic", () => { + const decision = computeSkipDecision({ "layout:/a": "d" }, new Set(["layout:/a"])); + expect(decision).toEqual(new Set()); + }); + + it("excludes an id missing from the flags", () => { + const decision = computeSkipDecision({}, new Set(["layout:/a"])); + expect(decision).toEqual(new Set()); + }); + + it("keeps only ids the server agrees are static in a mixed request", () => { + const decision = computeSkipDecision( + { "layout:/a": "s", "layout:/b": "d" }, + new Set(["layout:/a", "layout:/b"]), + ); + expect(decision).toEqual(new Set(["layout:/a"])); + }); +}); + +describe("buildOutgoingAppPayload", () => { + it("returns a non-record element unchanged (same identity)", () => { + const element = React.createElement("div", null, "page"); + const result = buildOutgoingAppPayload({ + element, + layoutFlags: { "layout:/": "s" }, + }); + expect(result).toBe(element); + }); + + it("returns a new object for a record element (different identity)", () => { + const element = { "page:/": "page" }; + const result = buildOutgoingAppPayload({ + element, + layoutFlags: {}, + }); + expect(result).not.toBe(element); + }); + + it("does not mutate the input record", () => { + const element: Record = { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog": "blog-page", + }; + const snapshot = structuredClone(element); + const result = buildOutgoingAppPayload({ + element, + layoutFlags: { "layout:/": "s", "layout:/blog": "d" }, + }); + expect(result).not.toBe(element); + expect(element).toEqual(snapshot); + expect(Object.keys(element)).toEqual(Object.keys(snapshot)); + expect(APP_LAYOUT_FLAGS_KEY in element).toBe(false); + }); + + it("attaches __layoutFlags on the returned record", () => { + const result = buildOutgoingAppPayload({ + element: { "page:/": "page" }, + layoutFlags: { "layout:/": "s" }, + }); + expect(isAppElementsRecord(result)).toBe(true); + if (isAppElementsRecord(result)) { + expect(result[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" }); + } + }); + + it("returns canonical record keys regardless of any upstream skip intent", () => { + const result = buildOutgoingAppPayload({ + element: { "layout:/": "root-layout", "page:/": "page" }, + layoutFlags: { "layout:/": "s" }, + }); + expect(isAppElementsRecord(result)).toBe(true); + if (isAppElementsRecord(result)) { + expect(result["layout:/"]).toBe("root-layout"); + expect(result["page:/"]).toBe("page"); + } + }); + + it("preserves non-layout metadata keys", () => { + const result = buildOutgoingAppPayload({ + element: { + [APP_ROUTE_KEY]: "route:/blog", + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_INTERCEPTION_CONTEXT_KEY]: null, + "layout:/": "root-layout", + "page:/blog": "blog-page", + }, + layoutFlags: { "layout:/": "s" }, + }); + expect(isAppElementsRecord(result)).toBe(true); + if (isAppElementsRecord(result)) { + expect(result[APP_ROUTE_KEY]).toBe("route:/blog"); + expect(result[APP_ROOT_LAYOUT_KEY]).toBe("/"); + expect(result[APP_INTERCEPTION_CONTEXT_KEY]).toBeNull(); + expect(result["page:/blog"]).toBe("blog-page"); + expect(result["layout:/"]).toBe("root-layout"); + } + }); +}); + +describe("buildSkipHeaderValue", () => { + it("returns null for empty flags", () => { + expect(buildSkipHeaderValue({})).toBeNull(); + }); + + it("returns null when all layouts are dynamic", () => { + expect(buildSkipHeaderValue({ "layout:/": "d", "layout:/blog": "d" })).toBeNull(); + }); + + it("includes only static layout IDs", () => { + const value = buildSkipHeaderValue({ "layout:/": "s", "layout:/blog": "d" }); + expect(value).toBe("layout:/"); + }); + + it("returns comma-separated IDs when multiple are static", () => { + const value = buildSkipHeaderValue({ + "layout:/": "s", + "layout:/blog": "s", + "layout:/blog/posts": "d", + }); + expect(value).toBe("layout:/,layout:/blog"); + }); + + it("returns all IDs when all are static", () => { + const value = buildSkipHeaderValue({ "layout:/": "s", "layout:/blog": "s" }); + expect(value).toBe("layout:/,layout:/blog"); + }); +}); diff --git a/tests/app-page-cache.test.ts b/tests/app-page-cache.test.ts index 6bbe24919..a05f6c9fe 100644 --- a/tests/app-page-cache.test.ts +++ b/tests/app-page-cache.test.ts @@ -332,6 +332,162 @@ describe("app page cache helpers", () => { errorSpy.mockRestore(); }); + it("filters cached canonical RSC bytes when the request carries a skip set", async () => { + const canonicalRows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const canonicalText = canonicalRows.join("\n") + "\n"; + const rscData = new TextEncoder().encode(canonicalText).buffer; + + const response = buildAppPageCachedResponse(buildCachedAppPageValue("", rscData), { + cacheState: "HIT", + isRscRequest: true, + revalidateSeconds: 60, + skipIds: new Set(["slot:layout:/"]), + }); + + expect(response).not.toBeNull(); + const bodyText = await response?.text(); + expect(bodyText).toBeDefined(); + expect(bodyText).toContain("page"); + expect(bodyText).not.toContain("layout"); + expect(bodyText).toContain(`"slot:page":"$L2"`); + }); + + it("returns canonical RSC bytes when the request carries no skip set", async () => { + const canonicalRows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const canonicalText = canonicalRows.join("\n") + "\n"; + const rscData = new TextEncoder().encode(canonicalText).buffer; + + const response = buildAppPageCachedResponse(buildCachedAppPageValue("", rscData), { + cacheState: "HIT", + isRscRequest: true, + revalidateSeconds: 60, + }); + + const bodyText = await response?.text(); + expect(bodyText).toBe(canonicalText); + }); + + it("filters identically on HIT and STALE reads for the same canonical bytes", async () => { + const canonicalRows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const canonicalText = canonicalRows.join("\n") + "\n"; + const rscData = new TextEncoder().encode(canonicalText).buffer; + const skipIds = new Set(["slot:layout:/"]); + + const hit = buildAppPageCachedResponse(buildCachedAppPageValue("", rscData), { + cacheState: "HIT", + isRscRequest: true, + revalidateSeconds: 60, + skipIds, + }); + const stale = buildAppPageCachedResponse(buildCachedAppPageValue("", rscData.slice(0)), { + cacheState: "STALE", + isRscRequest: true, + revalidateSeconds: 60, + skipIds, + }); + + const hitBody = await hit?.text(); + const staleBody = await stale?.text(); + // Both must contain the same row 0 rewrite, independent of cache state. + const hitRow0 = hitBody?.split("\n").find((line) => line.startsWith("0:")); + const staleRow0 = staleBody?.split("\n").find((line) => line.startsWith("0:")); + expect(hitRow0).toBeDefined(); + expect(hitRow0).toBe(staleRow0); + }); + + it("cache-read HITs thread skipIds from the request through to the response body", async () => { + const canonicalRows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const canonicalText = canonicalRows.join("\n") + "\n"; + const rscData = new TextEncoder().encode(canonicalText).buffer; + + const response = await readAppPageCacheResponse({ + cleanPathname: "/cached", + clearRequestContext() {}, + isRscRequest: true, + async isrGet() { + return buildISRCacheEntry(buildCachedAppPageValue("", rscData)); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet() {}, + revalidateSeconds: 60, + renderFreshPageForCache: async () => { + throw new Error("should not render"); + }, + scheduleBackgroundRegeneration() { + throw new Error("should not schedule regeneration"); + }, + skipIds: new Set(["slot:layout:/"]), + }); + + const bodyText = await response?.text(); + expect(bodyText).toBeDefined(); + expect(bodyText).not.toContain("layout"); + expect(bodyText).toContain(`"slot:page":"$L2"`); + }); + + it("cache-read STALE branch applies skipIds to the stale payload", async () => { + const canonicalRows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const canonicalText = canonicalRows.join("\n") + "\n"; + const rscData = new TextEncoder().encode(canonicalText).buffer; + const scheduled: Array<() => Promise> = []; + + const response = await readAppPageCacheResponse({ + cleanPathname: "/stale-skip", + clearRequestContext() {}, + isRscRequest: true, + async isrGet() { + return buildISRCacheEntry(buildCachedAppPageValue("", rscData), true); + }, + isrHtmlKey(pathname) { + return "html:" + pathname; + }, + isrRscKey(pathname) { + return "rsc:" + pathname; + }, + async isrSet() {}, + revalidateSeconds: 60, + renderFreshPageForCache: async () => ({ + html: "", + rscData, + tags: [], + }), + scheduleBackgroundRegeneration(_key, renderFn) { + scheduled.push(renderFn); + }, + skipIds: new Set(["slot:layout:/"]), + }); + + expect(response?.headers.get("x-vinext-cache")).toBe("STALE"); + const bodyText = await response?.text(); + expect(bodyText).not.toContain("layout"); + expect(bodyText).toContain(`"slot:page":"$L2"`); + }); + it("finalizes HTML responses by teeing the stream and writing HTML and RSC cache keys", async () => { const pendingCacheWrites: Promise[] = []; const isrSetCalls: Array<{ diff --git a/tests/app-page-execution.test.ts b/tests/app-page-execution.test.ts index 936c61c14..55fe508ed 100644 --- a/tests/app-page-execution.test.ts +++ b/tests/app-page-execution.test.ts @@ -349,6 +349,140 @@ describe("app page execution helpers", () => { expect(result.layoutFlags).toEqual({ "layout:/admin": "s" }); }); + it("does not read build-time reasons when debugClassification is absent", async () => { + const throwingReasons = { + get() { + throw new Error("build-time reasons should stay dormant when debug is disabled"); + }, + } as unknown as ReadonlyMap; + + await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + buildTimeReasons: throwingReasons, + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + }); + + it("emits a debug reason per layout when debugClassification is provided with build-time reasons", async () => { + const calls: Array<{ layoutId: string; reason: unknown }> = []; + + await probeAppPageLayouts({ + layoutCount: 3, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + [2, "static"], + ]), + buildTimeReasons: new Map([ + [0, { layer: "segment-config", key: "dynamic", value: "force-static" }], + [1, { layer: "segment-config", key: "dynamic", value: "force-dynamic" }], + [2, { layer: "module-graph", result: "static" }], + ]), + debugClassification(layoutId, reason) { + calls.push({ layoutId, reason }); + }, + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/admin", "layout:/admin/posts"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + return Promise.resolve({ result: fn(), dynamicDetected: false }); + }, + }, + }); + + expect(calls).toHaveLength(3); + const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason])); + expect(byId["layout:/"]).toEqual({ + layer: "segment-config", + key: "dynamic", + value: "force-static", + }); + expect(byId["layout:/admin"]).toEqual({ + layer: "segment-config", + key: "dynamic", + value: "force-dynamic", + }); + expect(byId["layout:/admin/posts"]).toEqual({ + layer: "module-graph", + result: "static", + }); + }); + + it("emits runtime-probe reason for layouts resolved by the Layer 3 probe", async () => { + const calls: Array<{ layoutId: string; reason: unknown }> = []; + let probeCalls = 0; + + await probeAppPageLayouts({ + layoutCount: 2, + onLayoutError() { + return Promise.resolve(null); + }, + probeLayoutAt() { + return null; + }, + runWithSuppressedHookWarning(probe) { + return probe(); + }, + classification: { + // No buildTimeClassifications → every layout takes the runtime path. + debugClassification(layoutId, reason) { + calls.push({ layoutId, reason }); + }, + getLayoutId(layoutIndex) { + return ["layout:/", "layout:/dashboard"][layoutIndex]; + }, + runWithIsolatedDynamicScope(fn) { + probeCalls++; + // probeAppPageLayouts iterates inner-to-outer: + // first call → layout 1 (dashboard) → dynamic + // second call → layout 0 (root) → static + return Promise.resolve({ result: fn(), dynamicDetected: probeCalls === 1 }); + }, + }, + }); + + expect(calls).toHaveLength(2); + const byId = Object.fromEntries(calls.map((c) => [c.layoutId, c.reason])); + expect(byId["layout:/dashboard"]).toEqual({ + layer: "runtime-probe", + outcome: "dynamic", + }); + expect(byId["layout:/"]).toEqual({ + layer: "runtime-probe", + outcome: "static", + }); + }); + it("builds Link headers for preloaded app-page fonts", () => { expect( buildAppPageFontLinkHeader([ diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 65fcab377..abd59ae98 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -1,7 +1,21 @@ +import type { ReactNode } from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import React from "react"; +import { + APP_LAYOUT_FLAGS_KEY, + isAppElementsRecord, + type AppOutgoingElements, +} from "../packages/vinext/src/server/app-elements.js"; +import type { LayoutClassificationOptions } from "../packages/vinext/src/server/app-page-execution.js"; import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js"; +function captureRecord(value: ReactNode | AppOutgoingElements): Record { + if (!isAppElementsRecord(value)) { + throw new Error("Expected captured element to be a plain record"); + } + return value; +} + function createStream(chunks: string[]): ReadableStream { return new ReadableStream({ start(controller) { @@ -13,6 +27,25 @@ function createStream(chunks: string[]): ReadableStream { }); } +/** + * Parses row 0 from a fake-RSC stream payload and returns its top-level + * object keys. Tests assert on these keys to detect whether a slot survived + * the skip filter, without matching fragile substrings inside nested JSON. + */ +function parseRow0Keys(body: string): string[] { + for (const line of body.split("\n")) { + if (!line.startsWith("0:")) continue; + const payload = line.slice(2); + if (!payload.startsWith("{")) return []; + const parsed = JSON.parse(payload); + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return []; + } + return Object.keys(parsed); + } + return []; +} + function createCommonOptions() { const waitUntilPromises: Promise[] = []; const renderToReadableStream = vi.fn(() => createStream(["flight-data"])); @@ -316,3 +349,533 @@ describe("app page render lifecycle", () => { expect(common.isrSet).not.toHaveBeenCalled(); }); }); + +describe("layoutFlags injection into RSC payload", () => { + function createRscOptions(overrides: { + element?: Record; + layoutCount?: number; + probeLayoutAt?: (index: number) => unknown; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; + }) { + let capturedElement: Record | null = null; + + const options = { + cleanPathname: "/test", + clearRequestContext: vi.fn(), + consumeDynamicUsage: vi.fn(() => false), + createRscOnErrorHandler: () => () => {}, + getDraftModeCookieHeader: () => null, + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + getPageTags: () => [], + getRequestCacheLife: () => null, + handlerStart: 0, + hasLoadingBoundary: false, + isDynamicError: false, + isForceDynamic: false, + isForceStatic: false, + isProduction: true, + isRscRequest: true, + isrHtmlKey: (p: string) => `html:${p}`, + isrRscKey: (p: string) => `rsc:${p}`, + isrSet: vi.fn().mockResolvedValue(undefined), + layoutCount: overrides.layoutCount ?? 0, + loadSsrHandler: vi.fn(), + middlewareContext: { headers: null, status: null }, + params: {}, + probeLayoutAt: overrides.probeLayoutAt ?? (() => null), + probePage: () => null, + revalidateSeconds: null, + renderErrorBoundaryResponse: async () => null, + renderLayoutSpecialError: async () => new Response("error", { status: 500 }), + renderPageSpecialError: async () => new Response("error", { status: 500 }), + renderToReadableStream(el: ReactNode | AppOutgoingElements) { + capturedElement = captureRecord(el); + return createStream(["flight-data"]); + }, + routeHasLocalBoundary: false, + routePattern: "/test", + runWithSuppressedHookWarning: (probe: () => Promise) => probe(), + element: overrides.element ?? { "page:/test": "test-page" }, + classification: overrides.classification, + requestedSkipLayoutIds: overrides.requestedSkipLayoutIds, + }; + + return { + options, + getCapturedElement: (): Record => { + if (capturedElement === null) { + throw new Error("renderToReadableStream was not called"); + } + return capturedElement; + }, + }; + } + + it("injects __layoutFlags with 's' when classification detects a static layout", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "s" }); + }); + + it("injects __layoutFlags with 'd' for dynamic layouts", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: true }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ "layout:/": "d" }); + }); + + it("injects empty __layoutFlags when classification is not provided (backward compat)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { "layout:/": "root-layout", "page:/test": "test-page" }, + layoutCount: 1, + probeLayoutAt: () => null, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({}); + }); + + it("injects __layoutFlags for multiple independently classified layouts", async () => { + let callCount = 0; + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + callCount++; + const result = await fn(); + // probeAppPageLayouts iterates from layoutCount-1 down to 0: + // call 1 → layout index 1 (blog) → dynamic + // call 2 → layout index 0 (root) → static + return { result, dynamicDetected: callCount === 1 }; + }, + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/blog": "d", + }); + }); + + it("__layoutFlags includes flags for ALL layouts even when some are skipped", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + // layoutFlags must include ALL layout flags, even for skipped layouts + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/blog": "s", + }); + }); + + it("wire payload layoutFlags uses only the shorthand 's'/'d' values, never tagged reasons", async () => { + // Regression guard: Fix 5 introduced a tagged discriminated union for + // classification reasons as a build-time sidecar, but the wire payload + // must stay lean so debug metadata never leaks into the RSC stream. + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/admin": "admin-layout", + "page:/admin/users": "users-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + buildTimeClassifications: new Map([ + [0, "static"], + [1, "dynamic"], + ]), + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/admin"), + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + }); + + await renderAppPageLifecycle(options); + + const wireFlags = getCapturedElement()[APP_LAYOUT_FLAGS_KEY]; + expect(wireFlags).toEqual({ "layout:/": "s", "layout:/admin": "d" }); + + // Every entry must be exactly "s" or "d" — not an object, not undefined. + for (const [_id, flag] of Object.entries(wireFlags as Record)) { + expect(flag === "s" || flag === "d").toBe(true); + } + }); +}); + +describe("skip header filtering", () => { + /** + * Fake RSC serializer: emits a wire-format stream whose row 0 carries the + * same keys as the incoming element (so the byte filter can reference them + * by slot id). Each slot value becomes its own child row and row 0 + * references it via `$L`. + */ + function renderElementToFakeRsc(el: ReactNode | AppOutgoingElements): ReadableStream { + if (!isAppElementsRecord(el)) { + return createStream(["0:null\n"]); + } + const childRows: string[] = []; + const row0Record: Record = {}; + let nextId = 1; + for (const [key, value] of Object.entries(el)) { + if (key.startsWith("__")) { + row0Record[key] = value; + continue; + } + const id = nextId++; + const label = typeof value === "string" ? value : key; + childRows.push(`${id.toString(16)}:["$","div",null,${JSON.stringify({ children: label })}]`); + row0Record[key] = `$L${id.toString(16)}`; + } + const row0 = `0:${JSON.stringify(row0Record)}`; + return createStream([childRows.join("\n") + (childRows.length > 0 ? "\n" : ""), row0 + "\n"]); + } + + function createRscOptions(overrides: { + element?: Record; + layoutCount?: number; + probeLayoutAt?: (index: number) => unknown; + classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; + isRscRequest?: boolean; + revalidateSeconds?: number | null; + isProduction?: boolean; + }) { + let capturedElement: Record | null = null; + const isrSetCalls: Array<{ + key: string; + hasRscData: boolean; + rscText: string | null; + }> = []; + const waitUntilPromises: Promise[] = []; + const isrSet = vi.fn(async (key: string, data: { rscData?: ArrayBuffer }) => { + isrSetCalls.push({ + key, + hasRscData: Boolean(data.rscData), + rscText: data.rscData ? new TextDecoder().decode(data.rscData) : null, + }); + }); + + const options = { + cleanPathname: "/test", + clearRequestContext: vi.fn(), + consumeDynamicUsage: vi.fn(() => false), + createRscOnErrorHandler: () => () => {}, + getDraftModeCookieHeader: () => null, + getFontLinks: () => [], + getFontPreloads: () => [], + getFontStyles: () => [], + getNavigationContext: () => null, + getPageTags: () => [], + getRequestCacheLife: () => null, + handlerStart: 0, + hasLoadingBoundary: false, + isDynamicError: false, + isForceDynamic: false, + isForceStatic: false, + isProduction: overrides.isProduction ?? true, + isRscRequest: overrides.isRscRequest ?? true, + isrHtmlKey: (p: string) => `html:${p}`, + isrRscKey: (p: string) => `rsc:${p}`, + isrSet, + layoutCount: overrides.layoutCount ?? 0, + loadSsrHandler: vi.fn(async () => ({ + async handleSsr() { + return createStream(["page"]); + }, + })), + middlewareContext: { headers: null, status: null }, + params: {}, + probeLayoutAt: overrides.probeLayoutAt ?? (() => null), + probePage: () => null, + revalidateSeconds: overrides.revalidateSeconds ?? null, + renderErrorBoundaryResponse: async () => null, + renderLayoutSpecialError: async () => new Response("error", { status: 500 }), + renderPageSpecialError: async () => new Response("error", { status: 500 }), + renderToReadableStream(el: ReactNode | AppOutgoingElements) { + capturedElement = captureRecord(el); + return renderElementToFakeRsc(el); + }, + routeHasLocalBoundary: false, + routePattern: "/test", + runWithSuppressedHookWarning: (probe: () => Promise) => probe(), + element: overrides.element ?? { "page:/test": "test-page" }, + classification: overrides.classification, + requestedSkipLayoutIds: overrides.requestedSkipLayoutIds, + waitUntil(promise: Promise) { + waitUntilPromises.push(promise); + }, + }; + + return { + options, + isrSetCalls, + waitUntilPromises, + getCapturedElement: (): Record => { + if (capturedElement === null) { + throw new Error("renderToReadableStream was not called"); + } + return capturedElement; + }, + }; + } + + function staticClassification(layoutIdMap: Record): LayoutClassificationOptions { + return { + getLayoutId: (index: number) => layoutIdMap[index], + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }; + } + + it("renders the canonical element to the RSC serializer regardless of skipIds", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured["layout:/"]).toBe("root-layout"); + expect(captured["layout:/blog"]).toBe("blog-layout"); + expect(captured["page:/blog/post"]).toBe("post-page"); + }); + + it("omits the skipped slot from the RSC response body on RSC requests", async () => { + const { options } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + const response = await renderAppPageLifecycle(options); + const body = await response.text(); + const row0Keys = parseRow0Keys(body); + expect(row0Keys).not.toContain("layout:/"); + expect(row0Keys).toContain("layout:/blog"); + expect(row0Keys).toContain("page:/blog/post"); + expect(body).not.toContain("root-layout"); + expect(body).toContain("blog-layout"); + expect(body).toContain("post-page"); + }); + + it("keeps a dynamic layout in the response body even if the client asked to skip it", async () => { + const { options } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: { + getLayoutId: () => "layout:/", + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: true }; + }, + }, + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + const response = await renderAppPageLifecycle(options); + const body = await response.text(); + const row0Keys = parseRow0Keys(body); + expect(row0Keys).toContain("layout:/"); + expect(body).toContain("root-layout"); + }); + + it("preserves metadata keys in the filtered response body", async () => { + const { options } = createRscOptions({ + element: { + __route: "route:/blog", + __rootLayout: "/", + __interceptionContext: null, + "layout:/": "root-layout", + "page:/blog": "blog-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + const response = await renderAppPageLifecycle(options); + const body = await response.text(); + const row0Keys = parseRow0Keys(body); + expect(row0Keys).toContain("__route"); + expect(row0Keys).toContain("__rootLayout"); + expect(row0Keys).toContain("__layoutFlags"); + expect(row0Keys).not.toContain("layout:/"); + expect(row0Keys).toContain("page:/blog"); + }); + + it("returns byte-identical output when skip set is empty", async () => { + const { options } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(), + }); + + const response = await renderAppPageLifecycle(options); + const body = await response.text(); + const row0Keys = parseRow0Keys(body); + expect(row0Keys).toContain("layout:/"); + expect(row0Keys).toContain("layout:/blog"); + expect(row0Keys).toContain("page:/blog/post"); + }); + + it("does not filter layouts on non-RSC requests (SSR/initial load)", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + isRscRequest: false, + }); + + await renderAppPageLifecycle(options); + const captured = getCapturedElement(); + expect(captured["layout:/"]).toBe("root-layout"); + }); + + it("writes canonical RSC bytes to the cache even when skipIds are non-empty", async () => { + const { options, isrSetCalls, waitUntilPromises } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + isProduction: true, + revalidateSeconds: 60, + }); + + const response = await renderAppPageLifecycle(options); + await response.text(); + await Promise.all(waitUntilPromises); + + const rscWrite = isrSetCalls.find((call) => call.key.startsWith("rsc:")); + expect(rscWrite?.hasRscData).toBe(true); + expect(rscWrite?.rscText).toBeDefined(); + // Canonical cache bytes must contain ALL slot keys, even the skipped one. + const cachedRow0Keys = parseRow0Keys(rscWrite?.rscText ?? ""); + expect(cachedRow0Keys).toContain("layout:/"); + expect(cachedRow0Keys).toContain("layout:/blog"); + expect(cachedRow0Keys).toContain("page:/blog/post"); + }); + + it("does not mutate options.element during render", async () => { + const element: Record = { + __route: "route:/blog", + __rootLayout: "/", + __interceptionContext: null, + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog": "blog-page", + }; + const snapshot = structuredClone(element); + const snapshotKeys = Object.keys(element); + + const { options } = createRscOptions({ + element, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + }); + + const response = await renderAppPageLifecycle(options); + await response.text(); + + expect(element).toEqual(snapshot); + expect(Object.keys(element)).toEqual(snapshotKeys); + expect("__layoutFlags" in element).toBe(false); + }); +}); diff --git a/tests/app-page-skip-filter.test.ts b/tests/app-page-skip-filter.test.ts new file mode 100644 index 000000000..f056307b7 --- /dev/null +++ b/tests/app-page-skip-filter.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, test } from "vite-plus/test"; +import { + collectRscReferenceIds, + createSkipFilterTransform, + filterRow0, + parseRscReferenceString, + wrapRscBytesForResponse, +} from "../packages/vinext/src/server/app-page-skip-filter.js"; + +describe("parseRscReferenceString", () => { + test("accepts bare hex reference", () => { + expect(parseRscReferenceString("$5")).toBe(5); + }); + + test("accepts $L lazy reference", () => { + expect(parseRscReferenceString("$L5")).toBe(5); + }); + + test("accepts $@ thenable reference", () => { + expect(parseRscReferenceString("$@5")).toBe(5); + }); + + test("accepts $F reference", () => { + expect(parseRscReferenceString("$F5")).toBe(5); + }); + + test("accepts $Q map reference", () => { + expect(parseRscReferenceString("$Q5")).toBe(5); + }); + + test("accepts $W set reference", () => { + expect(parseRscReferenceString("$W5")).toBe(5); + }); + + test("accepts $K formdata reference", () => { + expect(parseRscReferenceString("$K5")).toBe(5); + }); + + test("accepts $B blob reference", () => { + expect(parseRscReferenceString("$B5")).toBe(5); + }); + + test("parses multi-digit hex ids", () => { + expect(parseRscReferenceString("$Lff")).toBe(255); + }); + + test("rejects $$ literal-dollar escape", () => { + expect(parseRscReferenceString("$$")).toBeNull(); + }); + + test("rejects $undefined", () => { + expect(parseRscReferenceString("$undefined")).toBeNull(); + }); + + test("rejects $NaN", () => { + expect(parseRscReferenceString("$NaN")).toBeNull(); + }); + + test("rejects $Z error tag", () => { + expect(parseRscReferenceString("$Z")).toBeNull(); + }); + + test("rejects $-Infinity", () => { + expect(parseRscReferenceString("$-Infinity")).toBeNull(); + }); + + test("rejects $n bigint prefix", () => { + expect(parseRscReferenceString("$n123")).toBeNull(); + }); + + test("rejects unrelated strings", () => { + expect(parseRscReferenceString("abc")).toBeNull(); + }); + + test("rejects $L without digits", () => { + expect(parseRscReferenceString("$L")).toBeNull(); + }); + + test("rejects lone $", () => { + expect(parseRscReferenceString("$")).toBeNull(); + }); +}); + +describe("collectRscReferenceIds", () => { + test("collects a single top-level reference", () => { + const set = new Set(); + collectRscReferenceIds("$L5", set); + expect(set).toEqual(new Set([5])); + }); + + test("walks nested arrays", () => { + const set = new Set(); + collectRscReferenceIds(["$L1", ["$2", "plain", ["$@3"]]], set); + expect(set).toEqual(new Set([1, 2, 3])); + }); + + test("walks nested objects", () => { + const set = new Set(); + collectRscReferenceIds({ a: "$L1", b: { c: "$2", d: "$3" } }, set); + expect(set).toEqual(new Set([1, 2, 3])); + }); + + test("ignores $$ and other non-row tags", () => { + const set = new Set(); + collectRscReferenceIds(["$$", "$undefined", "$Z", "$NaN", "$T"], set); + expect(set).toEqual(new Set()); + }); + + test("dedupes duplicate references", () => { + const set = new Set(); + collectRscReferenceIds(["$L1", "$L1", { a: "$1" }], set); + expect(set).toEqual(new Set([1])); + }); + + test("no-op on primitives", () => { + const set = new Set(); + collectRscReferenceIds(42, set); + collectRscReferenceIds(true, set); + collectRscReferenceIds(null, set); + collectRscReferenceIds(undefined, set); + expect(set).toEqual(new Set()); + }); + + test("handles empty object and empty array", () => { + const set = new Set(); + collectRscReferenceIds({}, set); + collectRscReferenceIds([], set); + expect(set).toEqual(new Set()); + }); +}); + +describe("filterRow0", () => { + test("rewrites record by deleting skipped keys", () => { + const row0 = { + "slot:layout:/": "$L1", + "slot:page": "$L2", + __route: "route:/", + }; + const { rewritten, liveIds } = filterRow0(row0, new Set(["slot:layout:/"])); + expect(rewritten).toEqual({ "slot:page": "$L2", __route: "route:/" }); + expect(liveIds).toEqual(new Set([2])); + }); + + test("seeds liveIds from surviving keys only, not killed ones", () => { + // Killed slot references row 1; kept slot references row 2. + // Row 1 must not appear in liveIds — it was referenced only from a killed slot. + const row0 = { + "slot:layout:/": "$L1", + "slot:page": "$L2", + __route: "route:/", + }; + const { liveIds } = filterRow0(row0, new Set(["slot:layout:/"])); + expect(liveIds.has(1)).toBe(false); + expect(liveIds.has(2)).toBe(true); + }); + + test("keeps a row referenced from both a kept and a killed key", () => { + // Both slots reference row 2; killed slot also references row 1. + // Row 2 is shared, so it stays live via the kept slot. + const row0 = { + "slot:layout:/": ["$L1", "$L2"], + "slot:page": "$L2", + }; + const { liveIds } = filterRow0(row0, new Set(["slot:layout:/"])); + expect(liveIds).toEqual(new Set([2])); + }); + + test("empty skipIds returns a rewrite with full liveIds", () => { + const row0 = { + "slot:layout:/": "$L1", + "slot:page": "$L2", + }; + const { rewritten, liveIds } = filterRow0(row0, new Set()); + expect(rewritten).toEqual(row0); + expect(liveIds).toEqual(new Set([1, 2])); + }); + + test("skipIds with no matching row-0 keys yields a no-op rewrite", () => { + const row0 = { + "slot:layout:/": "$L1", + "slot:page": "$L2", + }; + const { rewritten, liveIds } = filterRow0(row0, new Set(["slot:nonexistent"])); + expect(rewritten).toEqual(row0); + expect(liveIds).toEqual(new Set([1, 2])); + }); +}); + +function createRscStream( + rows: string[], + chunkBoundaries: readonly number[] = [], +): ReadableStream { + const text = rows.join("\n") + "\n"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(text); + const boundaries = [...chunkBoundaries, bytes.byteLength]; + return new ReadableStream({ + start(controller) { + let start = 0; + for (const end of boundaries) { + controller.enqueue(bytes.slice(start, end)); + start = end; + } + controller.close(); + }, + }); +} + +async function collectStreamText(stream: ReadableStream): Promise { + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let out = ""; + for (;;) { + const { done, value } = await reader.read(); + if (done) { + break; + } + out += decoder.decode(value, { stream: true }); + } + out += decoder.decode(); + return out; +} + +describe("createSkipFilterTransform", () => { + test("empty skipIds passes through byte-equal", async () => { + const rows = [ + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + `1:["$","div",null,{"children":"root"}]`, + `2:["$","p",null,{"children":"hello"}]`, + ]; + const text = rows.join("\n") + "\n"; + + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set()); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toBe(text); + }); + + test("single skipped key with one orphaned child drops the child row", async () => { + const rows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2","__route":"route:/"}`, + ]; + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`2:["$","p",null,{"children":"page"}]`); + expect(output).not.toContain(`1:["$","header"`); + const expectedRow0 = `0:${JSON.stringify({ "slot:page": "$L2", __route: "route:/" })}`; + expect(output).toContain(expectedRow0); + }); + + test("keeps a row referenced from both kept and killed slots", async () => { + const rows = [ + `1:["$","header",null,{"children":"layout-only"}]`, + `2:["$","span",null,{"children":"shared"}]`, + `0:{"slot:layout:/":["$L1","$L2"],"slot:page":"$L2"}`, + ]; + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`2:["$","span",null,{"children":"shared"}]`); + expect(output).not.toContain(`1:["$","header"`); + }); + + test("parses row 0 split across multiple chunks", async () => { + const rows = [ + `1:["$","div",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + // Split the text at several arbitrary boundaries inside row 0. + const fullText = rows.join("\n") + "\n"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(fullText); + // Pick boundaries inside the row 0 range. + const row0Start = fullText.indexOf("0:"); + const row0End = fullText.length - 1; + const boundaries = [ + row0Start + 3, + row0Start + 10, + row0Start + Math.floor((row0End - row0Start) / 2), + ]; + + const input = new ReadableStream({ + start(controller) { + let start = 0; + for (const end of [...boundaries, bytes.byteLength]) { + controller.enqueue(bytes.slice(start, end)); + start = end; + } + controller.close(); + }, + }); + + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`2:["$","p",null,{"children":"page"}]`); + expect(output).not.toContain(`1:["$","div"`); + const expectedRow0 = `0:${JSON.stringify({ "slot:page": "$L2" })}`; + expect(output).toContain(expectedRow0); + }); + + test("parses a post-root row split across multiple chunks", async () => { + // Row 0 arrives first (async case), then row 2 spans chunk boundaries. + const rows = [`0:{"slot:page":"$L2"}`, `2:["$","section",null,{"children":"spanned"}]`]; + const fullText = rows.join("\n") + "\n"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(fullText); + const row2Start = fullText.indexOf("2:"); + const boundaries = [row2Start + 4, row2Start + 14]; + + const input = new ReadableStream({ + start(controller) { + let start = 0; + for (const end of [...boundaries, bytes.byteLength]) { + controller.enqueue(bytes.slice(start, end)); + start = end; + } + controller.close(); + }, + }); + + const transform = createSkipFilterTransform(new Set()); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`2:["$","section",null,{"children":"spanned"}]`); + expect(output).toContain(`0:{"slot:page":"$L2"}`); + }); + + test("drops an orphaned sibling even when out of input order", async () => { + // Row 0 references $L5 and $L2 only. Rows 3 and 4 are only referenced + // from killed slot and must drop. + const rows = [ + `5:["$","div",null,{"children":"kept-5"}]`, + `3:["$","div",null,{"children":"orphan-3"}]`, + `4:["$","div",null,{"children":"orphan-4"}]`, + `2:["$","div",null,{"children":"kept-2"}]`, + `0:{"slot:page":["$L5","$L2"],"slot:layout:/":["$L3","$L4"]}`, + ]; + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`5:["$","div",null,{"children":"kept-5"}]`); + expect(output).toContain(`2:["$","div",null,{"children":"kept-2"}]`); + expect(output).not.toContain("orphan-3"); + expect(output).not.toContain("orphan-4"); + }); + + test("flush emits a trailing row that lacks a terminating newline", async () => { + // Construct a byte sequence missing the final newline. + const rows = [`1:["$","div",null,{"children":"layout"}]`, `0:{"slot:page":"$L1"}`]; + const text = rows.join("\n"); + const bytes = new TextEncoder().encode(text); + const input = new ReadableStream({ + start(controller) { + controller.enqueue(bytes); + controller.close(); + }, + }); + const transform = createSkipFilterTransform(new Set()); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`1:["$","div",null,{"children":"layout"}]`); + expect(output).toContain(`0:{"slot:page":"$L1"}`); + }); + + test("preserves bytes after row 0 in the same chunk when row 0 is mid-buffer", async () => { + // Row 0 arrives first, immediately followed in the same chunk by a partial + // row 1. The remaining bytes of row 1 arrive in a second chunk. With + // non-empty skipIds the filter must carry over the residue across the + // initial -> streaming phase transition. A regression here drops the + // residue and loses row 1 entirely. + const rows = [`0:{"slot:page":"$L1"}`, `1:["$","section",null,{"children":"after-root"}]`]; + const fullText = rows.join("\n") + "\n"; + const encoder = new TextEncoder(); + const bytes = encoder.encode(fullText); + // Boundary inside row 1 so bytes after row 0 in chunk 1 are residue. + const row1Start = fullText.indexOf("1:"); + const split = row1Start + 6; + + const input = new ReadableStream({ + start(controller) { + controller.enqueue(bytes.slice(0, split)); + controller.enqueue(bytes.slice(split)); + controller.close(); + }, + }); + + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`0:{"slot:page":"$L1"}`); + expect(output).toContain(`1:["$","section",null,{"children":"after-root"}]`); + }); + + test("passes through unrecognized rows that arrive before row 0", async () => { + // Streaming phase passes unrecognized rows through verbatim. The initial + // phase must do the same so a malformed line buffered before row 0 still + // reaches the client. Otherwise a stray non-row-shaped line in front of + // row 0 silently disappears. + const rows = [ + `not-a-row-prefix-line`, + `1:["$","div",null,{"children":"layout"}]`, + `0:{"slot:page":"$L1"}`, + ]; + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`not-a-row-prefix-line`); + }); + + test("falls back to canonical passthrough when row 0 JSON cannot be parsed", async () => { + const rows = [ + `1:["$","div",null,{"children":"before-root"}]`, + `0:{"slot:page":"$L2"`, + `2:["$","section",null,{"children":"after-root"}]`, + `3:["$","footer",null,{"children":"tail"}]`, + ]; + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["slot:layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + + expect(output).toBe(rows.join("\n") + "\n"); + }); + + test("filters the same orphan on repeat runs (no shared state)", async () => { + const rows = [ + `1:["$","div",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const skip = new Set(["slot:layout:/"]); + for (let i = 0; i < 2; i++) { + const input = createRscStream(rows); + const transform = createSkipFilterTransform(skip); + const output = await collectStreamText(input.pipeThrough(transform)); + expect(output).toContain(`2:["$","p",null,{"children":"page"}]`); + expect(output).not.toContain(`1:["$","div"`); + } + }); +}); + +describe("wrapRscBytesForResponse", () => { + test("empty skipIds returns the raw ArrayBuffer identity", () => { + const bytes = new TextEncoder().encode("hello").buffer; + const result = wrapRscBytesForResponse(bytes, new Set()); + expect(result).toBe(bytes); + }); + + test("non-empty skipIds returns a filtered stream", async () => { + const rows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `0:{"slot:layout:/":"$L1","slot:page":"$L2"}`, + ]; + const text = rows.join("\n") + "\n"; + const bytes = new TextEncoder().encode(text).buffer; + + const result = wrapRscBytesForResponse(bytes, new Set(["slot:layout:/"])); + // The result is a BodyInit — wrap in Response to normalize. + const response = new Response(result); + const output = await response.text(); + expect(output).toContain(`2:["$","p",null,{"children":"page"}]`); + expect(output).not.toContain(`1:["$","header"`); + }); +}); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index d71a69380..bc6216cbf 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3634,6 +3634,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..0dbf652b3 --- /dev/null +++ b/tests/build-time-classification-integration.test.ts @@ -0,0 +1,253 @@ +/** + * 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"); + } + + 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 routeIdx assignments emitted in the `routes = [...]` + * table so the tests can map back from pattern strings (stable across test + * edits) to numeric indices (stable across plugin code). + */ +function extractRouteIndexByPattern(chunkSource: string): Map { + const result = new Map(); + const re = + /routeIdx:\s*(\d+),\s*__buildTimeClassifications:[^,]+,\s*__buildTimeReasons:[^,]+,\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 routeIdx + 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("gates the reasons sidecar behind __classDebug in the route table", () => { + expect(built.chunkSource).toMatch( + /__buildTimeReasons:\s*__classDebug\s*\?\s*__VINEXT_CLASS_REASONS\(\d+\)\s*:\s*null/, + ); + }); + + 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..240880859 --- /dev/null +++ b/tests/route-classification-manifest.test.ts @@ -0,0 +1,366 @@ +/** + * 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(); + }); +}); + +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(); + }); +}); From 2e0e0ad443e88d7adf14139f4d594fe4fb922824 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:12:35 +1000 Subject: [PATCH 2/4] test: refresh entry template snapshots --- .../entry-templates.test.ts.snap | 264 +++++++++++++++++- 1 file changed, 258 insertions(+), 6 deletions(-) diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 38913428c..6d5aa89e3 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -260,6 +260,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -408,8 +418,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -433,6 +464,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -456,6 +490,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -479,6 +516,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -2122,7 +2162,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -2480,6 +2522,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -2628,8 +2680,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -2653,6 +2726,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -2676,6 +2752,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -2699,6 +2778,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -4348,7 +4430,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -4706,6 +4790,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -4855,8 +4949,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -4880,6 +4995,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -4903,6 +5021,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -4926,6 +5047,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -6569,7 +6693,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -6927,6 +7053,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -7105,8 +7241,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -7130,6 +7287,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -7153,6 +7313,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -7176,6 +7339,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -8822,7 +8988,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -9180,6 +9348,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -9329,8 +9507,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -9354,6 +9553,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -9377,6 +9579,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -9400,6 +9605,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -11049,7 +11257,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -11407,6 +11617,16 @@ const __isrDebug = process.env.NEXT_PRIVATE_DEBUG_CACHE ? console.debug.bind(console, "[vinext] ISR:") : undefined; +// Classification debug — opt in with VINEXT_DEBUG_CLASSIFICATION=1. Gated on +// the env var so the hot path pays no overhead unless an operator is actively +// tracing why a layout was flagged static or dynamic. The reason payload is +// carried by __VINEXT_CLASS_REASONS and consumed inside probeAppPageLayouts. +const __classDebug = process.env.VINEXT_DEBUG_CLASSIFICATION + ? function(layoutId, reason) { + console.debug("[vinext] CLS:", layoutId, reason); + } + : undefined; + // Normalize null-prototype objects from matchPattern() into thenable objects // that work both as Promises (for Next.js 15+ async params) and as plain // objects with synchronous property access (for pre-15 code like params.id). @@ -11555,8 +11775,29 @@ 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; +} + +// Build-time layout classification reasons dispatch. Sibling of +// __VINEXT_CLASS, returning a per-route Map +// that feeds the debug channel when VINEXT_DEBUG_CLASSIFICATION is active. +// Replaced in generateBundle with a real dispatch table; the stub returns +// null so the hot path never allocates reason maps when debug is off. +function __VINEXT_CLASS_REASONS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(0) : null, pattern: "/", patternParts: [], isDynamic: false, @@ -11580,6 +11821,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -11603,6 +11847,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -11626,6 +11873,9 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -13636,7 +13886,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, - buildTimeClassifications: null, // Future: embed from Vite build plugin codegen + buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { From c68aa2a62022c63d35337cb73bd42bdbf4c50dae Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 17:46:14 +1000 Subject: [PATCH 3/4] fix: disable unsafe app-router skip filtering --- packages/vinext/src/entries/app-rsc-entry.ts | 1 + packages/vinext/src/server/app-page-render.ts | 8 ++++--- .../vinext/src/server/app-page-skip-filter.ts | 9 ++++++++ .../entry-templates.test.ts.snap | 6 +++++ tests/app-page-render.test.ts | 22 +++++++++++++++++++ tests/app-page-skip-filter.test.ts | 21 ++++++++++++++++++ 6 files changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index c4be7cff7..b17f5f803 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2442,6 +2442,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 9da780f49..6b2619ab2 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -75,6 +75,7 @@ export type RenderAppPageLifecycleOptions = { isForceStatic: boolean; isProduction: boolean; isRscRequest: boolean; + supportsFilteredRscStream?: boolean; isrDebug?: AppPageDebugLogger; isrHtmlKey: (pathname: string) => string; isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string; @@ -182,9 +183,10 @@ export async function renderAppPageLifecycle( ); const isrRscDataPromise = rscCapture.capturedRscDataPromise; - const skipIds = options.isRscRequest - ? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds) - : EMPTY_SKIP_SET; + const skipIds = + options.isRscRequest && (options.supportsFilteredRscStream ?? true) + ? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds) + : EMPTY_SKIP_SET; const rscForResponse = skipIds.size > 0 ? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds)) diff --git a/packages/vinext/src/server/app-page-skip-filter.ts b/packages/vinext/src/server/app-page-skip-filter.ts index bbeebb3d4..b9337673d 100644 --- a/packages/vinext/src/server/app-page-skip-filter.ts +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -122,6 +122,15 @@ function addRefsFromRaw(raw: string, into: Set): void { return; } const payload = raw.slice(colonIndex + 1); + + // React dev/progressive rows can carry references outside plain JSON + // object/array payloads, for example `1:D"$3a"`. Track those too so + // later rows are not dropped as orphans when a kept row introduces a + // new live id through a deferred chunk. + for (const match of payload.matchAll(/(? { isRscRequest?: boolean; revalidateSeconds?: number | null; isProduction?: boolean; + supportsFilteredRscStream?: boolean; }) { let capturedElement: Record | null = null; const isrSetCalls: Array<{ @@ -632,6 +633,7 @@ describe("skip header filtering", () => { isForceStatic: false, isProduction: overrides.isProduction ?? true, isRscRequest: overrides.isRscRequest ?? true, + supportsFilteredRscStream: overrides.supportsFilteredRscStream ?? true, isrHtmlKey: (p: string) => `html:${p}`, isrRscKey: (p: string) => `rsc:${p}`, isrSet, @@ -822,6 +824,26 @@ describe("skip header filtering", () => { expect(captured["layout:/"]).toBe("root-layout"); }); + it("does not filter layouts on development RSC requests", async () => { + const { options } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-page", + }, + layoutCount: 1, + probeLayoutAt: () => null, + classification: staticClassification({ 0: "layout:/" }), + requestedSkipLayoutIds: new Set(["layout:/"]), + supportsFilteredRscStream: false, + }); + + const response = await renderAppPageLifecycle(options); + const body = await response.text(); + const row0Keys = parseRow0Keys(body); + expect(row0Keys).toContain("layout:/"); + expect(body).toContain("root-layout"); + }); + it("writes canonical RSC bytes to the cache even when skipIds are non-empty", async () => { const { options, isrSetCalls, waitUntilPromises } = createRscOptions({ element: { diff --git a/tests/app-page-skip-filter.test.ts b/tests/app-page-skip-filter.test.ts index f056307b7..28e301cd0 100644 --- a/tests/app-page-skip-filter.test.ts +++ b/tests/app-page-skip-filter.test.ts @@ -392,6 +392,27 @@ describe("createSkipFilterTransform", () => { expect(output).toContain(`1:["$","section",null,{"children":"after-root"}]`); }); + test("keeps rows introduced by deferred reference chunks", async () => { + const rows = [ + `0:{"page:/search":"$L1","layout:/":"$L5"}`, + `1:D"$2"`, + `2:{"name":"SearchPage"}`, + `1:["$","div",null,{"children":"search"},"$2","$3",1]`, + `3:[["SearchPage","/app/search/page.tsx",1,1,1,false]]`, + `5:["$","html",null,{"children":"layout"}]`, + ]; + + const input = createRscStream(rows); + const transform = createSkipFilterTransform(new Set(["layout:/"])); + const output = await collectStreamText(input.pipeThrough(transform)); + + expect(output).toContain(`1:D"$2"`); + expect(output).toContain(`2:{"name":"SearchPage"}`); + expect(output).toContain(`1:["$","div",null,{"children":"search"},"$2","$3",1]`); + expect(output).toContain(`3:[["SearchPage","/app/search/page.tsx",1,1,1,false]]`); + expect(output).not.toContain(`5:["$","html",null,{"children":"layout"}]`); + }); + test("passes through unrecognized rows that arrive before row 0", async () => { // Streaming phase passes unrecognized rows through verbatim. The initial // phase must do the same so a malformed line buffered before row 0 still From d63f29a335e05f97d0178485f29629fdb2a6bd26 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 18:00:58 +1000 Subject: [PATCH 4/4] fix: restore mounted-slot requests and tighten codegen seams --- .../build/route-classification-manifest.ts | 3 +- packages/vinext/src/entries/app-rsc-entry.ts | 61 ++- packages/vinext/src/index.ts | 22 +- .../vinext/src/server/app-browser-entry.ts | 21 +- packages/vinext/src/server/app-elements.ts | 25 ++ .../vinext/src/server/app-page-skip-filter.ts | 6 + .../entry-templates.test.ts.snap | 366 ++++++++++++------ tests/app-elements.test.ts | 26 ++ ...ld-time-classification-integration.test.ts | 9 + 9 files changed, 370 insertions(+), 169 deletions(-) diff --git a/packages/vinext/src/build/route-classification-manifest.ts b/packages/vinext/src/build/route-classification-manifest.ts index 67df49fad..1bfd08da7 100644 --- a/packages/vinext/src/build/route-classification-manifest.ts +++ b/packages/vinext/src/build/route-classification-manifest.ts @@ -197,7 +197,8 @@ export function buildGenerateBundleReplacement( * that returns `Map` per route. * * The runtime consults this map only when `VINEXT_DEBUG_CLASSIFICATION` is - * set, so the bundle-size cost is bounded and the hot path pays nothing. + * 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 diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index b17f5f803..c1b5f71f3 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -968,7 +968,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -1860,12 +1868,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -2256,12 +2266,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -2316,12 +2328,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -2375,7 +2389,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 7174a69a5..de42be8f2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -1661,6 +1661,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { if (this.environment?.name !== "rsc") return; if (!rscClassificationManifest) return; + const enableClassificationDebug = Boolean(process.env.VINEXT_DEBUG_CLASSIFICATION); const stubRe = /function __VINEXT_CLASS\(routeIdx\)\s*\{\s*return null;?\s*\}/; const reasonsStubRe = /function __VINEXT_CLASS_REASONS\(routeIdx\)\s*\{\s*return null;?\s*\}/; @@ -1708,7 +1709,7 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `vinext: build-time classification — expected __VINEXT_CLASS stub in exactly one RSC chunk, found ${chunksWithStubBody.length}`, ); } - if (!reasonsStubRe.test(chunksWithStubBody[0]!.chunk.code)) { + if (enableClassificationDebug && !reasonsStubRe.test(chunksWithStubBody[0]!.chunk.code)) { throw new Error( "vinext: build-time classification — __VINEXT_CLASS_REASONS stub is missing alongside __VINEXT_CLASS. The generator and generateBundle have drifted.", ); @@ -1784,17 +1785,18 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { rscClassificationManifest, layer2PerRoute, ); - const reasonsReplacement = buildReasonsReplacement( - rscClassificationManifest, - layer2PerRoute, - ); - const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`; - const patchedReasonsBody = `function __VINEXT_CLASS_REASONS(routeIdx) { return (${reasonsReplacement})(routeIdx); }`; const target = chunksWithStubBody[0]!.chunk; - target.code = target.code - .replace(stubRe, patchedBody) - .replace(reasonsStubRe, patchedReasonsBody); + target.code = target.code.replace(stubRe, patchedBody); + + if (enableClassificationDebug) { + const reasonsReplacement = buildReasonsReplacement( + rscClassificationManifest, + layer2PerRoute, + ); + const patchedReasonsBody = `function __VINEXT_CLASS_REASONS(routeIdx) { return (${reasonsReplacement})(routeIdx); }`; + target.code = target.code.replace(reasonsStubRe, patchedReasonsBody); + } }, }, // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 5e3e6ae39..e46aefd5a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -53,13 +53,12 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { - buildSkipHeaderValue, + createRscNavigationRequestHeaders, createAppPayloadCacheKey, getMountedSlotIdsHeader, normalizeAppElements, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, - X_VINEXT_ROUTER_SKIP_HEADER, type AppElements, type AppWireElements, type LayoutFlags, @@ -369,21 +368,6 @@ function getRequestState( } } -function createRscRequestHeaders( - interceptionContext: string | null, - layoutFlags: LayoutFlags, -): Headers { - const headers = new Headers({ Accept: "text/x-component" }); - if (interceptionContext !== null) { - headers.set("X-Vinext-Interception-Context", interceptionContext); - } - const skipValue = buildSkipHeaderValue(layoutFlags); - if (skipValue !== null) { - headers.set(X_VINEXT_ROUTER_SKIP_HEADER, skipValue); - } - return headers; -} - /** * Resolve all pending navigation commits with renderId <= the committed renderId. * Note: Map iteration handles concurrent deletion safely — entries are visited in @@ -983,8 +967,9 @@ async function main(): Promise { } if (!navResponse) { - const requestHeaders = createRscRequestHeaders( + const requestHeaders = createRscNavigationRequestHeaders( requestInterceptionContext, + mountedSlotsHeader, getBrowserRouterState().layoutFlags, ); navResponse = await fetch(rscUrl, { diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 4e4fa039a..4902df4fa 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -139,6 +139,7 @@ export function normalizeAppElements(elements: AppWireElements): AppElements { } export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip"; +export const X_VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots"; export function parseSkipHeader(header: string | null): ReadonlySet { if (!header) return new Set(); @@ -161,6 +162,30 @@ export function buildSkipHeaderValue(layoutFlags: LayoutFlags): string | null { return staticIds.length > 0 ? staticIds.join(",") : null; } +/** + * Pure: builds the request headers for an App Router RSC navigation. + * Centralizing this contract keeps the navigation fetch aligned with the + * cache-key inputs (`interceptionContext`, mounted slots, layout flags). + */ +export function createRscNavigationRequestHeaders( + interceptionContext: string | null, + mountedSlotsHeader: string | null, + layoutFlags: LayoutFlags, +): Headers { + const headers = new Headers({ Accept: "text/x-component" }); + if (interceptionContext !== null) { + headers.set("X-Vinext-Interception-Context", interceptionContext); + } + if (mountedSlotsHeader !== null) { + headers.set(X_VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); + } + const skipValue = buildSkipHeaderValue(layoutFlags); + if (skipValue !== null) { + headers.set(X_VINEXT_ROUTER_SKIP_HEADER, skipValue); + } + return headers; +} + function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { if (!value || typeof value !== "object" || Array.isArray(value)) return false; for (const v of Object.values(value)) { diff --git a/packages/vinext/src/server/app-page-skip-filter.ts b/packages/vinext/src/server/app-page-skip-filter.ts index b9337673d..31fd59b85 100644 --- a/packages/vinext/src/server/app-page-skip-filter.ts +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -24,6 +24,9 @@ * streams subsequent rows through a forward-pass live set. */ +// React Flight row-reference tags come from react-server-dom-webpack's client +// parser. Audit this character class when upgrading React so new reference-like +// tags do not become silent drop cases in the filter. const REFERENCE_PATTERN = /^\$(?:[LBFQWK@])?([0-9a-fA-F]+)$/; /** @@ -193,6 +196,9 @@ export function createSkipFilterTransform( return; } const { rewritten, liveIds } = filterRow0(parsed, skipIds); + // App Router row 0 is always the plain JSON elements record. If that + // invariant changes, this filter must stop rewriting row 0 and fall back + // to canonical passthrough instead of serializing the wrong wire format. const newRow0Raw = `0:${JSON.stringify(rewritten)}`; for (const row of pending) { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index c22ea4224..d8c1cad26 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -731,7 +731,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -1588,12 +1596,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -1942,12 +1952,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -2002,12 +2014,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -2061,7 +2075,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -2994,7 +3015,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -3857,12 +3886,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -4211,12 +4242,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -4271,12 +4304,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -4330,7 +4365,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -5264,7 +5306,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -6121,12 +6171,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -6475,12 +6527,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -6535,12 +6589,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -6594,7 +6650,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -7557,7 +7620,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -8417,12 +8488,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -8771,12 +8844,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -8831,12 +8906,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -8890,7 +8967,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -9830,7 +9914,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -10687,12 +10779,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -11041,12 +11135,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -11101,12 +11197,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -11160,7 +11258,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); @@ -12093,7 +12198,15 @@ function findIntercept(pathname) { return null; } -async function buildPageElements(route, params, routePath, opts, searchParams, isRscRequest, request, mountedSlotsHeader, skipLayoutIds) { +async function buildPageElements(route, params, routePath, pageRequest) { + const { + opts, + searchParams, + isRscRequest, + request, + mountedSlotsHeader, + skipLayoutIds, + } = pageRequest; const PageComponent = route.page?.default; if (!PageComponent) { const _interceptionContext = opts?.interceptionContext ?? null; @@ -13317,12 +13430,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { actionRoute, actionParams, cleanPathname, - undefined, - url.searchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); } else { const _actionRouteId = __createAppPayloadRouteId(cleanPathname, null); @@ -13671,12 +13786,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { route, params, cleanPathname, - undefined, - new URLSearchParams(), - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: undefined, + searchParams: new URLSearchParams(), + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); const __revalOnError = createRscOnErrorHandler(request, cleanPathname, route.pattern); const __revalRscStream = renderToReadableStream(__revalElement, { onError: __revalOnError }); @@ -13731,12 +13848,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { interceptRoute, interceptParams, cleanPathname, - interceptOpts, - interceptSearchParams, - isRscRequest, - request, - __mountedSlotsHeader, - __skipLayoutIds, + { + opts: interceptOpts, + searchParams: interceptSearchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }, ); }, cleanPathname, @@ -13790,7 +13909,14 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const __pageBuildResult = await __buildAppPageElement({ buildPageElement() { - return buildPageElements(route, params, cleanPathname, interceptOpts, url.searchParams, isRscRequest, request, __mountedSlotsHeader, __skipLayoutIds); + return buildPageElements(route, params, cleanPathname, { + opts: interceptOpts, + searchParams: url.searchParams, + isRscRequest, + request, + mountedSlotsHeader: __mountedSlotsHeader, + skipLayoutIds: __skipLayoutIds, + }); }, renderErrorBoundaryPage(buildErr) { return renderErrorBoundaryPage(route, buildErr, isRscRequest, request, params, _scriptNonce); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index fe388a8e4..a9186b868 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -8,6 +8,7 @@ import { APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, buildOutgoingAppPayload, + createRscNavigationRequestHeaders, buildSkipHeaderValue, computeSkipDecision, createAppPayloadCacheKey, @@ -18,6 +19,8 @@ import { readAppElementsMetadata, resolveVisitedResponseInterceptionContext, withLayoutFlags, + X_VINEXT_MOUNTED_SLOTS_HEADER, + X_VINEXT_ROUTER_SKIP_HEADER, } from "../packages/vinext/src/server/app-elements.js"; describe("app elements payload helpers", () => { @@ -389,3 +392,26 @@ describe("buildSkipHeaderValue", () => { expect(value).toBe("layout:/,layout:/blog"); }); }); + +describe("createRscNavigationRequestHeaders", () => { + it("includes interception, mounted-slots, and skip headers when available", () => { + const headers = createRscNavigationRequestHeaders("/feed", "slot:modal:/ slot:team:/", { + "layout:/": "s", + "layout:/feed": "d", + }); + + expect(headers.get("Accept")).toBe("text/x-component"); + expect(headers.get("X-Vinext-Interception-Context")).toBe("/feed"); + expect(headers.get(X_VINEXT_MOUNTED_SLOTS_HEADER)).toBe("slot:modal:/ slot:team:/"); + expect(headers.get(X_VINEXT_ROUTER_SKIP_HEADER)).toBe("layout:/"); + }); + + it("omits optional headers when their inputs are absent", () => { + const headers = createRscNavigationRequestHeaders(null, null, { "layout:/": "d" }); + + expect(headers.get("Accept")).toBe("text/x-component"); + expect(headers.has("X-Vinext-Interception-Context")).toBe(false); + expect(headers.has(X_VINEXT_MOUNTED_SLOTS_HEADER)).toBe(false); + expect(headers.has(X_VINEXT_ROUTER_SKIP_HEADER)).toBe(false); + }); +}); diff --git a/tests/build-time-classification-integration.test.ts b/tests/build-time-classification-integration.test.ts index 0dbf652b3..9d089178e 100644 --- a/tests/build-time-classification-integration.test.ts +++ b/tests/build-time-classification-integration.test.ts @@ -211,6 +211,15 @@ describe("build-time classification integration", () => { ); }); + it("leaves __VINEXT_CLASS_REASONS as a null stub when build-time debug is off", () => { + expect(built.chunkSource).toMatch( + /function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{\s*return null;?\s*\}/, + ); + expect(built.chunkSource).not.toMatch( + /function\s+__VINEXT_CLASS_REASONS\s*\(routeIdx\)\s*\{[^}]*switch/, + ); + }); + it("classifies the force-dynamic layout at build time", () => { const routeIdx = built.routeIndexByPattern.get("/force-dyn"); expect(routeIdx).toBeDefined();