From 4c2f81fecb5ee5a152f367eca35d7ff6a0699dc2 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 1/3] 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 7b0f16a7d..d74ff6967 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, @@ -2468,6 +2475,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 83c752705..57d7a2709 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, @@ -2145,6 +2152,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -2309,6 +2317,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, @@ -3515,6 +3525,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 @@ -4117,6 +4130,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 @@ -4331,6 +4345,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -4379,6 +4394,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -4543,6 +4559,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, @@ -5744,6 +5762,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 @@ -6346,6 +6367,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 @@ -6560,6 +6582,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -6608,6 +6631,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -6772,6 +6796,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, @@ -8005,6 +8031,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 @@ -8607,6 +8636,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 @@ -8821,6 +8851,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -8869,6 +8900,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -9033,6 +9065,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, @@ -10240,6 +10274,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 @@ -10842,6 +10879,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 @@ -11056,6 +11094,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -11104,6 +11143,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -11268,6 +11308,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, @@ -12697,6 +12739,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 @@ -13437,6 +13482,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 @@ -13651,6 +13697,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { isForceStatic, isProduction: process.env.NODE_ENV === "production", isRscRequest, + supportsFilteredRscStream: false, isrDebug: __isrDebug, isrHtmlKey: __isrHtmlKey, isrRscKey: __isrRscKey, @@ -13699,6 +13746,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 1c3ef6b2f..9f1826653 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) { @@ -533,3 +552,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 bc703d564e81a1451af891fc844b377c6efbe7f9 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:41:47 +1000 Subject: [PATCH 2/3] fix(app-router): address skip-filter review feedback --- packages/vinext/src/entries/app-rsc-entry.ts | 3 ++- packages/vinext/src/server/app-elements.ts | 9 +++++++-- packages/vinext/src/server/app-page-render.ts | 3 +++ .../vinext/src/server/app-page-skip-filter.ts | 8 +++++--- .../__snapshots__/entry-templates.test.ts.snap | 18 ++++++++++++------ tests/app-elements.test.ts | 10 ++++++++++ tests/app-page-skip-filter.test.ts | 16 ++++++++++++++++ 7 files changed, 55 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index d74ff6967..f056288d2 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -1422,9 +1422,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index e998ea3f8..f97a4ada8 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -103,17 +103,22 @@ 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"; +const MAX_SKIP_LAYOUT_IDS = 50; +const EMPTY_SKIP_HEADER_IDS: ReadonlySet = new Set(); export function parseSkipHeader(header: string | null): ReadonlySet { - if (!header) return new Set(); + if (!header) return EMPTY_SKIP_HEADER_IDS; const ids = new Set(); for (const part of header.split(",")) { const trimmed = part.trim(); if (trimmed.startsWith("layout:")) { ids.add(trimmed); + if (ids.size >= MAX_SKIP_LAYOUT_IDS) { + break; + } } } - return ids; + return ids.size > 0 ? ids : EMPTY_SKIP_HEADER_IDS; } const EMPTY_SKIP_DECISION: ReadonlySet = new Set(); diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 6b2619ab2..aaf16279d 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -258,6 +258,9 @@ export async function renderAppPageLifecycle( return renderAppPageHtmlStream({ fontData, navigationContext: options.getNavigationContext(), + // HTML SSR always consumes the canonical stream: skipIds is only + // non-empty for RSC requests, so the HTML path never sees filtered + // bytes even though it reuses the same `rscForResponse` handle. rscStream: rscForResponse, scriptNonce: options.scriptNonce, ssrHandler, diff --git a/packages/vinext/src/server/app-page-skip-filter.ts b/packages/vinext/src/server/app-page-skip-filter.ts index 31fd59b85..ec8add9b5 100644 --- a/packages/vinext/src/server/app-page-skip-filter.ts +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -25,8 +25,10 @@ */ // 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. +// parser (`resolveModel` in ReactFlightClient.js). Audit this character class +// when upgrading React so new reference-like tags do not become silent drop +// cases in the filter: +// https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js const REFERENCE_PATTERN = /^\$(?:[LBFQWK@])?([0-9a-fA-F]+)$/; /** @@ -199,7 +201,7 @@ export function createSkipFilterTransform( // 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)}`; + const newRow0Raw = `${row0Raw.slice(0, colonIndex + 1)}${JSON.stringify(rewritten)}`; for (const row of pending) { if (row.kind === "passthrough") { diff --git a/tests/__snapshots__/entry-templates.test.ts.snap b/tests/__snapshots__/entry-templates.test.ts.snap index 57d7a2709..b3b3b6190 100644 --- a/tests/__snapshots__/entry-templates.test.ts.snap +++ b/tests/__snapshots__/entry-templates.test.ts.snap @@ -1283,9 +1283,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 @@ -3525,9 +3526,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 @@ -5762,9 +5764,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 @@ -8031,9 +8034,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 @@ -10274,9 +10278,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 @@ -12739,9 +12744,10 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); + const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) - : new Set(); + : __EMPTY_SKIP_LAYOUT_IDS; // 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 diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 5e2f2a84c..85cd03f20 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -187,6 +187,16 @@ describe("parseSkipHeader", () => { const result = parseSkipHeader("layout:/,garbage,layout:/blog,page:/x"); expect(result).toEqual(new Set(["layout:/", "layout:/blog"])); }); + + it("caps parsed layout ids to a bounded set", () => { + const header = Array.from({ length: 60 }, (_, index) => `layout:/segment-${index}`).join(","); + const result = parseSkipHeader(header); + + expect(result.size).toBe(50); + expect(result.has("layout:/segment-0")).toBe(true); + expect(result.has("layout:/segment-49")).toBe(true); + expect(result.has("layout:/segment-50")).toBe(false); + }); }); describe("computeSkipDecision", () => { diff --git a/tests/app-page-skip-filter.test.ts b/tests/app-page-skip-filter.test.ts index 28e301cd0..1d32b2e09 100644 --- a/tests/app-page-skip-filter.test.ts +++ b/tests/app-page-skip-filter.test.ts @@ -251,6 +251,22 @@ describe("createSkipFilterTransform", () => { expect(output).toContain(expectedRow0); }); + test("preserves the original row-0 prefix when rewriting", async () => { + const rows = [ + `1:["$","header",null,{"children":"layout"}]`, + `2:["$","p",null,{"children":"page"}]`, + `00:{"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(`00:${JSON.stringify({ "slot:page": "$L2", __route: "route:/" })}`); + expect(output.split("\n")).not.toContain( + `0:${JSON.stringify({ "slot:page": "$L2", __route: "route:/" })}`, + ); + }); + test("keeps a row referenced from both kept and killed slots", async () => { const rows = [ `1:["$","header",null,{"children":"layout-only"}]`, From 73d92243c9308c0e6ac26f74149277d6bc7dc4d6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Thu, 16 Apr 2026 18:55:25 +1000 Subject: [PATCH 3/3] fix(app-router): tighten dormant skip-filter defaults --- packages/vinext/src/entries/app-rsc-entry.ts | 2 +- packages/vinext/src/server/app-elements.ts | 2 +- packages/vinext/src/server/app-page-cache.ts | 2 ++ packages/vinext/src/server/app-page-render.ts | 2 +- .../vinext/src/server/app-page-skip-filter.ts | 9 +++++-- .../entry-templates.test.ts.snap | 12 ++++----- tests/app-elements.test.ts | 10 +++++++ tests/app-page-render.test.ts | 26 ++++++++++++++++++- 8 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index f056288d2..243a37b59 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -494,6 +494,7 @@ function __pageCacheTags(pathname, extraTags) { } return tags; } +const __EMPTY_SKIP_LAYOUT_IDS = new Set(); // Note: cache entries are written with \`headers: undefined\`. Next.js stores // response headers (e.g. set-cookie from cookies().set() during render) in the // cache entry so they can be replayed on HIT. We don't do this because: @@ -1422,7 +1423,6 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { } const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component"); - const __EMPTY_SKIP_LAYOUT_IDS = new Set(); const __skipLayoutIds = isRscRequest ? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER)) : __EMPTY_SKIP_LAYOUT_IDS; diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index f97a4ada8..587ece433 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -144,7 +144,7 @@ export function computeSkipDecision( decision.add(id); } } - return decision; + return decision.size > 0 ? decision : EMPTY_SKIP_DECISION; } export function normalizeAppElements(elements: AppWireElements): AppElements { diff --git a/packages/vinext/src/server/app-page-cache.ts b/packages/vinext/src/server/app-page-cache.ts index e8517c6dd..f143291a6 100644 --- a/packages/vinext/src/server/app-page-cache.ts +++ b/packages/vinext/src/server/app-page-cache.ts @@ -111,6 +111,8 @@ export function buildAppPageCachedResponse( rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader; } + // Cached bytes stay canonical. The current request's skipIds decide + // whether this client gets the full payload or a filtered egress view. const body = wrapRscBytesForResponse(cachedValue.rscData, options.skipIds ?? EMPTY_SKIP_SET); return new Response(body, { diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index aaf16279d..7ac0e10bf 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -184,7 +184,7 @@ export async function renderAppPageLifecycle( const isrRscDataPromise = rscCapture.capturedRscDataPromise; const skipIds = - options.isRscRequest && (options.supportsFilteredRscStream ?? true) + options.isRscRequest && (options.supportsFilteredRscStream ?? false) ? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds) : EMPTY_SKIP_SET; const rscForResponse = diff --git a/packages/vinext/src/server/app-page-skip-filter.ts b/packages/vinext/src/server/app-page-skip-filter.ts index ec8add9b5..4519b001c 100644 --- a/packages/vinext/src/server/app-page-skip-filter.ts +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -29,7 +29,12 @@ // when upgrading React so new reference-like tags do not become silent drop // cases in the filter: // https://github.com/facebook/react/blob/main/packages/react-client/src/ReactFlightClient.js -const REFERENCE_PATTERN = /^\$(?:[LBFQWK@])?([0-9a-fA-F]+)$/; +const FLIGHT_REF_TAGS = "LBFQWK@"; +const REFERENCE_PATTERN = new RegExp(`^\\$(?:[${FLIGHT_REF_TAGS}])?([0-9a-fA-F]+)$`); +const RAW_REFERENCE_PATTERN = new RegExp( + `(?): void { // 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(/(? { expect(decision).toEqual(new Set()); }); + it("reuses the shared empty-set sentinel when all requested ids are rejected", () => { + const emptyDecision = computeSkipDecision({ "layout:/": "s" }, undefined); + const rejectedDecision = computeSkipDecision( + { "layout:/a": "d", "layout:/b": "d" }, + new Set(["layout:/a", "layout:/b"]), + ); + + expect(rejectedDecision).toBe(emptyDecision); + }); + it("excludes an id missing from the flags", () => { const decision = computeSkipDecision({}, new Set(["layout:/a"])); expect(decision).toEqual(new Set()); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 9f1826653..95474f61a 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -625,7 +625,7 @@ describe("skip header filtering", () => { isForceStatic: false, isProduction: overrides.isProduction ?? true, isRscRequest: overrides.isRscRequest ?? true, - supportsFilteredRscStream: overrides.supportsFilteredRscStream ?? true, + supportsFilteredRscStream: overrides.supportsFilteredRscStream, isrHtmlKey: (p: string) => `html:${p}`, isrRscKey: (p: string) => `rsc:${p}`, isrSet, @@ -693,6 +693,7 @@ describe("skip header filtering", () => { probeLayoutAt: () => null, classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), requestedSkipLayoutIds: new Set(["layout:/"]), + supportsFilteredRscStream: true, }); await renderAppPageLifecycle(options); @@ -713,6 +714,7 @@ describe("skip header filtering", () => { probeLayoutAt: () => null, classification: staticClassification({ 0: "layout:/", 1: "layout:/blog" }), requestedSkipLayoutIds: new Set(["layout:/"]), + supportsFilteredRscStream: true, }); const response = await renderAppPageLifecycle(options); @@ -743,6 +745,7 @@ describe("skip header filtering", () => { }, }, requestedSkipLayoutIds: new Set(["layout:/"]), + supportsFilteredRscStream: true, }); const response = await renderAppPageLifecycle(options); @@ -765,6 +768,7 @@ describe("skip header filtering", () => { probeLayoutAt: () => null, classification: staticClassification({ 0: "layout:/" }), requestedSkipLayoutIds: new Set(["layout:/"]), + supportsFilteredRscStream: true, }); const response = await renderAppPageLifecycle(options); @@ -816,6 +820,25 @@ describe("skip header filtering", () => { expect(captured["layout:/"]).toBe("root-layout"); }); + it("keeps filtering dormant by default when the support flag is omitted", async () => { + const { options } = createRscOptions({ + element: { + "layout:/": "root-layout", + "page:/test": "test-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("layout:/"); + expect(body).toContain("root-layout"); + }); + it("does not filter layouts on development RSC requests", async () => { const { options } = createRscOptions({ element: { @@ -849,6 +872,7 @@ describe("skip header filtering", () => { requestedSkipLayoutIds: new Set(["layout:/"]), isProduction: true, revalidateSeconds: 60, + supportsFilteredRscStream: true, }); const response = await renderAppPageLifecycle(options);