Skip to content

Commit 4c2f81f

Browse files
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.
1 parent 9791a63 commit 4c2f81f

10 files changed

Lines changed: 1533 additions & 6 deletions

packages/vinext/src/entries/app-rsc-entry.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,6 +394,8 @@ import {
394394
import {
395395
APP_INTERCEPTION_CONTEXT_KEY as __APP_INTERCEPTION_CONTEXT_KEY,
396396
createAppPayloadRouteId as __createAppPayloadRouteId,
397+
parseSkipHeader as __parseSkipHeader,
398+
X_VINEXT_ROUTER_SKIP_HEADER as __X_VINEXT_ROUTER_SKIP_HEADER,
397399
} from ${JSON.stringify(appElementsPath)};
398400
import {
399401
buildAppPageElements as __buildAppPageElements,
@@ -1420,6 +1422,9 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
14201422
}
14211423
14221424
const isRscRequest = pathname.endsWith(".rsc") || request.headers.get("accept")?.includes("text/x-component");
1425+
const __skipLayoutIds = isRscRequest
1426+
? __parseSkipHeader(request.headers.get(__X_VINEXT_ROUTER_SKIP_HEADER))
1427+
: new Set();
14231428
// Read mounted-slots header once at the handler scope and thread it through
14241429
// every buildPageElements call site. Previously both the handler and
14251430
// buildPageElements read and normalized it independently, which invited
@@ -2206,6 +2211,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
22062211
isrSet: __isrSet,
22072212
mountedSlotsHeader: __mountedSlotsHeader,
22082213
revalidateSeconds,
2214+
skipIds: __skipLayoutIds,
22092215
renderFreshPageForCache: async function() {
22102216
// Re-render the page to produce fresh HTML + RSC data for the cache
22112217
// Use an empty headers context for background regeneration — not the original
@@ -2420,6 +2426,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
24202426
isForceStatic,
24212427
isProduction: process.env.NODE_ENV === "production",
24222428
isRscRequest,
2429+
supportsFilteredRscStream: false,
24232430
isrDebug: __isrDebug,
24242431
isrHtmlKey: __isrHtmlKey,
24252432
isrRscKey: __isrRscKey,
@@ -2468,6 +2475,7 @@ async function _handleRequest(request, __reqCtx, _mwCtx) {
24682475
},
24692476
revalidateSeconds,
24702477
mountedSlotsHeader: __mountedSlotsHeader,
2478+
requestedSkipLayoutIds: __skipLayoutIds,
24712479
renderErrorBoundaryResponse(renderErr) {
24722480
return renderErrorBoundaryPage(route, renderErr, isRscRequest, request, params, _scriptNonce);
24732481
},

packages/vinext/src/server/app-elements.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,47 @@ export function resolveVisitedResponseInterceptionContext(
101101
return payloadInterceptionContext ?? requestInterceptionContext;
102102
}
103103

104+
export const X_VINEXT_ROUTER_SKIP_HEADER = "X-Vinext-Router-Skip";
105+
export const X_VINEXT_MOUNTED_SLOTS_HEADER = "X-Vinext-Mounted-Slots";
106+
107+
export function parseSkipHeader(header: string | null): ReadonlySet<string> {
108+
if (!header) return new Set();
109+
const ids = new Set<string>();
110+
for (const part of header.split(",")) {
111+
const trimmed = part.trim();
112+
if (trimmed.startsWith("layout:")) {
113+
ids.add(trimmed);
114+
}
115+
}
116+
return ids;
117+
}
118+
119+
const EMPTY_SKIP_DECISION: ReadonlySet<string> = new Set<string>();
120+
121+
/**
122+
* Pure: computes the authoritative set of layout ids that should be omitted
123+
* from the outgoing payload. Defense-in-depth — an id is only included if the
124+
* server independently classified it as `"s"` (static). Empty or missing
125+
* `requested` yields a shared empty set so the hot path does not allocate.
126+
*
127+
* See `LayoutFlags` type docblock in this file for lifecycle.
128+
*/
129+
export function computeSkipDecision(
130+
layoutFlags: LayoutFlags,
131+
requested: ReadonlySet<string> | undefined,
132+
): ReadonlySet<string> {
133+
if (!requested || requested.size === 0) {
134+
return EMPTY_SKIP_DECISION;
135+
}
136+
const decision = new Set<string>();
137+
for (const id of requested) {
138+
if (layoutFlags[id] === "s") {
139+
decision.add(id);
140+
}
141+
}
142+
return decision;
143+
}
144+
104145
export function normalizeAppElements(elements: AppWireElements): AppElements {
105146
let needsNormalization = false;
106147
for (const [key, value] of Object.entries(elements)) {

packages/vinext/src/server/app-page-cache.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { CachedAppPageValue } from "../shims/cache.js";
22
import { buildAppPageCacheValue, type ISRCacheEntry } from "./isr-cache.js";
3+
import { wrapRscBytesForResponse } from "./app-page-skip-filter.js";
4+
5+
const EMPTY_SKIP_SET: ReadonlySet<string> = new Set<string>();
36

47
type AppPageDebugLogger = (event: string, detail: string) => void;
58
type AppPageCacheGetter = (key: string) => Promise<ISRCacheEntry | null>;
@@ -22,6 +25,7 @@ export type BuildAppPageCachedResponseOptions = {
2225
isRscRequest: boolean;
2326
mountedSlotsHeader?: string | null;
2427
revalidateSeconds: number;
28+
skipIds?: ReadonlySet<string>;
2529
};
2630

2731
export type ReadAppPageCacheResponseOptions = {
@@ -37,6 +41,7 @@ export type ReadAppPageCacheResponseOptions = {
3741
revalidateSeconds: number;
3842
renderFreshPageForCache: () => Promise<AppPageCacheRenderResult>;
3943
scheduleBackgroundRegeneration: AppPageBackgroundRegenerator;
44+
skipIds?: ReadonlySet<string>;
4045
};
4146

4247
export type FinalizeAppPageHtmlCacheResponseOptions = {
@@ -106,7 +111,9 @@ export function buildAppPageCachedResponse(
106111
rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader;
107112
}
108113

109-
return new Response(cachedValue.rscData, {
114+
const body = wrapRscBytesForResponse(cachedValue.rscData, options.skipIds ?? EMPTY_SKIP_SET);
115+
116+
return new Response(body, {
110117
status,
111118
headers: rscHeaders,
112119
});
@@ -142,6 +149,7 @@ export async function readAppPageCacheResponse(
142149
isRscRequest: options.isRscRequest,
143150
mountedSlotsHeader: options.mountedSlotsHeader,
144151
revalidateSeconds: options.revalidateSeconds,
152+
skipIds: options.skipIds,
145153
});
146154

147155
if (hitResponse) {
@@ -198,6 +206,7 @@ export async function readAppPageCacheResponse(
198206
isRscRequest: options.isRscRequest,
199207
mountedSlotsHeader: options.mountedSlotsHeader,
200208
revalidateSeconds: options.revalidateSeconds,
209+
skipIds: options.skipIds,
201210
});
202211

203212
if (staleResponse) {

packages/vinext/src/server/app-page-render.ts

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import type { ReactNode } from "react";
22
import type { CachedAppPageValue } from "../shims/cache.js";
3-
import { buildOutgoingAppPayload, type AppOutgoingElements } from "./app-elements.js";
3+
import {
4+
buildOutgoingAppPayload,
5+
computeSkipDecision,
6+
type AppOutgoingElements,
7+
} from "./app-elements.js";
48
import {
59
finalizeAppPageHtmlCacheResponse,
610
scheduleAppPageRscCacheWrite,
711
} from "./app-page-cache.js";
12+
import { createSkipFilterTransform } from "./app-page-skip-filter.js";
813
import {
914
buildAppPageFontLinkHeader,
1015
resolveAppPageSpecialError,
@@ -32,6 +37,8 @@ import {
3237
type AppPageSsrHandler,
3338
} from "./app-page-stream.js";
3439

40+
const EMPTY_SKIP_SET: ReadonlySet<string> = new Set<string>();
41+
3542
type AppPageBoundaryOnError = (
3643
error: unknown,
3744
requestInfo: unknown,
@@ -68,6 +75,7 @@ export type RenderAppPageLifecycleOptions = {
6875
isForceStatic: boolean;
6976
isProduction: boolean;
7077
isRscRequest: boolean;
78+
supportsFilteredRscStream?: boolean;
7179
isrDebug?: AppPageDebugLogger;
7280
isrHtmlKey: (pathname: string) => string;
7381
isrRscKey: (pathname: string, mountedSlotsHeader?: string | null) => string;
@@ -97,6 +105,7 @@ export type RenderAppPageLifecycleOptions = {
97105
waitUntil?: (promise: Promise<void>) => void;
98106
element: ReactNode | Readonly<Record<string, ReactNode>>;
99107
classification?: LayoutClassificationOptions | null;
108+
requestedSkipLayoutIds?: ReadonlySet<string>;
100109
};
101110

102111
function buildResponseTiming(
@@ -148,9 +157,9 @@ export async function renderAppPageLifecycle(
148157

149158
const layoutFlags = preRenderResult.layoutFlags;
150159

151-
// Render the CANONICAL element. The outgoing payload carries per-layout
152-
// static/dynamic flags under `__layoutFlags` so the client can later tell
153-
// which layouts are safe to skip on subsequent navigations.
160+
// Always render the CANONICAL element. Skip semantics are applied on the
161+
// egress branch only so the cache branch receives full bytes regardless of
162+
// the client's skip header. See `app-page-skip-filter.ts`.
154163
const outgoingElement = buildOutgoingAppPayload({
155164
element: options.element,
156165
layoutFlags,
@@ -172,9 +181,17 @@ export async function renderAppPageLifecycle(
172181
revalidateSeconds !== Infinity &&
173182
!options.isForceDynamic,
174183
);
175-
const rscForResponse = rscCapture.responseStream;
176184
const isrRscDataPromise = rscCapture.capturedRscDataPromise;
177185

186+
const skipIds =
187+
options.isRscRequest && (options.supportsFilteredRscStream ?? true)
188+
? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds)
189+
: EMPTY_SKIP_SET;
190+
const rscForResponse =
191+
skipIds.size > 0
192+
? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds))
193+
: rscCapture.responseStream;
194+
178195
if (options.isRscRequest) {
179196
const dynamicUsedDuringBuild = options.consumeDynamicUsage();
180197
const rscResponsePolicy = resolveAppPageRscResponsePolicy({

0 commit comments

Comments
 (0)