-
Notifications
You must be signed in to change notification settings - Fork 337
feat(app-router): filter skipped layouts from RSC responses and cached reads [3/6] #840
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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<string> = new Set<string>(); | ||||||
|
|
||||||
| export function parseSkipHeader(header: string | null): ReadonlySet<string> { | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The header parsing accepts any This is low severity since Cloudflare Workers already enforce request header size limits, but defense-in-depth at the application layer doesn't hurt.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||||||
| if (!header) return EMPTY_SKIP_HEADER_IDS; | ||||||
| const ids = new Set<string>(); | ||||||
| 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<string> = new Set<string>(); | ||||||
|
|
||||||
| /** | ||||||
| * 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<string> | undefined, | ||||||
| ): ReadonlySet<string> { | ||||||
| if (!requested || requested.size === 0) { | ||||||
| return EMPTY_SKIP_DECISION; | ||||||
| } | ||||||
| const decision = new Set<string>(); | ||||||
| for (const id of requested) { | ||||||
| if (layoutFlags[id] === "s") { | ||||||
| decision.add(id); | ||||||
| } | ||||||
| } | ||||||
| return decision; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
This means
Suggested change
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. |
||||||
| } | ||||||
|
|
||||||
| export function normalizeAppElements(elements: AppWireElements): AppElements { | ||||||
| let needsNormalization = false; | ||||||
| for (const [key, value] of Object.entries(elements)) { | ||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<string> = new Set<string>(); | ||
|
|
||
| type AppPageDebugLogger = (event: string, detail: string) => void; | ||
| type AppPageCacheGetter = (key: string) => Promise<ISRCacheEntry | null>; | ||
|
|
@@ -22,6 +25,7 @@ export type BuildAppPageCachedResponseOptions = { | |
| isRscRequest: boolean; | ||
| mountedSlotsHeader?: string | null; | ||
| revalidateSeconds: number; | ||
| skipIds?: ReadonlySet<string>; | ||
| }; | ||
|
|
||
| export type ReadAppPageCacheResponseOptions = { | ||
|
|
@@ -37,6 +41,7 @@ export type ReadAppPageCacheResponseOptions = { | |
| revalidateSeconds: number; | ||
| renderFreshPageForCache: () => Promise<AppPageCacheRenderResult>; | ||
| scheduleBackgroundRegeneration: AppPageBackgroundRegenerator; | ||
| skipIds?: ReadonlySet<string>; | ||
| }; | ||
|
|
||
| export type FinalizeAppPageHtmlCacheResponseOptions = { | ||
|
|
@@ -106,7 +111,9 @@ export function buildAppPageCachedResponse( | |
| rscHeaders["X-Vinext-Mounted-Slots"] = options.mountedSlotsHeader; | ||
| } | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good use of |
||
| return new Response(cachedValue.rscData, { | ||
| const body = wrapRscBytesForResponse(cachedValue.rscData, options.skipIds ?? EMPTY_SKIP_SET); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The cache-read path applies
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Documented. Added an inline comment on the cache-read path clarifying that the cached bytes stay canonical and the current request’s |
||
|
|
||
| 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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -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<string> = new Set<string>(); | ||||||
|
|
||||||
| 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>) => void; | ||||||
| element: ReactNode | Readonly<Record<string, ReactNode>>; | ||||||
| classification?: LayoutClassificationOptions | null; | ||||||
| requestedSkipLayoutIds?: ReadonlySet<string>; | ||||||
| }; | ||||||
|
|
||||||
| 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) | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Given that this is explicitly "dormant until canonical-stream is validated" per the PR description, an opt-in default (
Suggested change
This way, omitting the flag keeps the filter off rather than on.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Applied. The dormant filter is now opt-in: |
||||||
| ? computeSkipDecision(layoutFlags, options.requestedSkipLayoutIds) | ||||||
| : EMPTY_SKIP_SET; | ||||||
| const rscForResponse = | ||||||
| skipIds.size > 0 | ||||||
| ? rscCapture.responseStream.pipeThrough(createSkipFilterTransform(skipIds)) | ||||||
| : rscCapture.responseStream; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Worth noting for future readers:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Added an inline comment at the HTML SSR call site clarifying that
Comment on lines
+190
to
+193
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So from what I gather, it sounds like we render the entire tree, then skip returning parts of that tree to client? Wouldn't we want to skip rendering those before-hand to reduce server work? I believe both Next.js and Waku would skip before the rendering. |
||||||
|
|
||||||
| 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, | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The previous review flagged that this allocates a fresh
new Set()on every non-RSC request. The author responded that this was fixed to use a shared empty sentinel, but the code still showsconst __EMPTY_SKIP_LAYOUT_IDS = new Set()inside the request handler body — meaning it's allocated per-request, not shared.To actually share it, this needs to be hoisted outside
_handleRequestto module scope in the generated entry (similar to howEMPTY_SKIP_SETis a module-level constant inapp-page-render.tsandapp-page-cache.ts). The current placement inside the function body means a newSetinstance is created on every request, including non-RSC ones where__skipLayoutIdsimmediately points to it.Not a correctness issue (empty
Setis cheap), but the intent from the review response doesn't match what landed.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. The empty skip set is now hoisted to module scope in the generated RSC entry template, so non-RSC requests really reuse a shared sentinel instead of allocating inside
_handleRequest. Updated the entry snapshots with the hoist.