From 2d513db235324b37e84f740c0c5ef60de1edb786 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:10:05 +1000 Subject: [PATCH 1/6] feat(app-router): emit per-layout flags in the RSC payload Wire runtime layout classification through renderAppPageLifecycle and attach the resulting flags as __layoutFlags in the outgoing RSC payload via buildOutgoingAppPayload. Payload-shape helpers (withLayoutFlags, isAppElementsRecord, buildOutgoingAppPayload, AppOutgoingElements) live alongside the existing readAppElementsMetadata so the write and read boundaries sit next to each other. --- packages/vinext/src/entries/app-rsc-entry.ts | 16 ++ packages/vinext/src/server/app-elements.ts | 72 +++++- packages/vinext/src/server/app-page-render.ts | 20 +- .../entry-templates.test.ts.snap | 96 ++++++++ tests/app-elements.test.ts | 141 +++++++++++ tests/app-page-render.test.ts | 220 ++++++++++++++++++ 6 files changed, 560 insertions(+), 5 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index e40deeaec..1dbdbda0e 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -2449,6 +2449,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index b8bc42616..112822e48 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,23 @@ 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 (partial — later PRs extend this): + * + * 1. PROBE — probeAppPageLayouts (server/app-page-execution.ts) returns + * LayoutFlags for every layout in the route at render time. + * + * 2. ATTACH — withLayoutFlags (this file) writes `__layoutFlags` into the + * outgoing App Router payload record. + * + * 3. WIRE — renderToReadableStream serializes the record as RSC row 0. + * + * 4. PARSE — readAppElementsMetadata (this file) extracts layoutFlags from + * the wire payload on the client side. + */ export type LayoutFlags = Readonly>; export type AppElementsMetadata = { @@ -120,10 +136,62 @@ 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 }; +} + +/** + * 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`. + */ +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-render.ts b/packages/vinext/src/server/app-page-render.ts index 672bb4694..1b28abddf 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1,5 +1,6 @@ import type { ReactNode } from "react"; import type { CachedAppPageValue } from "../shims/cache.js"; +import { buildOutgoingAppPayload, type AppOutgoingElements } from "./app-elements.js"; import { finalizeAppPageHtmlCacheResponse, scheduleAppPageRscCacheWrite, @@ -10,6 +11,7 @@ import { teeAppPageRscStreamForCapture, type AppPageFontPreload, type AppPageSpecialError, + type LayoutClassificationOptions, } from "./app-page-execution.js"; import { probeAppPageBeforeRender } from "./app-page-probe.js"; import { @@ -84,7 +86,7 @@ export type RenderAppPageLifecycleOptions = { ) => Promise; renderPageSpecialError: (specialError: AppPageSpecialError) => Promise; renderToReadableStream: ( - element: ReactNode | Record, + element: ReactNode | AppOutgoingElements, options: { onError: AppPageBoundaryOnError }, ) => ReadableStream; routeHasLocalBoundary: boolean; @@ -93,7 +95,8 @@ export type RenderAppPageLifecycleOptions = { scriptNonce?: string; mountedSlotsHeader?: string | null; waitUntil?: (promise: Promise) => void; - element: ReactNode | Record; + element: ReactNode | Readonly>; + classification?: LayoutClassificationOptions | null; }; function buildResponseTiming( @@ -137,15 +140,26 @@ export async function renderAppPageLifecycle( runWithSuppressedHookWarning(probe) { return options.runWithSuppressedHookWarning(probe); }, + classification: options.classification, }); if (preRenderResult.response) { return preRenderResult.response; } + const layoutFlags = preRenderResult.layoutFlags; + + // Render the CANONICAL element. The outgoing payload carries per-layout + // static/dynamic flags under `__layoutFlags` so the client can later tell + // which layouts are safe to skip on subsequent navigations. + 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, }); diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 924bfd492..dd1c47a60 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -2126,6 +2126,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { @@ -4343,6 +4359,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { @@ -6555,6 +6587,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { @@ -8799,6 +8847,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { @@ -11017,6 +11081,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { @@ -13595,6 +13675,22 @@ 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); + }, + async runWithIsolatedDynamicScope(fn) { + const priorDynamic = consumeDynamicUsage(); + try { + const result = await fn(); + const dynamicDetected = consumeDynamicUsage(); + return { result, dynamicDetected }; + } finally { + if (priorDynamic) markDynamicUsage(); + } + }, + }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, renderErrorBoundaryResponse(renderErr) { diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 841bfc039..39a4f0216 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -7,11 +7,14 @@ import { APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + buildOutgoingAppPayload, createAppPayloadCacheKey, createAppPayloadRouteId, + isAppElementsRecord, normalizeAppElements, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, + withLayoutFlags, } from "../packages/vinext/src/server/app-elements.js"; describe("app elements payload helpers", () => { @@ -149,3 +152,141 @@ describe("app elements payload helpers", () => { expect(metadata.layoutFlags).toEqual({}); }); }); + +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("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"); + } + }); +}); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 65fcab377..a59a0d82c 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) { @@ -316,3 +330,209 @@ 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 () => { + 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" }); + + for (const [_id, flag] of Object.entries(wireFlags as Record)) { + expect(flag === "s" || flag === "d").toBe(true); + } + }); +}); From 1825a63de7b93dfe4e98d702cb7c7d494cc101b7 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Wed, 15 Apr 2026 13:38:36 +1000 Subject: [PATCH 2/6] fix(app-elements): widen isAppElementsRecord narrowing and drop redundant clone - isAppElementsRecord narrowed to Record, but the outgoing payload legitimately carries heterogeneous values (ReactNode for the rendered tree plus LayoutFlags under __layoutFlags). Widen the predicate to Readonly> so the return type matches what the runtime check actually proves. - buildOutgoingAppPayload spread input.element into a new object before passing it to withLayoutFlags, which immediately spreads again. Drop the outer copy; withLayoutFlags already guarantees immutability. --- packages/vinext/src/server/app-elements.ts | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 112822e48..4c5b0afbb 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -137,12 +137,15 @@ function parseLayoutFlags(value: unknown): LayoutFlags { } /** - * 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. + * Type predicate for a plain (non-null, non-array) record of app payload values. + * Used to distinguish the App Router payload object from bare React elements at + * the render boundary. Narrows to `Readonly>` because + * the outgoing payload carries heterogeneous values (ReactNodes for the rendered + * tree, plus metadata like `__layoutFlags` which is a plain object). 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 { +export function isAppElementsRecord(value: unknown): value is Readonly> { if (typeof value !== "object" || value === null) return false; if (Array.isArray(value)) return false; if (isValidElement(value)) return false; @@ -183,7 +186,7 @@ export function buildOutgoingAppPayload(input: { if (!isAppElementsRecord(input.element)) { return input.element; } - return withLayoutFlags({ ...input.element }, input.layoutFlags); + return withLayoutFlags(input.element, input.layoutFlags); } /** From 385440fe1aaa186ee929bea65ba650bcced8dbac Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:15:53 +1000 Subject: [PATCH 3/6] feat(app-router): filter skipped layouts from RSC responses and cached reads Introduce app-page-skip-filter.ts with the canonical-bytes guarantee: the render path always produces the full RSC payload and writes it to the cache; the egress branch applies a byte-level filter that omits layouts the client asked to skip, but only if the server independently classified them as static (computeSkipDecision). Wire the filter into renderAppPageLifecycle and buildAppPageCachedResponse so both fresh renders and cache hits honor the skip header. Parse the incoming X-Vinext-Router-Skip header at the handler scope and thread the resulting set through render and ISR. Gate the filter behind supportsFilteredRscStream: false in the generated entry so this PR is dormant at runtime until the canonical-stream story is validated. Tests exercise the filter directly by injecting the skip set into renderAppPageLifecycle options. --- packages/vinext/src/entries/app-rsc-entry.ts | 8 + packages/vinext/src/server/app-elements.ts | 41 ++ packages/vinext/src/server/app-page-cache.ts | 11 +- packages/vinext/src/server/app-page-render.ts | 27 +- .../vinext/src/server/app-page-skip-filter.ts | 333 ++++++++++++ .../entry-templates.test.ts.snap | 48 ++ tests/app-elements.test.ts | 69 +++ tests/app-page-cache.test.ts | 156 ++++++ tests/app-page-render.test.ts | 360 +++++++++++++ tests/app-page-skip-filter.test.ts | 486 ++++++++++++++++++ 10 files changed, 1533 insertions(+), 6 deletions(-) create mode 100644 packages/vinext/src/server/app-page-skip-filter.ts create mode 100644 tests/app-page-skip-filter.test.ts diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 1dbdbda0e..b37cdb33c 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -394,6 +394,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, @@ -1420,6 +1422,9 @@ 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 @@ -2206,6 +2211,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 @@ -2420,6 +2426,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -2467,6 +2474,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 4c5b0afbb..e998ea3f8 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -101,6 +101,47 @@ export function resolveVisitedResponseInterceptionContext( return payloadInterceptionContext ?? requestInterceptionContext; } +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(); + const ids = new Set(); + for (const part of header.split(",")) { + const trimmed = part.trim(); + if (trimmed.startsWith("layout:")) { + ids.add(trimmed); + } + } + return ids; +} + +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; +} + export function normalizeAppElements(elements: AppWireElements): AppElements { let needsNormalization = false; for (const [key, value] of Object.entries(elements)) { 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-render.ts b/packages/vinext/src/server/app-page-render.ts index 1b28abddf..6b2619ab2 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -1,10 +1,15 @@ import type { ReactNode } from "react"; import type { CachedAppPageValue } from "../shims/cache.js"; -import { buildOutgoingAppPayload, type AppOutgoingElements } from "./app-elements.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, @@ -32,6 +37,8 @@ import { type AppPageSsrHandler, } from "./app-page-stream.js"; +const EMPTY_SKIP_SET: ReadonlySet = new Set(); + type AppPageBoundaryOnError = ( error: unknown, requestInfo: unknown, @@ -68,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; @@ -97,6 +105,7 @@ export type RenderAppPageLifecycleOptions = { waitUntil?: (promise: Promise) => void; element: ReactNode | Readonly>; classification?: LayoutClassificationOptions | null; + requestedSkipLayoutIds?: ReadonlySet; }; function buildResponseTiming( @@ -148,9 +157,9 @@ export async function renderAppPageLifecycle( const layoutFlags = preRenderResult.layoutFlags; - // Render the CANONICAL element. The outgoing payload carries per-layout - // static/dynamic flags under `__layoutFlags` so the client can later tell - // which layouts are safe to skip on subsequent navigations. + // 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, @@ -172,9 +181,17 @@ export async function renderAppPageLifecycle( revalidateSeconds !== Infinity && !options.isForceDynamic, ); - const rscForResponse = rscCapture.responseStream; const isrRscDataPromise = rscCapture.capturedRscDataPromise; + const skipIds = + options.isRscRequest && (options.supportsFilteredRscStream ?? true) + ? 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..31fd59b85 --- /dev/null +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -0,0 +1,333 @@ +/** + * 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. + */ + +// 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]+)$/; + +/** + * 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); + + // 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(/(?, + 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); + // 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) { + 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/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index dd1c47a60..2aace66d4 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, @@ -1281,6 +1283,9 @@ 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 @@ -1883,6 +1888,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 @@ -2097,6 +2103,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -2144,6 +2151,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -2308,6 +2316,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, @@ -3514,6 +3524,9 @@ 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 @@ -4116,6 +4129,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 @@ -4330,6 +4344,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -4377,6 +4392,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -4541,6 +4557,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, @@ -5742,6 +5760,9 @@ 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 @@ -6344,6 +6365,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 @@ -6558,6 +6580,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -6605,6 +6628,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -6769,6 +6793,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, @@ -8002,6 +8028,9 @@ 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 @@ -8604,6 +8633,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 @@ -8818,6 +8848,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -8865,6 +8896,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -9029,6 +9061,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, @@ -10236,6 +10270,9 @@ 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 @@ -10838,6 +10875,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 @@ -11052,6 +11090,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -11099,6 +11138,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -11263,6 +11303,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, @@ -12692,6 +12734,9 @@ 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 @@ -13432,6 +13477,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 @@ -13646,6 +13692,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -13693,6 +13740,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, 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 39a4f0216..5e2f2a84c 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -8,10 +8,12 @@ import { APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, buildOutgoingAppPayload, + computeSkipDecision, createAppPayloadCacheKey, createAppPayloadRouteId, isAppElementsRecord, normalizeAppElements, + parseSkipHeader, readAppElementsMetadata, resolveVisitedResponseInterceptionContext, withLayoutFlags, @@ -153,6 +155,73 @@ describe("app elements payload helpers", () => { }); }); +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("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("isAppElementsRecord", () => { it("returns true for a plain record", () => { expect(isAppElementsRecord({ "page:/": "x" })).toBe(true); 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-render.test.ts b/tests/app-page-render.test.ts index a59a0d82c..7bd8b7db7 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -16,6 +16,25 @@ function captureRecord(value: ReactNode | AppOutgoingElements): Record { return new ReadableStream({ start(controller) { @@ -536,3 +555,344 @@ describe("layoutFlags injection into RSC payload", () => { } }); }); +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; + supportsFilteredRscStream?: 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, + supportsFilteredRscStream: overrides.supportsFilteredRscStream ?? 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("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: { + "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..28e301cd0 --- /dev/null +++ b/tests/app-page-skip-filter.test.ts @@ -0,0 +1,486 @@ +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("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 + // 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"`); + }); +}); From 939d4b7368385b2d5a4e20ee5262c66947940a77 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:18:40 +1000 Subject: [PATCH 4/6] feat(app-router): send X-Vinext-Router-Skip on navigation requests Introduce buildSkipHeaderValue and createRscNavigationRequestHeaders in app-elements. The client-side browser entry now uses the centralized request-header builder for navigation fetches and forwards the current layout flags so subsequent navigations can signal which static layouts the server may omit from the response body. --- .../vinext/src/server/app-browser-entry.ts | 18 ++---- packages/vinext/src/server/app-elements.ts | 33 +++++++++++ tests/app-elements.test.ts | 56 +++++++++++++++++++ 3 files changed, 95 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 4f58f3686..e46aefd5a 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -53,6 +53,7 @@ import { getVinextBrowserGlobal, } from "./app-browser-stream.js"; import { + createRscNavigationRequestHeaders, createAppPayloadCacheKey, getMountedSlotIdsHeader, normalizeAppElements, @@ -367,14 +368,6 @@ function getRequestState( } } -function createRscRequestHeaders(interceptionContext: string | null): Headers { - const headers = new Headers({ Accept: "text/x-component" }); - if (interceptionContext !== null) { - headers.set("X-Vinext-Interception-Context", interceptionContext); - } - return headers; -} - /** * Resolve all pending navigation commits with renderId <= the committed renderId. * Note: Map iteration handles concurrent deletion safely — entries are visited in @@ -974,10 +967,11 @@ async function main(): Promise { } if (!navResponse) { - const requestHeaders = createRscRequestHeaders(requestInterceptionContext); - if (mountedSlotsHeader) { - requestHeaders.set("X-Vinext-Mounted-Slots", mountedSlotsHeader); - } + const requestHeaders = createRscNavigationRequestHeaders( + requestInterceptionContext, + mountedSlotsHeader, + 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 e998ea3f8..179045c01 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -104,6 +104,39 @@ export function resolveVisitedResponseInterceptionContext( export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip"; export const X_VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots"; +/** 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; +} + +/** + * 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; +} + export function parseSkipHeader(header: string | null): ReadonlySet { if (!header) return new Set(); const ids = new Set(); diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 5e2f2a84c..981dd603b 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -8,15 +8,19 @@ import { APP_ROUTE_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, buildOutgoingAppPayload, + buildSkipHeaderValue, computeSkipDecision, createAppPayloadCacheKey, createAppPayloadRouteId, + createRscNavigationRequestHeaders, isAppElementsRecord, normalizeAppElements, parseSkipHeader, 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", () => { @@ -359,3 +363,55 @@ describe("buildOutgoingAppPayload", () => { } }); }); + +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"); + }); +}); + +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); + }); +}); From 8a427d88035223df50af118aea529bb038886eef Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:26:24 +1000 Subject: [PATCH 5/6] feat(build): wire build-time layout classification into the generated RSC entry Introduce a Rollup generateBundle hook that patches the __VINEXT_CLASS stub in the generated RSC entry with a real dispatch table built from Layer 1 segment-config analysis and Layer 2 module-graph classification. The runtime probe loop in app-page-execution.ts consults this table and skips the dynamic-isolation probe for layouts we proved static or dynamic at build time. Add route-classification-manifest.ts as the codegen glue between the classifier and the entry template, and flow buildTimeClassifications through renderAppPageLifecycle so the runtime probe can honor the build-time decision. Fail loudly if generateBundle sees __VINEXT_CLASS referenced without the recognized stub body, so generator and plugin cannot silently drift. --- .../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 | 234 +++++++++++ packages/vinext/src/entries/app-rsc-entry.ts | 14 +- packages/vinext/src/index.ts | 155 ++++++++ .../entry-templates.test.ts.snap | 108 ++++++ tests/app-router.test.ts | 21 + tests/build-report.test.ts | 45 ++- ...ld-time-classification-integration.test.ts | 246 ++++++++++++ tests/layout-classification.test.ts | 92 +++-- tests/route-classification-manifest.test.ts | 366 ++++++++++++++++++ 12 files changed, 1365 insertions(+), 70 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 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..1bfd08da7 --- /dev/null +++ b/packages/vinext/src/build/route-classification-manifest.ts @@ -0,0 +1,234 @@ +/** + * 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, and the plugin only patches this dispatcher into the built bundle when + * that env var is present at build time. + * + * Layer 1 priority applies the same way as in `buildGenerateBundleReplacement`: + * a segment-config reason must override a module-graph reason for the same + * layout index. + */ +export function buildReasonsReplacement( + manifest: RouteClassificationManifest, + layer2PerRoute: ReadonlyMap>, +): string { + const cases: string[] = []; + + for (let routeIdx = 0; routeIdx < manifest.routes.length; routeIdx++) { + const route = manifest.routes[routeIdx]!; + const merged = mergeLayersForRoute(route, layer2PerRoute.get(routeIdx)); + + if (merged.size === 0) continue; + + const entries = [...merged.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([idx, value]) => `[${idx}, ${serializeReasonExpression(value.reason)}]`) + .join(", "); + cases.push(` case ${routeIdx}: return new Map([${entries}]);`); + } + + return [ + "(routeIdx) => {", + " switch (routeIdx) {", + ...cases, + " default: return null;", + " }", + " }", + ].join("\n"); +} diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index b37cdb33c..a9986c987 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,8 @@ ${interceptEntries.join(",\n")} ep ? getImportVar(ep) : "null", ); return ` { + routeIdx: ${routeIdx}, + __buildTimeClassifications: __VINEXT_CLASS(${routeIdx}), pattern: ${JSON.stringify(route.pattern)}, patternParts: ${JSON.stringify(route.patternParts)}, isDynamic: ${route.isDynamic}, @@ -745,6 +747,15 @@ async function __ensureInstrumentation() { : "" } +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ ${routeEntries.join(",\n")} ]; @@ -2461,6 +2472,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 88c7394db..4da0a6bc0 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -17,6 +17,12 @@ import { createDirectRunner } from "./server/dev-module-runner.js"; import { generateRscEntry } from "./entries/app-rsc-entry.js"; import { generateSsrEntry } from "./entries/app-ssr-entry.js"; import { generateBrowserEntry } from "./entries/app-browser-entry.js"; +import { + buildGenerateBundleReplacement, + collectRouteClassificationManifest, + type RouteClassificationManifest, +} from "./build/route-classification-manifest.js"; +import { classifyLayoutByModuleGraph } from "./build/layout-classification.js"; import { normalizePathnameForRouteMatchStrict } from "./routing/utils.js"; import { findNextConfigPath, @@ -495,6 +501,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { let warnedInlineNextConfigOverride = false; let hasNitroPlugin = false; + // Build-time layout classification manifest, captured in the RSC virtual + // module's load hook and consumed in generateBundle to patch the generated + // `__VINEXT_CLASS` stub with a real dispatch table. + let rscClassificationManifest: RouteClassificationManifest | null = null; + // Resolve shim paths - works both from source (.ts) and built (.js) const shimsDir = path.resolve(__dirname, "shims"); @@ -1593,6 +1604,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 +1640,146 @@ 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*\}/; + + // Skip the scan-phase build where the RSC entry code has been + // tree-shaken out entirely. In the real RSC build the chunk that + // carries our runtime code will reference `__VINEXT_CLASS` via the + // per-route literal `__buildTimeClassifications: __VINEXT_CLASS(N)`, + // which Rolldown emits verbatim. + // + // If we see a chunk that mentions __VINEXT_CLASS but none of them + // contain the stub body we recognise, something upstream reshaped the + // generated source and we would silently degrade back to the Layer 3 + // runtime probe. Fail loudly instead so regressions surface at build + // time rather than as a mysterious perf cliff at request time. + const chunksMentioningStub: Array<{ + chunk: Extract<(typeof bundle)[string], { type: "chunk" }>; + fileName: string; + }> = []; + const chunksWithStubBody: Array<{ + chunk: Extract<(typeof bundle)[string], { type: "chunk" }>; + fileName: string; + }> = []; + for (const chunk of Object.values(bundle)) { + if (chunk.type !== "chunk") continue; + if (!chunk.code.includes("__VINEXT_CLASS")) continue; + chunksMentioningStub.push({ chunk, fileName: chunk.fileName }); + if (stubRe.test(chunk.code)) { + chunksWithStubBody.push({ chunk, fileName: chunk.fileName }); + } + } + + if (chunksMentioningStub.length === 0) return; + if (chunksWithStubBody.length === 0) { + throw new Error( + `vinext: build-time classification — __VINEXT_CLASS is referenced in ${chunksMentioningStub + .map((c) => c.fileName) + .join( + ", ", + )} but no chunk contains the stub body. The generator and generateBundle have drifted.`, + ); + } + if (chunksWithStubBody.length > 1) { + throw new Error( + `vinext: build-time classification — expected __VINEXT_CLASS stub in exactly one RSC chunk, found ${chunksWithStubBody.length}`, + ); + } + + // Rolldown stores module IDs as canonicalized filesystem paths + // (fs.realpathSync.native). On macOS, /var/folders/... becomes + // /private/var/folders/..., so raw paths collected at routing-scan + // time won't match module-graph keys. Canonicalize everything we + // hand to the classifier and everything we ask the graph for. + const canonicalize = (p: string): string => { + 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 patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`; + const target = chunksWithStubBody[0]!.chunk; + target.code = target.code.replace(stubRe, patchedBody); + }, }, // Stub node:async_hooks in client builds — see src/plugins/async-hooks-stub.ts asyncHooksStubPlugin, diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 2aace66d4..cb730f48a 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -408,8 +408,19 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -433,6 +444,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -456,6 +469,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -479,6 +494,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -2138,6 +2155,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -2643,8 +2661,19 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -2668,6 +2697,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -2691,6 +2722,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -2714,6 +2747,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -4379,6 +4414,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -4885,8 +4921,19 @@ import * as mod_11 from "/tmp/test/app/global-error.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -4910,6 +4957,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -4933,6 +4982,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -4956,6 +5007,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -6615,6 +6668,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -7150,8 +7204,19 @@ async function __ensureInstrumentation() { return __instrumentationInitPromise; } +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -7175,6 +7240,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -7198,6 +7265,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -7221,6 +7290,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -8883,6 +8954,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -9389,8 +9461,19 @@ import * as mod_11 from "/tmp/test/app/sitemap.ts"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -9414,6 +9497,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -9437,6 +9522,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -9460,6 +9547,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -11125,6 +11214,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -11630,8 +11720,19 @@ import * as mod_10 from "/tmp/test/app/dashboard/not-found.tsx"; +// Build-time layout classification dispatch. Replaced in generateBundle +// with a switch statement that returns a pre-computed per-layout +// Map for each route. Until the +// plugin patches this stub, every route falls back to the Layer 3 +// runtime probe, which is the current (slow) behaviour. +function __VINEXT_CLASS(routeIdx) { + return null; +} + const routes = [ { + routeIdx: 0, + __buildTimeClassifications: __VINEXT_CLASS(0), pattern: "/", patternParts: [], isDynamic: false, @@ -11655,6 +11756,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 1, + __buildTimeClassifications: __VINEXT_CLASS(1), pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -11678,6 +11781,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 2, + __buildTimeClassifications: __VINEXT_CLASS(2), pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -11701,6 +11806,8 @@ const routes = [ unauthorized: null, }, { + routeIdx: 3, + __buildTimeClassifications: __VINEXT_CLASS(3), pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -13727,6 +13834,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { const tp = route.layoutTreePositions?.[index] ?? 0; return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, + buildTimeClassifications: route.__buildTimeClassifications, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 08da34dd0..4266f004c 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3645,6 +3645,27 @@ describe("App Router next.config.js features (generateRscEntry)", () => { }); }); }); + + describe("build-time classification dispatch stub", () => { + it("declares a __VINEXT_CLASS dispatch function", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + expect(code).toContain("function __VINEXT_CLASS(routeIdx)"); + }); + + it("threads a numeric route index into each route's classification wiring", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + // minimalRoutes has three routes, so the generator should emit calls + // __VINEXT_CLASS(0), __VINEXT_CLASS(1), __VINEXT_CLASS(2). + for (let i = 0; i < minimalRoutes.length; i++) { + expect(code).toContain(`__VINEXT_CLASS(${i})`); + } + }); + + it("no longer hardcodes buildTimeClassifications to null", () => { + const code = generateRscEntry("/tmp/test/app", minimalRoutes, null, [], null, "", false); + expect(code).not.toContain("buildTimeClassifications: null"); + }); + }); }); describe("App Router middleware with NextRequest", () => { diff --git a/tests/build-report.test.ts b/tests/build-report.test.ts index f969b6172..19311cc33 100644 --- a/tests/build-report.test.ts +++ b/tests/build-report.test.ts @@ -744,35 +744,52 @@ describe("printBuildReport respects pageExtensions", () => { // ─── classifyLayoutSegmentConfig ───────────────────────────────────────────── describe("classifyLayoutSegmentConfig", () => { - it('returns "static" for export const dynamic = "force-static"', () => { - expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toBe("static"); + it("returns kind=static with segment-config reason for force-static", () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-static";')).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: "force-static" }, + }); }); - it('returns "static" for export const dynamic = "error" (enforces static)', () => { - expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toBe("static"); + it('returns kind=static with segment-config reason for dynamic = "error"', () => { + expect(classifyLayoutSegmentConfig("export const dynamic = 'error';")).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: "error" }, + }); }); - it('returns "dynamic" for export const dynamic = "force-dynamic"', () => { - expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toBe("dynamic"); + it("returns kind=dynamic with segment-config reason for force-dynamic", () => { + expect(classifyLayoutSegmentConfig('export const dynamic = "force-dynamic";')).toEqual({ + kind: "dynamic", + reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" }, + }); }); - it('returns "dynamic" for export const revalidate = 0', () => { - expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toBe("dynamic"); + it("returns kind=dynamic with revalidate reason for revalidate = 0", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 0;")).toEqual({ + kind: "dynamic", + reason: { layer: "segment-config", key: "revalidate", value: 0 }, + }); }); - it('returns "static" for export const revalidate = Infinity', () => { - expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toBe("static"); + it("returns kind=static with revalidate reason for revalidate = Infinity", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = Infinity;")).toEqual({ + kind: "static", + reason: { layer: "segment-config", key: "revalidate", value: Infinity }, + }); }); - it("returns null for no config (defers to module graph)", () => { + it("returns kind=absent when no config is present (defers to module graph)", () => { expect( classifyLayoutSegmentConfig( "export default function Layout({ children }) { return children; }", ), - ).toBeNull(); + ).toEqual({ kind: "absent" }); }); - it("returns null for positive revalidate (ISR is a page concept)", () => { - expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toBeNull(); + it("returns kind=absent for positive revalidate (ISR is a page concept)", () => { + expect(classifyLayoutSegmentConfig("export const revalidate = 60;")).toEqual({ + kind: "absent", + }); }); }); diff --git a/tests/build-time-classification-integration.test.ts b/tests/build-time-classification-integration.test.ts new file mode 100644 index 000000000..e99c47f47 --- /dev/null +++ b/tests/build-time-classification-integration.test.ts @@ -0,0 +1,246 @@ +/** + * 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*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("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 54296aa4ac046ac49bd20343104002a87486659d Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Tue, 14 Apr 2026 20:29:26 +1000 Subject: [PATCH 6/6] chore(debug): add classification reasons sidecar behind VINEXT_DEBUG_CLASSIFICATION Operators tracing why a layout was flagged static or dynamic can opt in with VINEXT_DEBUG_CLASSIFICATION=1 at build time. When active, the plugin patches a second stub (__VINEXT_CLASS_REASONS) with a dispatch table that returns per-layout ClassificationReason structures. The runtime probe loop emits a debug line per layout when debugClassification is wired, and the hot path remains allocation-free when debug is off because the stub returns null. The reasons machinery (types, report.ts tagged results, and buildReasonsReplacement) already shipped in the previous PR; this PR just enables the emission path and wires the debug consumer into the runtime probe. --- packages/vinext/src/entries/app-rsc-entry.ts | 22 +++ packages/vinext/src/index.ts | 18 +++ .../vinext/src/server/app-page-execution.ts | 34 ++++ .../entry-templates.test.ts.snap | 150 ++++++++++++++++++ tests/app-page-execution.test.ts | 134 ++++++++++++++++ ...ld-time-classification-integration.test.ts | 18 ++- 6 files changed, 375 insertions(+), 1 deletion(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index a9986c987..144752454 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -216,6 +216,7 @@ ${interceptEntries.join(",\n")} 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}, @@ -575,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). @@ -756,6 +767,15 @@ 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")} ]; @@ -2473,6 +2493,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/packages/vinext/src/index.ts b/packages/vinext/src/index.ts index 4da0a6bc0..de42be8f2 100644 --- a/packages/vinext/src/index.ts +++ b/packages/vinext/src/index.ts @@ -19,6 +19,7 @@ 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"; @@ -1660,7 +1661,10 @@ 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*\}/; // Skip the scan-phase build where the RSC entry code has been // tree-shaken out entirely. In the real RSC build the chunk that @@ -1705,6 +1709,11 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { `vinext: build-time classification — expected __VINEXT_CLASS stub in exactly one RSC chunk, found ${chunksWithStubBody.length}`, ); } + 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.", + ); + } // Rolldown stores module IDs as canonicalized filesystem paths // (fs.realpathSync.native). On macOS, /var/folders/... becomes @@ -1779,6 +1788,15 @@ export default function vinext(options: VinextOptions = {}): PluginOption[] { const patchedBody = `function __VINEXT_CLASS(routeIdx) { return (${replacement})(routeIdx); }`; const target = chunksWithStubBody[0]!.chunk; 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-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/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index cb730f48a..78a5f73c1 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). @@ -417,10 +427,20 @@ 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, @@ -446,6 +466,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -471,6 +492,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -496,6 +518,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -2156,6 +2179,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -2513,6 +2538,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). @@ -2670,10 +2705,20 @@ 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, @@ -2699,6 +2744,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -2724,6 +2770,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -2749,6 +2796,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -4415,6 +4463,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -4772,6 +4822,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). @@ -4930,10 +4990,20 @@ 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, @@ -4959,6 +5029,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -4984,6 +5055,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -5009,6 +5081,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -6669,6 +6742,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -7026,6 +7101,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). @@ -7213,10 +7298,20 @@ 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, @@ -7242,6 +7337,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -7267,6 +7363,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -7292,6 +7389,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -8955,6 +9053,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -9312,6 +9412,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). @@ -9470,10 +9580,20 @@ 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, @@ -9499,6 +9619,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -9524,6 +9645,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -9549,6 +9671,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -11215,6 +11338,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { @@ -11572,6 +11697,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). @@ -11729,10 +11864,20 @@ 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, @@ -11758,6 +11903,7 @@ const routes = [ { routeIdx: 1, __buildTimeClassifications: __VINEXT_CLASS(1), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(1) : null, pattern: "/about", patternParts: ["about"], isDynamic: false, @@ -11783,6 +11929,7 @@ const routes = [ { routeIdx: 2, __buildTimeClassifications: __VINEXT_CLASS(2), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(2) : null, pattern: "/blog/:slug", patternParts: ["blog",":slug"], isDynamic: true, @@ -11808,6 +11955,7 @@ const routes = [ { routeIdx: 3, __buildTimeClassifications: __VINEXT_CLASS(3), + __buildTimeReasons: __classDebug ? __VINEXT_CLASS_REASONS(3) : null, pattern: "/dashboard", patternParts: ["dashboard"], isDynamic: false, @@ -13835,6 +13983,8 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { return "layout:" + __createAppPageTreePath(route.routeSegments, tp); }, buildTimeClassifications: route.__buildTimeClassifications, + buildTimeReasons: route.__buildTimeReasons, + debugClassification: __classDebug, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { 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/build-time-classification-integration.test.ts b/tests/build-time-classification-integration.test.ts index e99c47f47..9d089178e 100644 --- a/tests/build-time-classification-integration.test.ts +++ b/tests/build-time-classification-integration.test.ts @@ -74,7 +74,8 @@ function extractDispatch(chunkSource: string): Dispatch { */ function extractRouteIndexByPattern(chunkSource: string): Map { const result = new Map(); - const re = /routeIdx:\s*(\d+),\s*__buildTimeClassifications:[^,]+,\s*pattern:\s*"([^"]+)"/g; + 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]!)); @@ -204,6 +205,21 @@ describe("build-time classification integration", () => { 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("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();