diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 7b0f16a7d..243a37b59 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, @@ -492,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: @@ -1420,6 +1423,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)) + : __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 @@ -2206,6 +2212,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 +2427,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 +2476,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..587ece433 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -101,6 +101,52 @@ 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"; +const MAX_SKIP_LAYOUT_IDS = 50; +const EMPTY_SKIP_HEADER_IDS: ReadonlySet = new Set(); + +export function parseSkipHeader(header: string | null): ReadonlySet { + 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.size > 0 ? ids : EMPTY_SKIP_HEADER_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.size > 0 ? decision : EMPTY_SKIP_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..f143291a6 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,11 @@ export function buildAppPageCachedResponse( rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader; } - return new Response(cachedValue.rscData, { + // 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, { status, headers: rscHeaders, }); @@ -142,6 +151,7 @@ export async function readAppPageCacheResponse( isRscRequest: options.isRscRequest, mountedSlotsHeader: options.mountedSlotsHeader, revalidateSeconds: options.revalidateSeconds, + skipIds: options.skipIds, }); if (hitResponse) { @@ -198,6 +208,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..7ac0e10bf 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 ?? false) + ? 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({ @@ -241,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 new file mode 100644 index 000000000..4519b001c --- /dev/null +++ b/packages/vinext/src/server/app-page-skip-filter.ts @@ -0,0 +1,340 @@ +/** + * 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 (`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 FLIGHT_REF_TAGS = "LBFQWK@"; +const REFERENCE_PATTERN = new RegExp(`^\\$(?:[${FLIGHT_REF_TAGS}])?([0-9a-fA-F]+)$`); +const RAW_REFERENCE_PATTERN = new RegExp( + `(?): 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(RAW_REFERENCE_PATTERN)) { + into.add(Number.parseInt(match[1], 16)); + } + + const jsonStart = payload.search(JSON_START_PATTERN); + if (jsonStart < 0) { + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(payload.slice(jsonStart)); + } catch { + return; + } + collectRscReferenceIds(parsed, into); +} + +function emitRow( + controller: TransformStreamDefaultController, + encoder: TextEncoder, + raw: string, +): void { + controller.enqueue(encoder.encode(`${raw}\n`)); +} + +/** + * Creates a TransformStream that rewrites row 0 to omit the given `skipIds` + * and drops any rows that end up orphaned. Empty skipIds yields an identity + * transform so the hot path pays no parsing cost. + */ +export function createSkipFilterTransform( + skipIds: ReadonlySet, +): TransformStream { + if (skipIds.size === 0) { + return new TransformStream({ + transform(chunk, controller) { + controller.enqueue(chunk); + }, + }); + } + + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + let state: FilterState = { phase: "initial", carry: "", pending: [] }; + + function promoteToStreaming( + controller: TransformStreamDefaultController, + row0Raw: string, + pending: readonly PendingRow[], + ): void { + const colonIndex = row0Raw.indexOf(":"); + const payload = row0Raw.slice(colonIndex + 1); + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + // Row 0 should always be a JSON object for App Router payloads. If + // parsing fails the skip filter cannot produce a correct result, so + // fall back to emitting the canonical stream unchanged. + for (const row of pending) { + emitRow(controller, encoder, row.raw); + } + state = { phase: "passthrough", carry: "" }; + return; + } + const { rewritten, liveIds } = filterRow0(parsed, skipIds); + // 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 = `${row0Raw.slice(0, colonIndex + 1)}${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..4b3f4e6a5 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, @@ -179,6 +181,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: @@ -1281,6 +1284,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)) + : __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 @@ -1883,6 +1889,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 +2104,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 +2153,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -2309,6 +2318,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, @@ -2407,6 +2418,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: @@ -3515,6 +3527,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)) + : __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 @@ -4117,6 +4132,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 +4347,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 +4396,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -4543,6 +4561,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, @@ -4641,6 +4661,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: @@ -5744,6 +5765,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)) + : __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 @@ -6346,6 +6370,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 +6585,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 +6634,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -6772,6 +6799,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, @@ -6870,6 +6899,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: @@ -8005,6 +8035,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)) + : __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 @@ -8607,6 +8640,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 +8855,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 +8904,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -9033,6 +9069,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, @@ -9131,6 +9169,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: @@ -10240,6 +10279,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)) + : __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 @@ -10842,6 +10884,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 +11099,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 +11148,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) { }, revalidateSeconds, mountedSlotsHeader: __mountedSlotsHeader, + requestedSkipLayoutIds: __skipLayoutIds, renderErrorBoundaryResponse(renderErr) { return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce); }, @@ -11268,6 +11313,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, @@ -11366,6 +11413,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: @@ -12697,6 +12745,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)) + : __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 @@ -13437,6 +13488,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 +13703,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 +13752,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..f284e9538 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,93 @@ 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"])); + }); + + 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", () => { + 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("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()); + }); + + 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..95474f61a 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,368 @@ 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, + 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:/"]), + supportsFilteredRscStream: true, + }); + + 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:/"]), + supportsFilteredRscStream: true, + }); + + 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:/"]), + supportsFilteredRscStream: true, + }); + + 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:/"]), + supportsFilteredRscStream: true, + }); + + 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("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: { + "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, + supportsFilteredRscStream: true, + }); + + 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..1d32b2e09 --- /dev/null +++ b/tests/app-page-skip-filter.test.ts @@ -0,0 +1,502 @@ +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("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"}]`, + `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"`); + }); +});