diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index b2d31cbbc..cfe939d95 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -55,7 +55,6 @@ const appPageRouteWiringPath = resolveEntryPath( import.meta.url, ); const appPageProbePath = resolveEntryPath("../server/app-page-probe.js", import.meta.url); -const appPageParamsPath = resolveEntryPath("../server/app-page-params.js", import.meta.url); const appPageDispatchPath = resolveEntryPath("../server/app-page-dispatch.js", import.meta.url); const appPageRequestPath = resolveEntryPath("../server/app-page-request.js", import.meta.url); const appSegmentConfigPath = resolveEntryPath("../server/app-segment-config.js", import.meta.url); @@ -255,12 +254,10 @@ import { AppElementsWire as __AppElementsWire, } from ${JSON.stringify(appElementsPath)}; import { + probeAppPageLayoutWithTracking as __probeAppPageLayoutWithTracking, resolveAppPageChildSegments as __resolveAppPageChildSegments, } from ${JSON.stringify(appPageRouteWiringPath)}; import { buildPageElements as __buildPageElements } from ${JSON.stringify(appPageElementBuilderPath)}; -import { - resolveAppPageSegmentParams as __resolveAppPageSegmentParams, -} from ${JSON.stringify(appPageParamsPath)}; import { probeAppPage as __probeAppPage } from ${JSON.stringify(appPageProbePath)}; import { dispatchAppPage as __dispatchAppPage, @@ -440,7 +437,7 @@ function findIntercept(pathname, sourcePathname = null) { return __routeMatcher.findIntercept(pathname, sourcePathname); } -async function buildPageElements(route, params, routePath, pageRequest) { +async function buildPageElements(route, params, routePath, pageRequest, layoutParamAccess) { return __buildPageElements({ route, params, @@ -451,6 +448,7 @@ async function buildPageElements(route, params, routePath, pageRequest) { rootForbiddenModule: ${rootForbiddenVar ? rootForbiddenVar : "null"}, rootUnauthorizedModule: ${rootUnauthorizedVar ? rootUnauthorizedVar : "null"}, metadataRoutes, + layoutParamAccess, basePath: __basePath, }); } @@ -512,6 +510,7 @@ export default __createAppRscHandler({ configRedirects: __configRedirects, configRewrites: __configRewrites, dispatchMatchedPage({ + clientReuseManifest, cleanPathname, formState, actionError, @@ -544,7 +543,7 @@ export default __createAppRscHandler({ const _asyncRouteParams = makeThenableParams(params); return __dispatchAppPage({ basePath: __basePath, - buildPageElement(targetRoute, targetParams, targetOpts, targetSearchParams) { + buildPageElement(targetRoute, targetParams, targetOpts, targetSearchParams, layoutParamAccess) { return buildPageElements(targetRoute, targetParams, cleanPathname, { opts: targetOpts, searchParams: targetSearchParams, @@ -552,8 +551,9 @@ export default __createAppRscHandler({ request, mountedSlotsHeader, renderMode, - }); + }, layoutParamAccess); }, + clientReuseManifest, cleanPathname, clearRequestContext() { __clearRequestContext(); @@ -600,16 +600,13 @@ export default __createAppRscHandler({ mountedSlotsHeader, params, rootParams, - probeLayoutAt(li) { - const LayoutComp = route.layouts[li]?.default; - if (!LayoutComp) return null; - return LayoutComp({ - params: makeThenableParams(__resolveAppPageSegmentParams( - route.routeSegments, - route.layoutTreePositions?.[li] ?? 0, - params, - )), - children: null, + probeLayoutAt(li, layoutParamAccess) { + return __probeAppPageLayoutWithTracking({ + layoutIndex: li, + layoutParamAccess, + makeThenableParams, + matchedParams: params, + route, }); }, probePage() { diff --git a/packages/vinext/src/server/app-browser-client-reuse-manifest.ts b/packages/vinext/src/server/app-browser-client-reuse-manifest.ts new file mode 100644 index 000000000..3fa16db81 --- /dev/null +++ b/packages/vinext/src/server/app-browser-client-reuse-manifest.ts @@ -0,0 +1,170 @@ +import type { ArtifactCompatibilityEnvelope } from "./artifact-compatibility.js"; +import { + buildCacheVariantWithRouteBudget, + DEFAULT_CACHE_VARIANT_BUDGET, + type StaticLayoutCacheProofOutputScope, +} from "./cache-proof.js"; +import { + CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET, + countUtf8Bytes, + DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + serializeClientReuseManifest, +} from "./client-reuse-manifest.js"; +import { AppElementsWire, type AppElements } from "./app-elements.js"; +import { + createStaticLayoutClientReuseArtifactCompatibility, + createStaticLayoutClientReusePayloadHash, + createStaticLayoutClientReuseRouteId, +} from "./static-layout-client-reuse-proof.js"; +import type { AppRouterState } from "./app-browser-state.js"; + +type ClientReuseManifestLimits = typeof DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS; + +type VisibleAppState = Pick; + +type BrowserClientReuseManifestEntry = Readonly<{ + artifactCompatibility: ArtifactCompatibilityEnvelope; + id: string; + payloadHash: string; + privacy: "public"; + variantCacheKey: string; +}>; + +type CreateClientReuseManifestHeaderOptions = Readonly<{ + limits?: ClientReuseManifestLimits; +}>; + +function capClientReuseManifestProducerLimits( + limits: ClientReuseManifestLimits, +): ClientReuseManifestLimits { + return { + ...limits, + maxEntryCount: Math.min( + limits.maxEntryCount, + CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET, + ), + }; +} + +function serializeBoundedClientReuseManifest(input: { + entries: readonly BrowserClientReuseManifestEntry[]; + limits: ClientReuseManifestLimits; + visibleCommitVersion: number; +}): string | null { + const entries = input.entries.slice(0, input.limits.maxEntryCount); + // Binary search for the largest prefix that fits. JSON array serialization + // is monotonic here: adding an entry cannot reduce the byte count. + let low = 1; + let high = entries.length; + let best: string | null = null; + + while (low <= high) { + const size = Math.floor((low + high) / 2); + const serialized = serializeClientReuseManifest({ + entries: entries.slice(0, size), + replayWindow: { + validFromVisibleCommitVersion: input.visibleCommitVersion, + validUntilVisibleCommitVersion: input.visibleCommitVersion, + }, + visibleCommitVersion: input.visibleCommitVersion, + }); + if (countUtf8Bytes(serialized) <= input.limits.maxManifestBytes) { + best = serialized; + low = size + 1; + } else { + high = size - 1; + } + } + + return best; +} + +function hasRetainedElement(elements: AppElements, elementId: string): boolean { + return Object.hasOwn(elements, elementId); +} + +function createStaticLayoutEntry(input: { + artifactCompatibility: ArtifactCompatibilityEnvelope; + layoutId: string; +}): BrowserClientReuseManifestEntry | null { + const routeId = createStaticLayoutClientReuseRouteId(input.layoutId); + const output: StaticLayoutCacheProofOutputScope = { + kind: "layout", + layoutId: input.layoutId, + rootBoundaryId: input.artifactCompatibility.rootBoundaryId, + routeId, + }; + const candidateVariant = buildCacheVariantWithRouteBudget({ + budget: DEFAULT_CACHE_VARIANT_BUDGET, + dimensions: [], + output, + routeBudget: { + routeId: output.routeId, + variantCacheKeys: [], + }, + }); + if (candidateVariant.kind !== "variant") { + return null; + } + + const artifactCompatibility = createStaticLayoutClientReuseArtifactCompatibility({ + artifactCompatibility: input.artifactCompatibility, + layoutId: input.layoutId, + rootBoundaryId: output.rootBoundaryId, + routeId: output.routeId, + variantCacheKey: candidateVariant.variant.cacheKey, + }); + + return { + artifactCompatibility, + id: input.layoutId, + payloadHash: createStaticLayoutClientReusePayloadHash({ + artifactCompatibility, + layoutId: input.layoutId, + rootBoundaryId: output.rootBoundaryId, + routeId: output.routeId, + variantCacheKey: candidateVariant.variant.cacheKey, + }), + privacy: "public", + variantCacheKey: candidateVariant.variant.cacheKey, + }; +} + +export function createClientReuseManifestHeaderFromVisibleAppState( + state: VisibleAppState, + options: CreateClientReuseManifestHeaderOptions = {}, +): string | null { + const limits = capClientReuseManifestProducerLimits( + options.limits ?? DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + ); + const metadata = AppElementsWire.readMetadata(state.elements); + const entries: BrowserClientReuseManifestEntry[] = []; + + for (const layoutId of metadata.layoutIds) { + if (entries.length >= limits.maxEntryCount) break; + if (layoutId.length > limits.maxEntryIdLength) continue; + if (metadata.layoutFlags[layoutId] !== "s") continue; + if (!hasRetainedElement(state.elements, layoutId)) continue; + + const parsedKey = AppElementsWire.parseElementKey(layoutId); + if (parsedKey?.kind !== "layout") continue; + + const entry = createStaticLayoutEntry({ + artifactCompatibility: metadata.artifactCompatibility, + layoutId, + }); + if (entry) { + entries.push(entry); + } + } + + if (entries.length === 0) { + return null; + } + + return serializeBoundedClientReuseManifest({ + entries, + limits, + visibleCommitVersion: state.visibleCommitVersion, + }); +} diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1f1c389d9..147720c41 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -99,6 +99,7 @@ import { ElementsContext, Slot } from "vinext/shims/slot"; import type { RouteManifest } from "../routing/app-route-graph.js"; import { stripBasePath } from "../utils/base-path.js"; import { createOnUncaughtError } from "./app-browser-error.js"; +import { createClientReuseManifestHeaderFromVisibleAppState } from "./app-browser-client-reuse-manifest.js"; import { devOnCaughtError, devOnUncaughtError, @@ -131,7 +132,6 @@ import { import { ACTION_REDIRECT_HEADER, ACTION_REDIRECT_TYPE_HEADER, - VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_PARAMS_HEADER, VINEXT_RSC_REDIRECT_HEADER, } from "./headers.js"; @@ -1383,16 +1383,20 @@ function bootstrapHydration(rscStream: ReadableStream): void { // Pass navId so only this navigation (or a newer one) can clear it later. setPendingPathname(url.pathname, navId); - const elementsAtNavStart = getBrowserRouterState().elements; + const routerStateAtNavStart = getBrowserRouterState(); + const elementsAtNavStart = routerStateAtNavStart.elements; const mountedSlotsHeader = getMountedSlotIdsHeader(elementsAtNavStart); + const clientReuseManifestHeader = + navigationKind === "navigate" + ? createClientReuseManifestHeaderFromVisibleAppState(routerStateAtNavStart) + : null; const requestHeaders = createRscRequestHeaders({ + clientReuseManifestHeader, interceptionContext: requestInterceptionContext, + mountedSlotsHeader, renderMode: navigationKind === "refresh" ? APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI : undefined, }); - if (mountedSlotsHeader) { - requestHeaders.set(VINEXT_MOUNTED_SLOTS_HEADER, mountedSlotsHeader); - } const rscUrl = await createRscRequestUrl(url.pathname + url.search, requestHeaders); const cachedRoute = getVisitedResponse( rscUrl, diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index f9f0fd49e..385772c19 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -91,6 +91,7 @@ export type AppRouterAction = { renderId: number; rootLayoutTreePath: string | null; routeId: string; + skippedLayoutIds: readonly string[]; slotBindings: readonly AppElementsSlotBinding[]; type: "navigate" | "replace" | "traverse"; }; @@ -103,6 +104,7 @@ export type PendingNavigationCommit = { previousNextUrl: string | null; rootLayoutTreePath: string | null; routeId: string; + skippedLayoutIds: readonly string[]; }; export type AppNavigationPayloadOrigin = Readonly< @@ -119,6 +121,7 @@ export const VISITED_CACHE_APP_NAVIGATION_PAYLOAD_ORIGIN: AppNavigationPayloadOr type PendingNavigationCommitDisposition = "dispatch" | "hard-navigate" | "skip"; type CacheRestorableAppPayloadMetadata = Readonly<{ cacheEntryReuseProof?: CacheEntryReuseProof; + skippedLayoutIds: readonly string[]; }>; type DispatchPendingNavigationCommitDispositionDecision = { disposition: "dispatch"; @@ -151,7 +154,7 @@ function createOperationRecord(options: { export function isCacheRestorableAppPayloadMetadata( metadata: CacheRestorableAppPayloadMetadata, ): metadata is CacheRestorableAppPayloadMetadata & { cacheEntryReuseProof: CacheEntryReuseProof } { - return metadata.cacheEntryReuseProof !== undefined; + return metadata.cacheEntryReuseProof !== undefined && metadata.skippedLayoutIds.length === 0; } function requiresCacheEntryReuseProof(origin: AppNavigationPayloadOrigin): boolean { @@ -265,7 +268,7 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { }; } - return mapNavigationDecisionToPendingDisposition( + const decision = mapNavigationDecisionToPendingDisposition( planPendingRootBoundaryFlightResponse({ currentState: options.currentState, pending: options.pending, @@ -274,6 +277,12 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { traceFields, }), ); + + return mergeSkippedLayoutPreservation({ + currentState: options.currentState, + decision, + pending: options.pending, + }); } function createPendingNavigationTraceFields(options: { @@ -451,6 +460,39 @@ function mapNavigationDecisionToPendingDisposition( } } +function mergeSkippedLayoutPreservation(options: { + currentState: AppRouterState; + decision: PendingNavigationCommitDispositionDecision; + pending: PendingNavigationCommit; +}): PendingNavigationCommitDispositionDecision { + if (options.decision.disposition !== "dispatch") return options.decision; + if (options.pending.skippedLayoutIds.length === 0) return options.decision; + + const currentLayoutIds = new Set(options.currentState.layoutIds); + const targetLayoutIds = new Set(options.pending.action.layoutIds); + const preserveElementIds = [...options.decision.preserveElementIds]; + const seenPreservedIds = new Set(preserveElementIds); + + for (const id of options.pending.skippedLayoutIds) { + if (seenPreservedIds.has(id)) continue; + if (AppElementsWire.parseElementKey(id)?.kind !== "layout") continue; + if (!currentLayoutIds.has(id) || !targetLayoutIds.has(id)) continue; + if (!Object.hasOwn(options.currentState.elements, id)) continue; + + preserveElementIds.push(id); + seenPreservedIds.add(id); + } + + if (preserveElementIds.length === options.decision.preserveElementIds.length) { + return options.decision; + } + + return { + ...options.decision, + preserveElementIds, + }; +} + export async function createPendingNavigationCommit(options: { currentState: AppRouterState; nextElements: Promise; @@ -495,6 +537,7 @@ export async function createPendingNavigationCommit(options: { renderId: options.renderId, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, + skippedLayoutIds: metadata.skippedLayoutIds, type: options.type, }, // Convenience aliases — always equal their action.* counterparts. @@ -504,5 +547,6 @@ export async function createPendingNavigationCommit(options: { previousNextUrl, rootLayoutTreePath: metadata.rootLayoutTreePath, routeId: metadata.routeId, + skippedLayoutIds: metadata.skippedLayoutIds, }; } diff --git a/packages/vinext/src/server/app-elements-wire.ts b/packages/vinext/src/server/app-elements-wire.ts index 2a4d9f1fa..9c2ca7307 100644 --- a/packages/vinext/src/server/app-elements-wire.ts +++ b/packages/vinext/src/server/app-elements-wire.ts @@ -11,7 +11,10 @@ import type { CacheProofRejectionCode, RenderObservation, } from "./cache-proof.js"; +import type { ClientReuseManifestSkipDisposition } from "./client-reuse-manifest.js"; import { isInterceptionMatchedUrlPath } from "./normalize-path.js"; +import { releaseAppElementRenderDependency } from "./app-render-dependency.js"; +import { isUnknownRecord } from "../utils/record.js"; const APP_INTERCEPTION_SEPARATOR = "\0"; @@ -24,10 +27,12 @@ export const APP_LAYOUT_FLAGS_KEY = "__layoutFlags"; export const APP_RENDER_OBSERVATION_KEY = "__renderObservation"; export const APP_ROUTE_KEY = "__route"; export const APP_ROOT_LAYOUT_KEY = "__rootLayout"; +export const APP_SKIPPED_LAYOUT_IDS_KEY = "__skippedLayoutIds"; export const APP_SLOT_BINDINGS_KEY = "__slotBindings"; export const APP_UNMATCHED_SLOT_WIRE_VALUE = "__VINEXT_UNMATCHED_SLOT__"; export const UNMATCHED_SLOT = Symbol.for("vinext.unmatchedSlot"); +const EMPTY_SKIPPED_LAYOUT_IDS: ReadonlySet = new Set(); function createCacheProofRejectionCodeSet( codes: T & @@ -141,6 +146,7 @@ export type AppElementValue = | ArtifactCompatibilityEnvelope | CacheEntryReuseProof | AppElementsInterception + | readonly string[] | readonly AppElementsSlotBinding[]; type AppWireElementValue = | ReactNode @@ -150,6 +156,7 @@ type AppWireElementValue = | ArtifactCompatibilityEnvelope | CacheEntryReuseProof | AppElementsInterception + | readonly string[] | readonly AppElementsSlotBinding[]; export type AppElements = Readonly>; @@ -183,6 +190,7 @@ type AppElementsMetadata = { layoutFlags: LayoutFlags; routeId: string; rootLayoutTreePath: string | null; + skippedLayoutIds: readonly string[]; slotBindings: readonly AppElementsSlotBinding[]; }; @@ -226,6 +234,7 @@ export type AppOutgoingElements = Readonly< | CacheEntryReuseProof | AppElementsInterception | RenderObservation + | readonly string[] | readonly AppElementsSlotBinding[] > >; @@ -240,6 +249,7 @@ type AppElementsWireKeys = { readonly renderObservation: typeof APP_RENDER_OBSERVATION_KEY; readonly rootLayout: typeof APP_ROOT_LAYOUT_KEY; readonly route: typeof APP_ROUTE_KEY; + readonly skippedLayoutIds: typeof APP_SKIPPED_LAYOUT_IDS_KEY; readonly slotBindings: typeof APP_SLOT_BINDINGS_KEY; }; @@ -251,15 +261,12 @@ type AppElementsWireCodec = { encodeCacheKey(rscUrl: string, interceptionContext: string | null): string; encodeLayoutId(treePath: string): string; encodeOutgoingPayload(input: { - element: - | ReactNode - | Readonly< - Record - >; + element: ReactNode | AppElements; artifactCompatibility?: ArtifactCompatibilityEnvelope; cacheEntryReuseProof?: CacheEntryReuseProof; layoutFlags: LayoutFlags; renderObservation?: RenderObservation; + skipDisposition?: ClientReuseManifestSkipDisposition; }): ReactNode | AppOutgoingElements; encodePageId(routePath: string, interceptionContext: string | null): string; encodeRouteId(routePath: string, interceptionContext: string | null): string; @@ -425,20 +432,16 @@ function isLayoutFlagsRecord(value: unknown): value is LayoutFlags { return true; } -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - function parseLayoutFlags(value: unknown): LayoutFlags { if (isLayoutFlagsRecord(value)) return value; return {}; } -function parseLayoutIds(value: unknown): readonly string[] { +function parseLayoutIdList(value: unknown, fieldName: string): readonly string[] { if (value === undefined) return []; if (!Array.isArray(value)) { throw new Error( - "[vinext] Invalid __layoutIds in App Router payload: expected layout id string[]", + `[vinext] Invalid ${fieldName} in App Router payload: expected layout id string[]`, ); } @@ -446,13 +449,13 @@ function parseLayoutIds(value: unknown): readonly string[] { for (const entry of value) { if (typeof entry !== "string") { throw new Error( - "[vinext] Invalid __layoutIds in App Router payload: expected layout id string[]", + `[vinext] Invalid ${fieldName} in App Router payload: expected layout id string[]`, ); } const parsed = parseAppElementsWireElementKey(entry); if (parsed?.kind !== "layout") { - throw new Error("[vinext] Invalid __layoutIds in App Router payload: expected layout ids"); + throw new Error(`[vinext] Invalid ${fieldName} in App Router payload: expected layout ids`); } layoutIds.push(entry); @@ -460,6 +463,14 @@ function parseLayoutIds(value: unknown): readonly string[] { return layoutIds; } +function parseLayoutIds(value: unknown): readonly string[] { + return parseLayoutIdList(value, APP_LAYOUT_IDS_KEY); +} + +function parseSkippedLayoutIds(value: unknown): readonly string[] { + return parseLayoutIdList(value, APP_SKIPPED_LAYOUT_IDS_KEY); +} + function isSlotBindingState(value: unknown): value is AppElementsSlotBindingState { return value === "active" || value === "default" || value === "unmatched"; } @@ -478,7 +489,7 @@ function parseSlotBindings( const slotBindings: AppElementsSlotBinding[] = []; for (const entry of value) { - if (!isRecord(entry)) { + if (!isUnknownRecord(entry)) { throw new Error("[vinext] Invalid __slotBindings in App Router payload: expected objects"); } @@ -547,7 +558,7 @@ function parseInterceptionSlotId(value: string): string { function parseInterceptionMetadata(value: unknown): AppElementsInterception | null { if (value === undefined || value === null) return null; - if (!isRecord(value)) { + if (!isUnknownRecord(value)) { throw new Error("[vinext] Invalid __interception in App Router payload: expected object"); } @@ -596,19 +607,17 @@ export function withLayoutFlags>( } export function buildOutgoingAppPayload(input: { - element: - | ReactNode - | Readonly< - Record - >; + element: ReactNode | AppElements; artifactCompatibility?: ArtifactCompatibilityEnvelope; cacheEntryReuseProof?: CacheEntryReuseProof; layoutFlags: LayoutFlags; renderObservation?: RenderObservation; + skipDisposition?: ClientReuseManifestSkipDisposition; }): ReactNode | AppOutgoingElements { if (!isAppElementsRecord(input.element)) { return input.element; } + const skippedLayoutIds = createSkippedLayoutIds(input.skipDisposition); const payload: Record< string, | ReactNode @@ -617,13 +626,22 @@ export function buildOutgoingAppPayload(input: { | CacheEntryReuseProof | AppElementsInterception | RenderObservation + | readonly string[] | readonly AppElementsSlotBinding[] - > = { - ...input.element, - [APP_LAYOUT_FLAGS_KEY]: input.layoutFlags, - [APP_ARTIFACT_COMPATIBILITY_KEY]: - input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(), - }; + > = {}; + for (const [key, value] of Object.entries(input.element)) { + if (skippedLayoutIds.has(key)) { + releaseAppElementRenderDependency(input.element, key); + continue; + } + payload[key] = value === UNMATCHED_SLOT ? APP_UNMATCHED_SLOT_WIRE_VALUE : value; + } + payload[APP_LAYOUT_FLAGS_KEY] = input.layoutFlags; + if (skippedLayoutIds.size > 0) { + payload[APP_SKIPPED_LAYOUT_IDS_KEY] = [...skippedLayoutIds]; + } + payload[APP_ARTIFACT_COMPATIBILITY_KEY] = + input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(); if (input.cacheEntryReuseProof) { payload[APP_CACHE_ENTRY_REUSE_PROOF_KEY] = input.cacheEntryReuseProof; } @@ -633,6 +651,20 @@ export function buildOutgoingAppPayload(input: { return payload; } +function createSkippedLayoutIds( + skipDisposition: ClientReuseManifestSkipDisposition | undefined, +): ReadonlySet { + if (skipDisposition?.enabled !== true) return EMPTY_SKIPPED_LAYOUT_IDS; + + const skippedLayoutIds = new Set(); + for (const id of skipDisposition.skippedEntryIds) { + if (parseAppElementsWireElementKey(id)?.kind === "layout") { + skippedLayoutIds.add(id); + } + } + return skippedLayoutIds; +} + function readArtifactCompatibilityMetadata(value: unknown): ArtifactCompatibilityEnvelope { if (value === undefined) return createArtifactCompatibilityEnvelope(); @@ -669,13 +701,13 @@ function isCacheProofFallbackScope(value: unknown): value is CacheProofFallbackS // - { decision: ... } means the proof parsed into an explicit reuse decision. function parseCacheEntryReuseProofMetadata(value: unknown): CacheEntryReuseProof | null { if (value === undefined) return null; - if (!isRecord(value) || value.kind !== "runtime-cache-entry") { + if (!isUnknownRecord(value) || value.kind !== "runtime-cache-entry") { return createMissingCacheEntryReuseProof(); } const decision = value.decision; if (decision === null) return createMissingCacheEntryReuseProof(); - if (!isRecord(decision)) return createMissingCacheEntryReuseProof(); + if (!isUnknownRecord(decision)) return createMissingCacheEntryReuseProof(); if ( decision.kind === "reuse" && @@ -746,6 +778,7 @@ export function readAppElementsMetadata( const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); const layoutIds = parseLayoutIds(elements[APP_LAYOUT_IDS_KEY]); + const skippedLayoutIds = parseSkippedLayoutIds(elements[APP_SKIPPED_LAYOUT_IDS_KEY]); const slotBindings = parseSlotBindings(elements[APP_SLOT_BINDINGS_KEY], { layoutIds }); const interception = parseInterceptionMetadata(elements[APP_INTERCEPTION_KEY]); const artifactCompatibility = readArtifactCompatibilityMetadata( @@ -764,6 +797,7 @@ export function readAppElementsMetadata( layoutFlags, routeId, rootLayoutTreePath, + skippedLayoutIds, slotBindings, }; } @@ -781,6 +815,7 @@ export const AppElementsWire: AppElementsWireCodec = { renderObservation: APP_RENDER_OBSERVATION_KEY, rootLayout: APP_ROOT_LAYOUT_KEY, route: APP_ROUTE_KEY, + skippedLayoutIds: APP_SKIPPED_LAYOUT_IDS_KEY, slotBindings: APP_SLOT_BINDINGS_KEY, }, unmatchedSlotValue: APP_UNMATCHED_SLOT_WIRE_VALUE, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index e61632d5f..da9e5370e 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -14,6 +14,7 @@ export { APP_RENDER_OBSERVATION_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, + APP_SKIPPED_LAYOUT_IDS_KEY, APP_SLOT_BINDINGS_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, UNMATCHED_SLOT, diff --git a/packages/vinext/src/server/app-layout-param-observation.ts b/packages/vinext/src/server/app-layout-param-observation.ts new file mode 100644 index 000000000..6ac85f7fc --- /dev/null +++ b/packages/vinext/src/server/app-layout-param-observation.ts @@ -0,0 +1,238 @@ +import type { ThenableParamsObserver } from "vinext/shims/thenable-params"; +import { + _peekRequestScopedCacheLife, + _peekUnstableCacheObservations, + type UnstableCacheObservation, +} from "vinext/shims/cache"; +import { + getCollectedFetchTags, + peekCacheableFetchObservations, + peekDynamicFetchObservations, +} from "vinext/shims/fetch-cache"; +import { peekRenderRequestApiUsage } from "vinext/shims/headers"; +import { + isInsideUnifiedScope, + runWithUnifiedStateMutation, +} from "vinext/shims/unified-request-context"; +import type { RenderRequestApiKind } from "./cache-proof.js"; + +export type AppLayoutParamAccessObservation = Readonly<{ + cacheLifeObserved: boolean; + cacheTags: readonly string[]; + cacheableFetchCount: number; + completeness: "complete" | "unknown"; + dynamicFetchCount: number; + finiteRevalidateSeconds: number | null; + keys: readonly string[]; + observed: boolean; + paramScopeKeys: readonly string[]; + requestApis: readonly RenderRequestApiKind[]; + unstableCaches: readonly UnstableCacheObservation[]; +}>; + +export type AppLayoutParamAccessTracker = Readonly<{ + createThenableParamsObserver: (layoutId: string) => ThenableParamsObserver; + getLayoutObservation: (layoutId: string) => AppLayoutParamAccessObservation; + recordLayoutFiniteRevalidate: (layoutId: string, revalidateSeconds: number) => void; + recordLayoutParamScope: (layoutId: string, paramScopeKeys: readonly string[]) => void; + runLayoutProbe: (layoutId: string, probe: () => unknown) => unknown; +}>; + +export function isAppLayoutObservationUnsafeForStaticReuse( + observation: AppLayoutParamAccessObservation, +): boolean { + return ( + observation.completeness !== "complete" || + observation.paramScopeKeys.length > 0 || + observation.observed || + observation.requestApis.length > 0 || + observation.finiteRevalidateSeconds !== null || + observation.cacheLifeObserved || + observation.cacheTags.length > 0 || + observation.cacheableFetchCount > 0 || + observation.dynamicFetchCount > 0 || + observation.unstableCaches.length > 0 + ); +} + +type MutableLayoutParamAccessObservation = { + cacheLifeObserved: boolean; + cacheTags: Set; + cacheableFetches: Set; + dynamicFetches: Set; + finiteRevalidateSeconds: number | null; + keys: Set; + observed: boolean; + paramScopeKeys: Set; + probeComplete: boolean; + requestApis: Set; + unstableCaches: Map; +}; + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean( + value && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function", + ); +} + +export function createAppLayoutParamAccessTracker(): AppLayoutParamAccessTracker { + const observations = new Map(); + + const ensureObservation = (layoutId: string): MutableLayoutParamAccessObservation => { + const existing = observations.get(layoutId); + if (existing) return existing; + + const created: MutableLayoutParamAccessObservation = { + cacheLifeObserved: false, + cacheTags: new Set(), + cacheableFetches: new Set(), + dynamicFetches: new Set(), + finiteRevalidateSeconds: null, + keys: new Set(), + observed: false, + paramScopeKeys: new Set(), + probeComplete: false, + requestApis: new Set(), + unstableCaches: new Map(), + }; + observations.set(layoutId, created); + return created; + }; + + const markObserved = (layoutId: string, keys: readonly string[]) => { + const observation = ensureObservation(layoutId); + observation.observed = true; + for (const key of keys) { + observation.keys.add(key); + } + }; + + const markProbeComplete = (layoutId: string) => { + ensureObservation(layoutId).probeComplete = true; + }; + + const stringifyCacheLifeSnapshot = (): string | null => { + const cacheLife = _peekRequestScopedCacheLife(); + return cacheLife === null ? null : JSON.stringify(cacheLife); + }; + + const runWithIsolatedProbeDependencies = (probe: () => unknown): unknown => { + if (!isInsideUnifiedScope()) { + return probe(); + } + return runWithUnifiedStateMutation((ctx) => { + ctx.cacheableFetchUrls = new Set(); + ctx.currentRequestTags = []; + ctx.dynamicFetchUrls = new Set(); + ctx.dynamicUsageDetected = false; + ctx.renderRequestApiUsage = new Set(); + ctx.requestScopedCacheLife = null; + ctx.unstableCacheObservations = new Map(); + }, probe); + }; + + const recordProbeDependencies = (layoutId: string) => { + const observation = ensureObservation(layoutId); + if (stringifyCacheLifeSnapshot() !== null) { + observation.cacheLifeObserved = true; + } + for (const tag of getCollectedFetchTags()) { + observation.cacheTags.add(tag); + } + for (const url of peekCacheableFetchObservations()) { + observation.cacheableFetches.add(url); + } + for (const url of peekDynamicFetchObservations()) { + observation.dynamicFetches.add(url); + } + for (const requestApi of peekRenderRequestApiUsage()) { + observation.requestApis.add(requestApi); + } + for (const unstableCache of _peekUnstableCacheObservations()) { + observation.unstableCaches.set(unstableCache.keyHash, unstableCache); + } + }; + + return { + createThenableParamsObserver(layoutId) { + return { + observeParamAccess(keys) { + markObserved(layoutId, keys); + }, + }; + }, + getLayoutObservation(layoutId) { + const observation = observations.get(layoutId); + if (!observation) { + return { + cacheLifeObserved: false, + cacheTags: [], + cacheableFetchCount: 0, + completeness: "unknown", + dynamicFetchCount: 0, + finiteRevalidateSeconds: null, + keys: [], + observed: false, + paramScopeKeys: [], + requestApis: [], + unstableCaches: [], + }; + } + + return { + cacheLifeObserved: observation.cacheLifeObserved, + cacheTags: [...observation.cacheTags].sort(), + cacheableFetchCount: observation.cacheableFetches.size, + completeness: observation.probeComplete ? "complete" : "unknown", + dynamicFetchCount: observation.dynamicFetches.size, + finiteRevalidateSeconds: observation.finiteRevalidateSeconds, + keys: [...observation.keys].sort(), + observed: observation.observed, + paramScopeKeys: [...observation.paramScopeKeys].sort(), + requestApis: [...observation.requestApis].sort(), + unstableCaches: [...observation.unstableCaches.values()].sort((a, b) => + a.keyHash.localeCompare(b.keyHash), + ), + }; + }, + recordLayoutFiniteRevalidate(layoutId, revalidateSeconds) { + if (!Number.isFinite(revalidateSeconds) || revalidateSeconds <= 0) return; + const observation = ensureObservation(layoutId); + observation.finiteRevalidateSeconds = + observation.finiteRevalidateSeconds === null + ? revalidateSeconds + : Math.min(observation.finiteRevalidateSeconds, revalidateSeconds); + }, + recordLayoutParamScope(layoutId, paramScopeKeys) { + const observation = ensureObservation(layoutId); + for (const key of paramScopeKeys) { + observation.paramScopeKeys.add(key); + } + }, + runLayoutProbe(layoutId, probe) { + return runWithIsolatedProbeDependencies(() => { + const result = probe(); + if (!isPromiseLike(result)) { + recordProbeDependencies(layoutId); + markProbeComplete(layoutId); + return result; + } + + return Promise.resolve(result).then( + (resolved) => { + recordProbeDependencies(layoutId); + markProbeComplete(layoutId); + return resolved; + }, + (error: unknown) => { + recordProbeDependencies(layoutId); + throw error; + }, + ); + }); + }, + }; +} diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 9dd2be5af..d813861e2 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -71,9 +71,15 @@ import { } from "./app-rsc-render-mode.js"; import { createAppPageTreePath } from "./app-page-route-wiring.js"; import type { AppPageSsrHandler } from "./app-page-stream.js"; +import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { createStaticGenerationHeadersContext } from "./app-static-generation.js"; import { buildPageCacheTags } from "./implicit-tags.js"; import type { ISRCacheEntry } from "./isr-cache.js"; +import { + createAppLayoutParamAccessTracker, + isAppLayoutObservationUnsafeForStaticReuse, + type AppLayoutParamAccessTracker, +} from "./app-layout-param-observation.js"; type AppPageParams = Record; type AppPageElement = ReactNode | Readonly>; @@ -124,8 +130,20 @@ type AppPageDispatchInterceptOptions = { type AppPageModule = { default?: unknown; + dynamic?: unknown; + revalidate?: unknown; }; +type LayoutSegmentConfigClassification = Readonly<{ + kind: "dynamic" | "static"; + reason: ClassificationReason; +}>; + +type EffectiveLayoutClassifications = Readonly<{ + buildTimeClassifications: ReadonlyMap | null; + buildTimeReasons: ReadonlyMap | null | undefined; +}>; + type AppPageDispatchRoute = { __buildTimeClassifications?: LayoutClassificationOptions["buildTimeClassifications"]; __buildTimeReasons?: LayoutClassificationOptions["buildTimeReasons"]; @@ -151,7 +169,9 @@ type DispatchAppPageOptions = { params: AppPageParams, opts: AppPageDispatchInterceptOptions | undefined, searchParams: URLSearchParams, + layoutParamAccess?: AppLayoutParamAccessTracker, ) => Promise; + clientReuseManifest?: ClientReuseManifestParseResult; cleanPathname: string; clearRequestContext: () => void; createRscOnErrorHandler: (pathname: string, routePath: string) => AppPageBoundaryOnError; @@ -191,7 +211,7 @@ type DispatchAppPageOptions = { mountedSlotsHeader?: string | null; params: AppPageParams; rootParams?: RootParams; - probeLayoutAt: (layoutIndex: number) => unknown; + probeLayoutAt: (layoutIndex: number, layoutParamAccess?: AppLayoutParamAccessTracker) => unknown; probePage: () => unknown; expireSeconds?: number; renderErrorBoundaryPage: (error: unknown) => Promise; @@ -227,6 +247,69 @@ type DispatchAppPageOptions = { renderMode?: AppRscRenderMode; }; +function classifyLayoutSegmentConfigFromModule( + layout: AppPageModule | null | undefined, +): LayoutSegmentConfigClassification | null { + if (!layout) return null; + + switch (layout.dynamic) { + case "force-dynamic": + return { + kind: "dynamic", + reason: { layer: "segment-config", key: "dynamic", value: "force-dynamic" }, + }; + case "force-static": + case "error": + return { + kind: "static", + reason: { layer: "segment-config", key: "dynamic", value: layout.dynamic }, + }; + } + + if (layout.revalidate === false || layout.revalidate === Infinity) { + return { + kind: "static", + reason: { layer: "segment-config", key: "revalidate", value: Infinity }, + }; + } + if (layout.revalidate === 0) { + return { + kind: "dynamic", + reason: { layer: "segment-config", key: "revalidate", value: 0 }, + }; + } + + return null; +} + +function createEffectiveLayoutClassifications( + route: AppPageDispatchRoute, + includeReasons: boolean, +): EffectiveLayoutClassifications { + const classifications = new Map(route.__buildTimeClassifications ?? []); + const reasons = includeReasons ? new Map(route.__buildTimeReasons ?? []) : null; + + for (let index = 0; index < route.layouts.length; index++) { + const classification = classifyLayoutSegmentConfigFromModule(route.layouts[index]); + if (classification === null) continue; + + classifications.set(index, classification.kind); + reasons?.set(index, classification.reason); + } + + return { + buildTimeClassifications: classifications.size > 0 ? classifications : null, + buildTimeReasons: reasons && reasons.size > 0 ? reasons : null, + }; +} + +function getEffectiveLayoutClassifications( + route: AppPageDispatchRoute, + debugClassification: DispatchAppPageOptions["debugClassification"], +): EffectiveLayoutClassifications { + return createEffectiveLayoutClassifications(route, debugClassification !== undefined); +} + function shouldReadAppPageCache(options: { isProgressiveActionRender: boolean; isDraftMode: boolean; @@ -339,6 +422,7 @@ async function dispatchAppPageInner( const isDynamicError = dynamicConfig === "error"; const isForceDynamic = dynamicConfig === "force-dynamic"; const isDraftMode = isDraftModeRequest(options.request); + const layoutParamAccess = createAppLayoutParamAccessTracker(); setCurrentFetchSoftTags(buildAppPageTags(options.cleanPathname, [], route.routeSegments)); setCurrentFetchCacheMode(options.fetchCache ?? null); @@ -542,13 +626,20 @@ async function dispatchAppPageInner( AppPageDispatchInterceptOptions, AppPageElement >({ - buildPageElement(interceptRoute, interceptParams, interceptOpts, interceptSearchParams) { + buildPageElement( + interceptRoute, + interceptParams, + interceptOpts, + interceptSearchParams, + interceptLayoutParamAccess, + ) { setCurrentFetchCacheMode(options.resolveRouteFetchCacheMode?.(interceptRoute) ?? null); return options.buildPageElement( interceptRoute, interceptParams, interceptOpts, interceptSearchParams, + interceptLayoutParamAccess, ); }, cleanPathname: options.cleanPathname, @@ -563,6 +654,7 @@ async function dispatchAppPageInner( return options.getSourceRoute(sourceRouteIndex); }, isRscRequest: options.isRscRequest, + layoutParamAccess, renderInterceptResponse(sourceRoute, interceptElement) { const interceptOnError = options.createRscOnErrorHandler( options.cleanPathname, @@ -604,6 +696,7 @@ async function dispatchAppPageInner( options.params, interceptResult.interceptOpts, options.searchParams, + layoutParamAccess, ); }, renderErrorBoundaryPage(buildError) { @@ -618,6 +711,11 @@ async function dispatchAppPageInner( return pageBuildResult.response; } + const layoutClassifications = getEffectiveLayoutClassifications( + route, + options.debugClassification, + ); + return renderAppPageLifecycle({ basePath: options.basePath, cleanPathname: options.cleanPathname, @@ -634,6 +732,7 @@ async function dispatchAppPageInner( return options.createRscOnErrorHandler(pathname, routePath); }, element: pageBuildResult.element, + clientReuseManifest: options.clientReuseManifest, getDraftModeCookieHeader, getFontLinks: options.getFontLinks, getFontPreloads: options.getFontPreloads, @@ -672,6 +771,7 @@ async function dispatchAppPageInner( loadSsrHandler: options.loadSsrHandler, middlewareContext: options.middlewareContext, params: options.params, + layoutParamAccess, rootParams: options.rootParams, peekRenderObservationState() { return { @@ -680,7 +780,7 @@ async function dispatchAppPageInner( }; }, probeLayoutAt(layoutIndex) { - return options.probeLayoutAt(layoutIndex); + return options.probeLayoutAt(layoutIndex, layoutParamAccess); }, probePage() { return options.probePage(); @@ -692,9 +792,14 @@ async function dispatchAppPageInner( createAppPageTreePath([...route.routeSegments], treePosition), ); }, - buildTimeClassifications: route.__buildTimeClassifications, - buildTimeReasons: route.__buildTimeReasons, + buildTimeClassifications: layoutClassifications.buildTimeClassifications, + buildTimeReasons: layoutClassifications.buildTimeReasons, debugClassification: options.debugClassification, + isLayoutObservationDynamic(layoutId) { + return isAppLayoutObservationUnsafeForStaticReuse( + layoutParamAccess.getLayoutObservation(layoutId), + ); + }, async runWithIsolatedDynamicScope(fn) { const priorDynamic = consumeDynamicUsage(); try { diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index d3b4fce0d..268708ea6 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -18,6 +18,7 @@ import { normalizePathnameForRouteMatch } from "../routing/utils.js"; import type { MetadataFileRoute } from "./metadata-routes.js"; import { APP_RSC_RENDER_MODE_NAVIGATION, type AppRscRenderMode } from "./app-rsc-render-mode.js"; import { isInterceptionMatchedUrlPath, normalizePath } from "./normalize-path.js"; +import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; export type { AppPageErrorModule, AppPageRouteWiringRoute } from "./app-page-route-wiring.js"; @@ -79,6 +80,7 @@ export type BuildPageElementsOptions< rootUnauthorizedModule?: TModule | null; /** File-based metadata routes (favicon, manifest, sitemap, etc.). */ metadataRoutes: readonly MetadataFileRoute[]; + layoutParamAccess?: AppLayoutParamAccessTracker; /** * Configured next.config `basePath`. Threaded through `resolveAppPageHead` * so file-based metadata route URLs emitted in are prefixed. @@ -205,6 +207,7 @@ export async function buildPageElements< globalErrorModule: globalErrorModule ?? (DEFAULT_GLOBAL_ERROR_MODULE as unknown as TErrorModule), isRscRequest, + layoutParamAccess: options.layoutParamAccess, mountedSlotIds, makeThenableParams, matchedParams: params, diff --git a/packages/vinext/src/server/app-page-execution.ts b/packages/vinext/src/server/app-page-execution.ts index 3ab81b993..f368c03df 100644 --- a/packages/vinext/src/server/app-page-execution.ts +++ b/packages/vinext/src/server/app-page-execution.ts @@ -159,6 +159,11 @@ export type LayoutClassificationOptions = { debugClassification?: (layoutId: string, reason: ClassificationReason) => void; /** Maps layout index to its layout ID (e.g. "layout:/blog"). */ getLayoutId: (layoutIndex: number) => string; + /** + * Returns whether the completed per-layout observation makes static reuse + * unsafe even if the older dynamic scope did not report dynamic usage. + */ + isLayoutObservationDynamic?: (layoutId: string) => boolean; /** Runs a function with isolated dynamic usage tracking per layout. */ runWithIsolatedDynamicScope: (fn: () => T) => Promise<{ result: T; dynamicDetected: boolean }>; }; @@ -399,9 +404,10 @@ export async function probeAppPageLayouts( const buildTimeResult = cls?.buildTimeClassifications?.get(layoutIndex); if (cls && buildTimeResult) { + const layoutId = cls.getLayoutId(layoutIndex); // Build-time classified (Layer 1 or Layer 2): skip dynamic isolation, // but still probe for special errors (redirects, not-found). - layoutFlags[cls.getLayoutId(layoutIndex)] = buildTimeResult === "static" ? "s" : "d"; + layoutFlags[layoutId] = buildTimeResult === "static" ? "s" : "d"; if (cls.debugClassification) { // `no-classifier` is the documented fallback for a layout that was // build-time classified but whose reason payload is absent — either @@ -409,34 +415,39 @@ export async function probeAppPageLayouts( // because no Layer 1/2 classifier attached a reason. This is the sole // producer of the variant; see `layout-classification-types.ts`. cls.debugClassification( - cls.getLayoutId(layoutIndex), + layoutId, cls.buildTimeReasons?.get(layoutIndex) ?? { layer: "no-classifier" }, ); } const errorResponse = await probeLayoutForErrors(options, layoutIndex); if (errorResponse) return errorResponse; + const observationDynamic = cls.isLayoutObservationDynamic?.(layoutId) === true; + layoutFlags[layoutId] = buildTimeResult === "dynamic" || observationDynamic ? "d" : "s"; continue; } if (cls) { + const layoutId = cls.getLayoutId(layoutIndex); // Layer 3: probe with isolated dynamic scope to detect per-layout // dynamic API usage (headers(), cookies(), connection(), etc.) try { const { dynamicDetected } = await cls.runWithIsolatedDynamicScope(() => options.probeLayoutAt(layoutIndex), ); - layoutFlags[cls.getLayoutId(layoutIndex)] = dynamicDetected ? "d" : "s"; + const observationDynamic = cls.isLayoutObservationDynamic?.(layoutId) === true; + const layoutDynamic = dynamicDetected || observationDynamic; + layoutFlags[layoutId] = layoutDynamic ? "d" : "s"; if (cls.debugClassification) { - cls.debugClassification(cls.getLayoutId(layoutIndex), { + cls.debugClassification(layoutId, { layer: "runtime-probe", - outcome: dynamicDetected ? "dynamic" : "static", + outcome: layoutDynamic ? "dynamic" : "static", }); } } catch (error) { // Probe failed — conservatively treat as dynamic. - layoutFlags[cls.getLayoutId(layoutIndex)] = "d"; + layoutFlags[layoutId] = "d"; if (cls.debugClassification) { - cls.debugClassification(cls.getLayoutId(layoutIndex), { + cls.debugClassification(layoutId, { layer: "runtime-probe", outcome: "dynamic", error: error instanceof Error ? error.message : String(error), diff --git a/packages/vinext/src/server/app-page-params.ts b/packages/vinext/src/server/app-page-params.ts index 21efe1cac..b7b766bb4 100644 --- a/packages/vinext/src/server/app-page-params.ts +++ b/packages/vinext/src/server/app-page-params.ts @@ -25,6 +25,28 @@ function isEmptyOptionalCatchAll(segment: string, paramValue: string | string[]) return segment.startsWith("[[...") && Array.isArray(paramValue) && paramValue.length === 0; } +export function resolveAppPageSegmentParamScopeKeys( + routeSegments: readonly string[] | null | undefined, + treePosition: number, +): readonly string[] { + const paramNames: string[] = []; + const seen = new Set(); + const segments = routeSegments ?? []; + const end = Math.min(Math.max(treePosition, 0), segments.length); + + for (let index = 0; index < end; index++) { + const paramName = getAppPageSegmentParamName(segments[index]); + if (!paramName || seen.has(paramName)) { + continue; + } + + seen.add(paramName); + paramNames.push(paramName); + } + + return paramNames; +} + export function resolveAppPageSegmentParams( routeSegments: readonly string[] | null | undefined, treePosition: number, diff --git a/packages/vinext/src/server/app-page-probe.ts b/packages/vinext/src/server/app-page-probe.ts index 260df574f..37e732c20 100644 --- a/packages/vinext/src/server/app-page-probe.ts +++ b/packages/vinext/src/server/app-page-probe.ts @@ -1,3 +1,4 @@ +import { Fragment, isValidElement, type ReactElement, type ReactNode } from "react"; import { makeThenableParams } from "vinext/shims/thenable-params"; import { collectAppPageSearchParams } from "./app-page-head.js"; import { @@ -8,6 +9,212 @@ import { type LayoutFlags, } from "./app-page-execution.js"; +const DEFAULT_SUBTREE_PROBE_MAX_DEPTH = 32; +const DEFAULT_SUBTREE_PROBE_MAX_NODES = 1000; +const REACT_FORWARD_REF_TYPE = Symbol.for("react.forward_ref"); +const REACT_LAZY_TYPE = Symbol.for("react.lazy"); +const REACT_MEMO_TYPE = Symbol.for("react.memo"); + +type ProbeReactServerSubtreeOptions = Readonly<{ + maxDepth?: number; + maxNodes?: number; +}>; + +type ProbeReactElementProps = Readonly<{ + children?: ReactNode; +}>; + +type UnknownFunction = (...args: unknown[]) => unknown; + +type ReactMemoType = Readonly<{ + innerType: unknown; +}>; + +type ReactLazyType = Readonly<{ + init: UnknownFunction; + payload: unknown; +}>; + +class AppPageSubtreeProbeLimitError extends Error { + constructor(message: string) { + super(message); + this.name = "AppPageSubtreeProbeLimitError"; + } +} + +class AppPageSubtreeProbeUnsupportedIterableError extends Error { + constructor() { + super("App page layout subtree probe cannot safely inspect iterable children"); + this.name = "AppPageSubtreeProbeUnsupportedIterableError"; + } +} + +function isPromiseLike(value: unknown): value is PromiseLike { + return Boolean( + value && + (typeof value === "object" || typeof value === "function") && + "then" in value && + typeof value.then === "function", + ); +} + +function isIterable(value: unknown): value is Iterable { + return Boolean( + value && + typeof value !== "string" && + typeof value === "object" && + Symbol.iterator in value && + typeof value[Symbol.iterator] === "function", + ); +} + +function isProbeReactElement(value: unknown): value is ReactElement { + return isValidElement(value); +} + +function isObjectLike(value: unknown): value is object { + return (typeof value === "object" || typeof value === "function") && value !== null; +} + +function isUnknownFunction(value: unknown): value is UnknownFunction { + return typeof value === "function"; +} + +function readReactMemoType(value: unknown): ReactMemoType | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_MEMO_TYPE) { + return null; + } + return { innerType: Reflect.get(value, "type") }; +} + +function readReactLazyType(value: unknown): ReactLazyType | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_LAZY_TYPE) { + return null; + } + const init = Reflect.get(value, "_init"); + if (!isUnknownFunction(init)) { + return null; + } + return { init, payload: Reflect.get(value, "_payload") }; +} + +function readReactForwardRefRender(value: unknown): UnknownFunction | null { + if (!isObjectLike(value) || Reflect.get(value, "$$typeof") !== REACT_FORWARD_REF_TYPE) { + return null; + } + const render = Reflect.get(value, "render"); + return isUnknownFunction(render) ? render : null; +} + +async function resolveReactLazyType(lazyType: ReactLazyType): Promise { + try { + return lazyType.init(lazyType.payload); + } catch (error) { + if (!isPromiseLike(error)) { + throw error; + } + await error; + return lazyType.init(lazyType.payload); + } +} + +/** + * Invokes server-component children returned by a layout probe so per-layout + * skip eligibility observes data dependencies created below the layout's + * immediate function body. The real RSC render remains authoritative; probe + * failures only make static-layout skip fall back to render-and-send. + */ +export async function probeReactServerSubtree( + node: unknown, + options: ProbeReactServerSubtreeOptions = {}, +): Promise { + const maxDepth = options.maxDepth ?? DEFAULT_SUBTREE_PROBE_MAX_DEPTH; + const maxNodes = options.maxNodes ?? DEFAULT_SUBTREE_PROBE_MAX_NODES; + let visitedNodes = 0; + + const enterProbeNode = (depth: number): void => { + if (depth > maxDepth) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max depth"); + } + visitedNodes += 1; + if (visitedNodes > maxNodes) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max nodes"); + } + }; + + const renderElementType = async ( + type: unknown, + props: ProbeReactElementProps, + depth: number, + wrapperDepth = 0, + ): Promise => { + if (wrapperDepth > maxDepth) { + throw new AppPageSubtreeProbeLimitError("App page layout subtree probe exceeded max depth"); + } + + if (isUnknownFunction(type)) { + await visit(type(props), depth + 1); + return true; + } + + const memoType = readReactMemoType(type); + if (memoType) { + return renderElementType(memoType.innerType, props, depth, wrapperDepth + 1); + } + + const lazyType = readReactLazyType(type); + if (lazyType) { + return renderElementType( + await resolveReactLazyType(lazyType), + props, + depth, + wrapperDepth + 1, + ); + } + + const forwardRefRender = readReactForwardRefRender(type); + if (forwardRefRender) { + await visit(forwardRefRender(props, null), depth + 1); + return true; + } + + return false; + }; + + const visit = async (value: unknown, depth: number): Promise => { + enterProbeNode(depth); + if (value == null || typeof value === "boolean" || typeof value === "number") return; + if (typeof value === "string" || typeof value === "bigint") return; + if (isPromiseLike(value)) { + await visit(await value, depth); + return; + } + if (Array.isArray(value)) { + for (const child of value) { + await visit(child, depth + 1); + } + return; + } + if (isIterable(value) && !isProbeReactElement(value)) { + throw new AppPageSubtreeProbeUnsupportedIterableError(); + } + if (!isProbeReactElement(value)) return; + + if (value.type === Fragment || typeof value.type === "string") { + await visit(value.props.children, depth + 1); + return; + } + + if (await renderElementType(value.type, value.props, depth)) { + return; + } + + await visit(value.props.children, depth + 1); + }; + + await visit(node, 0); +} + /** * Build a probePage() invocation for the App Router request lifecycle. * diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 60b93c9e9..b09c710e1 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -42,6 +42,28 @@ import { createArtifactCompatibilityGraphVersion, type ArtifactCompatibilityEnvelope, } from "./artifact-compatibility.js"; +import { + buildCacheVariantWithRouteBudget, + buildRenderObservation, + buildRenderRequestApiObservations, + createStaticLayoutArtifactReuseDecision, + DEFAULT_CACHE_VARIANT_BUDGET, + type StaticLayoutCacheProofOutputScope, +} from "./cache-proof.js"; +import type { + ClientReuseManifestEntry, + ClientReuseManifestParseResult, + ClientReuseManifestSkipDisposition, +} from "./client-reuse-manifest.js"; +import { NO_STORE_CACHE_CONTROL } from "./cache-control.js"; +import { + createClientReuseSkipTransportPlan, + createStaticLayoutClientReuseArtifactCompatibility, + createStaticLayoutClientReusePayloadHash, + createStaticLayoutClientReuseRouteId, + crossCheckClientReuseManifestEntryWithCache, + STATIC_LAYOUT_SKIP_VERIFICATION_ENTRY_BUDGET, +} from "./skip-cache-proof.js"; import { createAppPageHtmlOutputScope, createAppPageRenderObservation, @@ -49,6 +71,10 @@ import { createEmptyAppPageRenderObservationState, type AppPageRenderObservationState, } from "./app-page-render-observation.js"; +import type { + AppLayoutParamAccessObservation, + AppLayoutParamAccessTracker, +} from "./app-layout-param-observation.js"; type AppPageBoundaryOnError = ( error: unknown, @@ -105,6 +131,7 @@ type RenderAppPageLifecycleOptions = { ) => string; isrSet: AppPageCacheSetter; layoutCount: number; + layoutParamAccess?: AppLayoutParamAccessTracker; loadSsrHandler: () => Promise; middlewareContext: AppPageMiddlewareContext; params: Record; @@ -129,6 +156,8 @@ type RenderAppPageLifecycleOptions = { routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; scriptNonce?: string; + clientReuseManifest?: ClientReuseManifestParseResult; + skipDisposition?: ClientReuseManifestSkipDisposition; mountedSlotsHeader?: string | null; renderMode?: AppRscRenderMode; waitUntil?: (promise: Promise) => void; @@ -212,6 +241,296 @@ function createAppPageArtifactCompatibility( }); } +function readStringMetadata( + element: Readonly>, + key: string, +): string | null { + const value = element[key]; + return typeof value === "string" ? value : null; +} + +function createStaticLayoutOutputScope(input: { + artifactCompatibility: ArtifactCompatibilityEnvelope; + element: Readonly>; + layoutId: string; +}): StaticLayoutCacheProofOutputScope | null { + const routeId = readStringMetadata(input.element, AppElementsWire.keys.route); + if (routeId === null) return null; + + return { + kind: "layout", + layoutId: input.layoutId, + rootBoundaryId: input.artifactCompatibility.rootBoundaryId, + routeId, + }; +} + +function createRenderAndSendSkipDisposition(): ClientReuseManifestSkipDisposition { + return { + code: "SKIP_MODEL_DISABLED", + enabled: false, + mode: "renderAndSend", + }; +} + +function rejectStaticLayoutObservation( + entry: ClientReuseManifestEntry, + code: + | "SKIP_LAYOUT_CACHE_LIFE_OBSERVED" + | "SKIP_LAYOUT_CACHE_TAGS_OBSERVED" + | "SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED" + | "SKIP_LAYOUT_DYNAMIC_FETCHES_OBSERVED" + | "SKIP_LAYOUT_PARAMS_OBSERVED" + | "SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE" + | "SKIP_LAYOUT_PARAMS_PRESENT" + | "SKIP_LAYOUT_REVALIDATE_PRESENT" + | "SKIP_LAYOUT_REQUEST_API_OBSERVED" + | "SKIP_LAYOUT_UNSTABLE_CACHE_OBSERVED", + observation?: AppLayoutParamAccessObservation, +): ReturnType { + return { + kind: "rejected", + rejection: { + code, + entryId: entry.id, + fields: observation + ? { + cacheLifeObserved: observation.cacheLifeObserved, + cacheTags: observation.cacheTags, + cacheableFetchCount: observation.cacheableFetchCount, + dynamicFetchCount: observation.dynamicFetchCount, + finiteRevalidateSeconds: observation.finiteRevalidateSeconds, + observedParamKeys: observation.keys, + paramScopeKeys: observation.paramScopeKeys, + requestApis: observation.requestApis, + unstableCacheCount: observation.unstableCaches.length, + unstableCacheKeyHashes: observation.unstableCaches.map((cache) => cache.keyHash), + unstableCacheRevalidates: observation.unstableCaches.map((cache) => + String(cache.revalidate), + ), + unstableCacheTagCounts: observation.unstableCaches.map((cache) => + String(cache.tagCount), + ), + unstableCacheTagHashes: observation.unstableCaches.map( + (cache) => cache.tagHash ?? "none", + ), + } + : {}, + }, + skipDisposition: createRenderAndSendSkipDisposition(), + }; +} + +function rejectUnsafeStaticLayoutObservation( + entry: ClientReuseManifestEntry, + layoutParamAccess: AppLayoutParamAccessTracker | undefined, +): ReturnType | null { + const observation = layoutParamAccess?.getLayoutObservation(entry.id); + if (!observation || observation.completeness !== "complete") { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE"); + } + + if (observation.paramScopeKeys.length > 0) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_PARAMS_PRESENT", observation); + } + + if (observation.observed) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_PARAMS_OBSERVED", observation); + } + + if (observation.requestApis.length > 0) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_REQUEST_API_OBSERVED", observation); + } + + if (observation.finiteRevalidateSeconds !== null) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_REVALIDATE_PRESENT", observation); + } + + if (observation.cacheLifeObserved) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_CACHE_LIFE_OBSERVED", observation); + } + + if (observation.unstableCaches.length > 0) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_UNSTABLE_CACHE_OBSERVED", observation); + } + + if (observation.cacheTags.length > 0) { + return rejectStaticLayoutObservation(entry, "SKIP_LAYOUT_CACHE_TAGS_OBSERVED", observation); + } + + if (observation.cacheableFetchCount > 0) { + return rejectStaticLayoutObservation( + entry, + "SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED", + observation, + ); + } + + if (observation.dynamicFetchCount > 0) { + return rejectStaticLayoutObservation( + entry, + "SKIP_LAYOUT_DYNAMIC_FETCHES_OBSERVED", + observation, + ); + } + + return null; +} + +function createRenderLifecycleSkipDisposition(input: { + artifactCompatibility: ArtifactCompatibilityEnvelope | undefined; + cleanPathname: string; + clientReuseManifest: ClientReuseManifestParseResult | undefined; + element: ReactNode | Readonly>; + isRscRequest: boolean; + layoutFlags: Readonly>; + layoutParamAccess: AppLayoutParamAccessTracker | undefined; +}): ClientReuseManifestSkipDisposition | undefined { + if (!input.isRscRequest || input.clientReuseManifest === undefined) { + return undefined; + } + const clientReuseManifest = input.clientReuseManifest; + if (clientReuseManifest.kind !== "parsed" || clientReuseManifest.manifest.entries.length === 0) { + return undefined; + } + if (!isAppElementsRecord(input.element) || input.artifactCompatibility === undefined) { + return { + code: "SKIP_MODEL_DISABLED", + enabled: false, + mode: "renderAndSend", + }; + } + const element = input.element; + const artifactCompatibility = input.artifactCompatibility; + + const staticLayoutIds = new Set( + Object.entries(input.layoutFlags) + .filter(([, flag]) => flag === "s") + .map(([layoutId]) => layoutId), + ); + const plan = createClientReuseSkipTransportPlan({ + manifest: clientReuseManifest, + maxEntriesToVerify: STATIC_LAYOUT_SKIP_VERIFICATION_ENTRY_BUDGET, + verifyEntry(entry) { + if ( + entry.kind !== "layout" || + !staticLayoutIds.has(entry.id) || + AppElementsWire.parseElementKey(entry.id)?.kind !== "layout" + ) { + return crossCheckClientReuseManifestEntryWithCache({ + artifact: { + compatibility: artifactCompatibility, + invalidation: { kind: "unknown" }, + payloadHash: null, + }, + cacheDecision: null, + entry, + }); + } + + const currentOutput = createStaticLayoutOutputScope({ + artifactCompatibility, + element, + layoutId: entry.id, + }); + if (currentOutput === null) { + return crossCheckClientReuseManifestEntryWithCache({ + artifact: { + compatibility: artifactCompatibility, + invalidation: { kind: "unknown" }, + payloadHash: null, + }, + cacheDecision: null, + entry, + }); + } + const observationRejection = rejectUnsafeStaticLayoutObservation( + entry, + input.layoutParamAccess, + ); + if (observationRejection) { + return observationRejection; + } + const candidateRouteId = createStaticLayoutClientReuseRouteId(entry.id); + const candidateOutput: StaticLayoutCacheProofOutputScope = { + ...currentOutput, + routeId: candidateRouteId, + }; + + const candidateVariant = buildCacheVariantWithRouteBudget({ + budget: DEFAULT_CACHE_VARIANT_BUDGET, + dimensions: [], + output: candidateOutput, + routeBudget: { + routeId: candidateRouteId, + variantCacheKeys: [], + }, + }); + const skipArtifactCompatibility = + candidateVariant.kind === "variant" + ? createStaticLayoutClientReuseArtifactCompatibility({ + artifactCompatibility, + layoutId: entry.id, + rootBoundaryId: candidateOutput.rootBoundaryId, + routeId: candidateOutput.routeId, + variantCacheKey: candidateVariant.variant.cacheKey, + }) + : artifactCompatibility; + const cacheDecision = createStaticLayoutArtifactReuseDecision({ + candidateArtifactCompatibility: skipArtifactCompatibility, + // Static layout classification plus the per-layout observation gate + // above are the authority for this synthetic cache proof. Before a + // layout reaches this point, skip has already rejected param-scoped + // layouts, finite-revalidate segment configs, request API reads, + // cacheLife(), cache-tagged/cacheable fetches, and dynamic fetches. + candidateObservation: buildRenderObservation({ + boundaryOutcome: { kind: "success" }, + cacheability: "public", + cacheTags: [], + completeness: "complete", + dynamicFetches: [], + output: candidateOutput, + pathTags: [input.cleanPathname], + requestApis: buildRenderRequestApiObservations({ + completeness: "complete", + observed: input.layoutParamAccess?.getLayoutObservation(entry.id).requestApis ?? [], + }), + }), + candidateVariant, + currentArtifactCompatibility: skipArtifactCompatibility, + currentOutput, + }); + + return crossCheckClientReuseManifestEntryWithCache({ + artifact: { + compatibility: skipArtifactCompatibility, + invalidation: { kind: "valid" }, + payloadHash: + candidateVariant.kind === "variant" + ? createStaticLayoutClientReusePayloadHash({ + artifactCompatibility: skipArtifactCompatibility, + layoutId: entry.id, + rootBoundaryId: candidateOutput.rootBoundaryId, + routeId: candidateOutput.routeId, + variantCacheKey: candidateVariant.variant.cacheKey, + }) + : null, + }, + cacheDecision, + entry, + }); + }, + }); + + return plan.skipDisposition; +} + +function isSkipTransportEnabled( + skipDisposition: ClientReuseManifestSkipDisposition | undefined, +): boolean { + return skipDisposition?.enabled === true; +} + /** * Wraps an RSC response body to report invalid dynamic usage errors after the * stream is fully consumed. In dev mode, errors from cookies()/headers() inside @@ -349,11 +668,25 @@ export async function renderAppPageLifecycle( params: options.params, state: options.peekRenderObservationState?.() ?? createEmptyAppPageRenderObservationState(), }); + const skipDisposition = + options.skipDisposition ?? + createRenderLifecycleSkipDisposition({ + artifactCompatibility, + cleanPathname: options.cleanPathname, + clientReuseManifest: options.clientReuseManifest, + element: options.element, + isRscRequest: options.isRscRequest, + layoutFlags, + layoutParamAccess: options.layoutParamAccess, + }); + const shouldBypassRscCacheForSkipTransport = + options.isRscRequest && isSkipTransportEnabled(skipDisposition); const outgoingElement = AppElementsWire.encodeOutgoingPayload({ element: options.element, layoutFlags, ...(artifactCompatibility ? { artifactCompatibility } : {}), renderObservation: payloadRenderObservation, + skipDisposition: options.isRscRequest ? skipDisposition : undefined, }); const compileEnd = options.isProduction ? undefined : performance.now(); @@ -379,7 +712,8 @@ export async function renderAppPageLifecycle( (options.isProduction || options.isPrerender === true) && (revalidateSeconds === null || (revalidateSeconds > 0 && revalidateSeconds !== Infinity)) && !options.isDraftMode && - !options.isForceDynamic; + !options.isForceDynamic && + !shouldBypassRscCacheForSkipTransport; const rscCapture = teeAppPageRscStreamForCapture(rscStream, shouldCaptureRscForCacheMetadata); const rscForResponse = rscCapture.ssrStream; @@ -404,16 +738,24 @@ export async function renderAppPageLifecycle( } const dynamicUsedDuringBuild = options.consumeDynamicUsage(); - const rscResponsePolicy = resolveAppPageRscResponsePolicy({ - dynamicUsedDuringBuild, - isDraftMode: options.isDraftMode, - isDynamicError: options.isDynamicError, - isForceDynamic: options.isForceDynamic, - isForceStatic: options.isForceStatic, - isProduction: options.isProduction, - expireSeconds, - revalidateSeconds, - }); + // When skip transport is enabled, omit cacheState because the response is a + // per-client payload, not a shared-cache MISS/HIT artifact. The absence also + // keeps finalizeAppPageRscCacheResponse from overwriting no-store. + const rscResponsePolicy = shouldBypassRscCacheForSkipTransport + ? { cacheControl: NO_STORE_CACHE_CONTROL } + : resolveAppPageRscResponsePolicy({ + dynamicUsedDuringBuild, + isDraftMode: options.isDraftMode, + isDynamicError: options.isDynamicError, + isForceDynamic: options.isForceDynamic, + isForceStatic: options.isForceStatic, + isProduction: options.isProduction, + expireSeconds, + revalidateSeconds, + }); + if (shouldBypassRscCacheForSkipTransport) { + options.isrDebug?.("RSC cache write skipped (skip transport payload)", options.cleanPathname); + } const rscResponse = buildAppPageRscResponse(rscForResponse, { middlewareContext: options.middlewareContext, mountedSlotsHeader: options.mountedSlotsHeader, diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index 1c9c722f2..03d5abd91 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -2,6 +2,7 @@ import type { AppPageSpecialError } from "./app-page-execution.js"; import { runWithFetchDedupe } from "vinext/shims/fetch-cache"; import { getAppPageSegmentParamName } from "./app-page-params.js"; import { notFoundResponse } from "./http-error-responses.js"; +import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; type AppPageParams = Record; type GenerateStaticParams = (args: { params: AppPageParams }) => unknown; @@ -100,6 +101,7 @@ type ResolveAppPageInterceptOptions = { params: AppPageParams, interceptOpts: TInterceptOpts | undefined, searchParams: URLSearchParams, + layoutParamAccess?: AppLayoutParamAccessTracker, ) => Promise; cleanPathname: string; currentRoute: TRoute; @@ -107,6 +109,7 @@ type ResolveAppPageInterceptOptions = { getRouteParamNames: (route: TRoute) => readonly string[]; getSourceRoute: (sourceRouteIndex: number) => TRoute | undefined; isRscRequest: boolean; + layoutParamAccess?: AppLayoutParamAccessTracker; renderInterceptResponse: (route: TRoute, element: TElement) => Promise | Response; searchParams: URLSearchParams; setNavigationContext: (context: { @@ -396,6 +399,7 @@ export async function resolveAppPageIntercept; type AppPageErrorComponent = ComponentType<{ error: unknown; reset: () => void }>; +const APP_PAGE_LAYOUT_PROBE_CHILD = ; export type AppPageModule = Record & { default?: AppPageComponent | null | undefined; @@ -143,7 +151,8 @@ type BuildAppPageRouteElementOptions< > = { element: ReactNode; globalErrorModule?: TErrorModule | null; - makeThenableParams: (params: AppPageParams) => unknown; + layoutParamAccess?: AppLayoutParamAccessTracker; + makeThenableParams: MakeThenableParams; matchedParams: AppPageParams; resolvedMetadata: Metadata | null; resolvedMetadataPathname?: string; @@ -155,6 +164,8 @@ type BuildAppPageRouteElementOptions< slotOverrides?: Readonly>> | null; }; +type MakeThenableParams = (params: AppPageParams, observer?: ThenableParamsObserver) => unknown; + type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, @@ -202,6 +213,76 @@ export function createAppPageTreePath( return `/${treePathSegments.join("/")}`; } +function readFiniteRevalidateSeconds(module: AppPageModule | null | undefined): number | null { + const revalidate = module?.revalidate; + return typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0 + ? revalidate + : null; +} + +function recordLayoutSkipObservationScope(options: { + layoutId: string; + layoutModule: AppPageModule | null | undefined; + layoutParamAccess: AppLayoutParamAccessTracker | undefined; + routeSegments: readonly string[] | null | undefined; + treePosition: number; +}): void { + options.layoutParamAccess?.recordLayoutParamScope( + options.layoutId, + resolveAppPageSegmentParamScopeKeys(options.routeSegments, options.treePosition), + ); + const revalidateSeconds = readFiniteRevalidateSeconds(options.layoutModule); + if (revalidateSeconds !== null) { + options.layoutParamAccess?.recordLayoutFiniteRevalidate(options.layoutId, revalidateSeconds); + } +} + +export function probeAppPageLayoutWithTracking(options: { + layoutIndex: number; + layoutParamAccess: AppLayoutParamAccessTracker | undefined; + makeThenableParams: MakeThenableParams; + matchedParams: AppPageParams; + route: Pick< + AppPageRouteWiringRoute, + "layoutTreePositions" | "layouts" | "routeSegments" + >; +}): unknown { + const treePosition = options.route.layoutTreePositions?.[options.layoutIndex] ?? 0; + const treePath = createAppPageTreePath(options.route.routeSegments, treePosition); + const layoutId = AppElementsWire.encodeLayoutId(treePath); + const probe = () => { + const layoutModule = options.route.layouts[options.layoutIndex]; + const LayoutComponent = getDefaultExport(layoutModule); + if (!LayoutComponent) return null; + recordLayoutSkipObservationScope({ + layoutId, + layoutModule, + layoutParamAccess: options.layoutParamAccess, + routeSegments: options.route.routeSegments, + treePosition, + }); + const layoutParams = resolveAppPageSegmentParams( + options.route.routeSegments, + treePosition, + options.matchedParams, + ); + return probeReactServerSubtree( + + {APP_PAGE_LAYOUT_PROBE_CHILD} + , + ); + }; + + return options.layoutParamAccess + ? options.layoutParamAccess.runLayoutProbe(layoutId, probe) + : probe(); +} + export function createAppPageLayoutEntries< TModule extends AppPageModule, TErrorModule extends AppPageErrorModule, @@ -394,6 +475,7 @@ export function buildAppPageElements< layoutIndicesByTreePosition.set(layoutEntries[index].treePosition, index); } const layoutDependenciesByIndex = new Map(); + const renderDependenciesByElementId = new Map(); const layoutDependenciesBefore: AppRenderDependency[][] = []; const slotDependenciesByLayoutIndex: AppRenderDependency[][] = []; const templateDependenciesById = new Map(); @@ -450,6 +532,7 @@ export function buildAppPageElements< if (getDefaultExport(layoutEntry.layoutModule)) { const layoutDependency = createAppRenderDependency(); layoutDependenciesByIndex.set(layoutIndex, layoutDependency); + renderDependenciesByElementId.set(layoutEntry.id, layoutDependency); pageDependencies.push(layoutDependency); } slotDependenciesByLayoutIndex[layoutIndex] = [...pageDependencies]; @@ -512,14 +595,23 @@ export function buildAppPageElements< if (!layoutComponent) { continue; } + const layoutParams = resolveAppPageSegmentParams( + options.route.routeSegments, + layoutEntry.treePosition, + options.matchedParams, + ); + recordLayoutSkipObservationScope({ + layoutId: layoutEntry.id, + layoutModule: layoutEntry.layoutModule, + layoutParamAccess: options.layoutParamAccess, + routeSegments: options.route.routeSegments, + treePosition: layoutEntry.treePosition, + }); const layoutProps: Record = { params: options.makeThenableParams( - resolveAppPageSegmentParams( - options.route.routeSegments, - layoutEntry.treePosition, - options.matchedParams, - ), + layoutParams, + options.layoutParamAccess?.createThenableParamsObserver(layoutEntry.id), ), }; @@ -869,5 +961,6 @@ export function buildAppPageElements< ); + registerAppElementRenderDependencies(elements, renderDependenciesByElementId); return elements; } diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index 4476043ff..b4b996063 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -5,6 +5,26 @@ export type AppRenderDependency = { release: () => void; }; +const appElementRenderDependencies = new WeakMap< + Readonly>, + ReadonlyMap +>(); + +export function registerAppElementRenderDependencies( + elements: Readonly>, + dependenciesByElementId: ReadonlyMap, +): void { + if (dependenciesByElementId.size === 0) return; + appElementRenderDependencies.set(elements, dependenciesByElementId); +} + +export function releaseAppElementRenderDependency( + elements: Readonly>, + elementId: string, +): void { + appElementRenderDependencies.get(elements)?.get(elementId)?.release(); +} + export function createAppRenderDependency(): AppRenderDependency { let released = false; let resolve!: () => void; diff --git a/packages/vinext/src/server/app-rsc-cache-busting.ts b/packages/vinext/src/server/app-rsc-cache-busting.ts index 35bb6db0e..3ac84d27b 100644 --- a/packages/vinext/src/server/app-rsc-cache-busting.ts +++ b/packages/vinext/src/server/app-rsc-cache-busting.ts @@ -10,6 +10,7 @@ import { NEXT_ROUTER_STATE_TREE_HEADER, NEXT_URL_HEADER, RSC_HEADER, + VINEXT_CLIENT_REUSE_MANIFEST_HEADER, VINEXT_INTERCEPTION_CONTEXT_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, VINEXT_RSC_RENDER_MODE_HEADER, @@ -44,6 +45,7 @@ const CACHE_BUSTING_DIGEST_BYTES = 12; const textEncoder = new TextEncoder(); type CreateRscRequestHeadersOptions = { + clientReuseManifestHeader?: string | null; interceptionContext?: string | null; mountedSlotsHeader?: string | null; renderMode?: AppRscRenderMode; @@ -274,6 +276,13 @@ export function createRscRequestHeaders(options: CreateRscRequestHeadersOptions headers.set(VINEXT_MOUNTED_SLOTS_HEADER, options.mountedSlotsHeader); } + if ( + options.clientReuseManifestHeader !== undefined && + options.clientReuseManifestHeader !== null + ) { + headers.set(VINEXT_CLIENT_REUSE_MANIFEST_HEADER, options.clientReuseManifestHeader); + } + const renderMode = options.renderMode ?? APP_RSC_RENDER_MODE_NAVIGATION; if (renderMode !== APP_RSC_RENDER_MODE_NAVIGATION) { headers.set(VINEXT_RSC_RENDER_MODE_HEADER, renderMode); diff --git a/packages/vinext/src/server/app-rsc-handler.ts b/packages/vinext/src/server/app-rsc-handler.ts index 28779be66..1996882a2 100644 --- a/packages/vinext/src/server/app-rsc-handler.ts +++ b/packages/vinext/src/server/app-rsc-handler.ts @@ -50,6 +50,7 @@ import type { MiddlewareModule } from "./middleware-runtime.js"; import { runWithPrerenderWorkUnit } from "./prerender-work-unit-setup.js"; import { buildPostMwRequestContext } from "./app-post-middleware-context.js"; import type { AppRscRenderMode } from "./app-rsc-render-mode.js"; +import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import { cloneRequestWithHeaders, filterInternalHeaders, @@ -85,6 +86,7 @@ type AppRscRouteMatch = { }; type DispatchMatchedPageOptions = { + clientReuseManifest: ClientReuseManifestParseResult; cleanPathname: string; formState: ReactFormState | null; actionError?: unknown; @@ -313,8 +315,14 @@ async function handleAppRscRequest( const normalized = normalizeRscRequest(request, options.basePath); if (normalized instanceof Response) return normalized; - const { url, isRscRequest, interceptionContextHeader, mountedSlotsHeader, renderMode } = - normalized; + const { + url, + isRscRequest, + interceptionContextHeader, + mountedSlotsHeader, + renderMode, + clientReuseManifest, + } = normalized; let { pathname, cleanPathname } = normalized; // Canonical (external) pathname the user requested. Middleware rewrites and // next.config.js rewrites mutate `cleanPathname` so internal route matching @@ -605,6 +613,7 @@ async function handleAppRscRequest( } return options.dispatchMatchedPage({ + clientReuseManifest, cleanPathname, formState, actionError, diff --git a/packages/vinext/src/server/app-rsc-request-normalization.ts b/packages/vinext/src/server/app-rsc-request-normalization.ts index 5bbc3b274..e2b69fa9b 100644 --- a/packages/vinext/src/server/app-rsc-request-normalization.ts +++ b/packages/vinext/src/server/app-rsc-request-normalization.ts @@ -38,7 +38,7 @@ export type NormalizedRscRequest = { mountedSlotsHeader: string | null; /** Semantic RSC payload mode. HTML requests always normalize to "navigation". */ renderMode: AppRscRenderMode; - /** Disabled ClientReuseManifest hint. Never authorizes skip transport in this stage. */ + /** Parsed ClientReuseManifest hint. Verification and skip authorization happen later. */ clientReuseManifest: ClientReuseManifestParseResult; }; @@ -66,7 +66,7 @@ export type NormalizedRscRequest = { * 8. Sanitize X-Vinext-Interception-Context — strip null bytes (header injection) * 9. Normalize x-vinext-mounted-slots — dedup and sort for canonical cache keys * 10. Read semantic render mode for refresh/action payload rendering - * 11. Parse disabled ClientReuseManifest hints on canonical RSC payload requests + * 11. Parse ClientReuseManifest hints on canonical RSC payload requests * * @returns A 400 or 404 Response for invalid or out-of-scope inputs, * or a NormalizedRscRequest for valid requests. diff --git a/packages/vinext/src/server/client-reuse-manifest.ts b/packages/vinext/src/server/client-reuse-manifest.ts index 687c4cda1..8a52934da 100644 --- a/packages/vinext/src/server/client-reuse-manifest.ts +++ b/packages/vinext/src/server/client-reuse-manifest.ts @@ -28,6 +28,11 @@ export const DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS = { maxVariantCacheKeyLength: 256, } satisfies ClientReuseManifestLimits; +// Producer cap for normal browser manifests. The parser accepts a larger +// hostile-input envelope, but browser-produced manifests should stay within +// the server skip planner's verification budget. +export const CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET = 8; + export type ClientReuseManifestEntryKind = "layout" | "page" | "route" | "slot" | "template"; type ClientReuseManifestEntryPrivacy = "private" | "public"; @@ -90,6 +95,16 @@ export type ClientReuseManifestRejectionCode = | "SKIP_CACHE_PROOF_REJECTED" | "SKIP_CACHE_REUSE_CLASS_UNSUPPORTED" | "SKIP_CACHE_VARIANT_MISMATCH" + | "SKIP_LAYOUT_CACHE_LIFE_OBSERVED" + | "SKIP_LAYOUT_CACHE_TAGS_OBSERVED" + | "SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED" + | "SKIP_LAYOUT_DYNAMIC_FETCHES_OBSERVED" + | "SKIP_LAYOUT_PARAMS_OBSERVED" + | "SKIP_LAYOUT_PARAMS_OBSERVATION_INCOMPLETE" + | "SKIP_LAYOUT_PARAMS_PRESENT" + | "SKIP_LAYOUT_REVALIDATE_PRESENT" + | "SKIP_LAYOUT_REQUEST_API_OBSERVED" + | "SKIP_LAYOUT_UNSTABLE_CACHE_OBSERVED" | "SKIP_ARTIFACT_COMPATIBILITY_INVALID" | "SKIP_ENTRY_COUNT_EXCEEDED" | "SKIP_ENTRY_HASH_INVALID" @@ -109,8 +124,6 @@ export type ClientReuseManifestRejectionCode = | "SKIP_VISIBLE_COMMIT_VERSION_INVALID" | "SKIP_VISIBLE_COMMIT_VERSION_MISMATCH"; -export type ClientReuseManifestDispositionCode = "SKIP_MODEL_DISABLED"; - export type ClientReuseManifestTraceFieldValue = | string | number @@ -132,11 +145,18 @@ export type ClientReuseManifestEntryRejection = ClientReuseManifestRejection & entryId: string | null; }>; -export type ClientReuseManifestSkipDisposition = Readonly<{ - code: ClientReuseManifestDispositionCode; - enabled: false; - mode: "renderAndSend"; -}>; +export type ClientReuseManifestSkipDisposition = + | Readonly<{ + code: "SKIP_MODEL_DISABLED"; + enabled: false; + mode: "renderAndSend"; + }> + | Readonly<{ + code: "SKIP_STATIC_LAYOUT_VERIFIED"; + enabled: true; + mode: "skipStaticLayout"; + skippedEntryIds: readonly string[]; + }>; export type ClientReuseManifestParseResult = | Readonly<{ kind: "absent" }> @@ -204,8 +224,8 @@ function createCanonicalWireEntries( return Array.from(entriesById.values()).sort(compareManifestEntries); } -// The manifest byte budget is enforced once at the untrusted header boundary. -function countUtf8Bytes(input: string): number { +// Manifest byte budgets are enforced over UTF-8 encoded header values. +export function countUtf8Bytes(input: string): number { return textEncoder.encode(input).length; } diff --git a/packages/vinext/src/server/skip-cache-proof.ts b/packages/vinext/src/server/skip-cache-proof.ts index a17324478..0adad19d8 100644 --- a/packages/vinext/src/server/skip-cache-proof.ts +++ b/packages/vinext/src/server/skip-cache-proof.ts @@ -5,12 +5,22 @@ import { } from "./artifact-compatibility.js"; import type { StaticLayoutArtifactReuseDecision } from "./cache-proof.js"; import { + CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET, createClientReusePayloadHash, type ClientReuseManifestEntry, type ClientReuseManifestEntryRejection, + type ClientReuseManifestParseResult, + type ClientReuseManifestRejection, type ClientReuseManifestSkipDisposition, type ClientReuseManifestTraceFields, } from "./client-reuse-manifest.js"; +import { ARTIFACT_COMPATIBILITY_PROOF_FIELDS } from "./static-layout-client-reuse-proof.js"; + +export { + createStaticLayoutClientReuseArtifactCompatibility, + createStaticLayoutClientReusePayloadHash, + createStaticLayoutClientReuseRouteId, +} from "./static-layout-client-reuse-proof.js"; export type SkipCacheInvalidationProof = | Readonly<{ kind: "invalidated"; invalidationEpoch: string | null }> @@ -48,15 +58,29 @@ type CrossCheckClientReuseManifestEntryWithCacheInput = Readonly<{ }> & ArtifactCompatibilityEvaluationOptions; -const ARTIFACT_COMPATIBILITY_PROOF_FIELDS: readonly (keyof ArtifactCompatibilityEnvelope)[] = [ - "schemaVersion", - "graphVersion", - "deploymentVersion", - "appElementsSchemaVersion", - "rscPayloadSchemaVersion", - "rootBoundaryId", - "renderEpoch", -]; +type ClientReuseSkipTransportPlan = + | Readonly<{ + entryRejections: readonly ClientReuseManifestEntryRejection[]; + kind: "renderAndSend"; + manifestRejection?: ClientReuseManifestRejection; + skipDisposition: ClientReuseManifestSkipDisposition; + skippedEntryIds: readonly string[]; + }> + | Readonly<{ + entryRejections: readonly ClientReuseManifestEntryRejection[]; + kind: "skip"; + skipDisposition: ClientReuseManifestSkipDisposition; + skippedEntryIds: readonly string[]; + }>; + +type CreateClientReuseSkipTransportPlanInput = Readonly<{ + manifest: ClientReuseManifestParseResult; + maxEntriesToVerify?: number; + verifyEntry: (entry: ClientReuseManifestEntry) => SkipCacheCrossCheckResult; +}>; + +export const STATIC_LAYOUT_SKIP_VERIFICATION_ENTRY_BUDGET = + CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET; function createDisabledSkipDisposition(): ClientReuseManifestSkipDisposition { return { @@ -66,6 +90,17 @@ function createDisabledSkipDisposition(): ClientReuseManifestSkipDisposition { }; } +function createStaticLayoutSkipDisposition( + skippedEntryIds: readonly string[], +): ClientReuseManifestSkipDisposition { + return { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: [...skippedEntryIds], + }; +} + function rejectSkipCacheCrossCheck( entry: ClientReuseManifestEntry, code: ClientReuseManifestEntryRejection["code"], @@ -95,10 +130,43 @@ function collectArtifactCompatibilityProofMismatches( return mismatchedFields; } +function isExactArtifactCompatibility( + artifactCompatibility: ArtifactCompatibilityEnvelope, + entryCompatibility: ArtifactCompatibilityEnvelope, +): boolean { + return ( + collectArtifactCompatibilityProofMismatches(artifactCompatibility, entryCompatibility) + .length === 0 + ); +} + function assertNever(value: never): never { throw new Error(`Unhandled skip/cache proof state: ${String(value)}`); } +function createRenderAndSendPlan(options: { + entryRejections?: readonly ClientReuseManifestEntryRejection[]; + manifestRejection?: ClientReuseManifestRejection; +}): ClientReuseSkipTransportPlan { + return { + kind: "renderAndSend", + entryRejections: options.entryRejections ?? [], + ...(options.manifestRejection ? { manifestRejection: options.manifestRejection } : {}), + skipDisposition: createDisabledSkipDisposition(), + skippedEntryIds: [], + }; +} + +function createEntryCountExceededRejection( + entryCount: number, + maxEntryCount: number, +): ClientReuseManifestRejection { + return { + code: "SKIP_ENTRY_COUNT_EXCEEDED", + fields: { entryCount, maxEntryCount }, + }; +} + function crossCheckInvalidationProof( entry: ClientReuseManifestEntry, invalidation: SkipCacheInvalidationProof, @@ -195,6 +263,12 @@ export function crossCheckClientReuseManifestEntryWithCache( const invalidationRejection = crossCheckInvalidationProof(entry, input.artifact.invalidation); if (invalidationRejection) return invalidationRejection; + const skipDisposition = isExactArtifactCompatibility( + input.artifact.compatibility, + entry.artifactCompatibility, + ) + ? createStaticLayoutSkipDisposition([entry.id]) + : createDisabledSkipDisposition(); return { kind: "verified", @@ -205,6 +279,60 @@ export function crossCheckClientReuseManifestEntryWithCache( reuseClass: proof.reuseClass, variantCacheKeyHash: createClientReusePayloadHash(proof.variant.cacheKey), }, - skipDisposition: createDisabledSkipDisposition(), + skipDisposition, + }; +} + +export function createClientReuseSkipTransportPlan( + input: CreateClientReuseSkipTransportPlanInput, +): ClientReuseSkipTransportPlan { + const { manifest } = input; + if (manifest.kind === "absent") { + return createRenderAndSendPlan({}); + } + if (manifest.kind === "rejected") { + return createRenderAndSendPlan({ manifestRejection: manifest.rejection }); + } + + const maxEntriesToVerify = input.maxEntriesToVerify; + if ( + maxEntriesToVerify !== undefined && + (!Number.isSafeInteger(maxEntriesToVerify) || maxEntriesToVerify < 0) + ) { + throw new RangeError("maxEntriesToVerify must be a non-negative safe integer"); + } + + if (maxEntriesToVerify !== undefined && manifest.manifest.entries.length > maxEntriesToVerify) { + return createRenderAndSendPlan({ + entryRejections: manifest.entryRejections, + manifestRejection: createEntryCountExceededRejection( + manifest.manifest.entries.length, + maxEntriesToVerify, + ), + }); + } + + const skippedEntryIds: string[] = []; + const entryRejections: ClientReuseManifestEntryRejection[] = [...manifest.entryRejections]; + for (const entry of manifest.manifest.entries) { + const verification = input.verifyEntry(entry); + if (verification.kind === "rejected") { + entryRejections.push(verification.rejection); + continue; + } + if (verification.skipDisposition.enabled) { + skippedEntryIds.push(entry.id); + } + } + + if (skippedEntryIds.length === 0) { + return createRenderAndSendPlan({ entryRejections }); + } + + return { + kind: "skip", + entryRejections, + skipDisposition: createStaticLayoutSkipDisposition(skippedEntryIds), + skippedEntryIds, }; } diff --git a/packages/vinext/src/server/static-layout-client-reuse-proof.ts b/packages/vinext/src/server/static-layout-client-reuse-proof.ts new file mode 100644 index 000000000..876ab1c32 --- /dev/null +++ b/packages/vinext/src/server/static-layout-client-reuse-proof.ts @@ -0,0 +1,70 @@ +import type { ArtifactCompatibilityEnvelope } from "./artifact-compatibility.js"; +import { createClientReusePayloadHash } from "./client-reuse-manifest.js"; + +type StaticLayoutClientReuseProofInput = Readonly<{ + artifactCompatibility: ArtifactCompatibilityEnvelope; + layoutId: string; + rootBoundaryId: string | null; + routeId: string; + variantCacheKey: string; +}>; + +export const ARTIFACT_COMPATIBILITY_PROOF_FIELDS: readonly (keyof ArtifactCompatibilityEnvelope)[] = + [ + "schemaVersion", + "graphVersion", + "deploymentVersion", + "appElementsSchemaVersion", + "rscPayloadSchemaVersion", + "rootBoundaryId", + "renderEpoch", + ]; + +export function createStaticLayoutClientReuseRouteId(layoutId: string): string { + return `static-layout:${createClientReusePayloadHash(layoutId)}`; +} + +export function createStaticLayoutClientReusePayloadHash( + input: StaticLayoutClientReuseProofInput, +): string { + const artifactCompatibility: Record = {}; + for (const field of ARTIFACT_COMPATIBILITY_PROOF_FIELDS) { + artifactCompatibility[field] = input.artifactCompatibility[field]; + } + + return createClientReusePayloadHash( + JSON.stringify({ + artifactCompatibility, + layoutId: input.layoutId, + rootBoundaryId: input.rootBoundaryId, + routeId: input.routeId, + variantCacheKey: input.variantCacheKey, + }), + ); +} + +// The app-route payload envelope is route-scoped and may not carry a render epoch +// during this wave. Static-layout skip needs layout-scoped compatibility so +// sibling-route navigation can reuse a retained static layout without treating +// the source route id as part of the retained artifact identity. +export function createStaticLayoutClientReuseArtifactCompatibility( + input: StaticLayoutClientReuseProofInput, +): ArtifactCompatibilityEnvelope { + return { + ...input.artifactCompatibility, + graphVersion: `static-layout-graph:${createClientReusePayloadHash( + JSON.stringify({ + layoutId: input.layoutId, + rootBoundaryId: input.rootBoundaryId, + }), + )}`, + renderEpoch: `static-layout:${createClientReusePayloadHash( + JSON.stringify({ + layoutId: input.layoutId, + rootBoundaryId: input.rootBoundaryId, + routeId: input.routeId, + variantCacheKey: input.variantCacheKey, + }), + )}`, + }; +} diff --git a/packages/vinext/src/shims/cache.ts b/packages/vinext/src/shims/cache.ts index 2e2f560ee..f4d2d4e65 100644 --- a/packages/vinext/src/shims/cache.ts +++ b/packages/vinext/src/shims/cache.ts @@ -561,10 +561,18 @@ let _unstableIoWarned = false; // --------------------------------------------------------------------------- export type UnstableCacheRevalidationMode = "foreground" | "background"; export type ActionRevalidationKind = 0 | 1 | 2; +export type UnstableCacheObservation = Readonly<{ + kind: "unstable_cache"; + keyHash: string; + revalidate: number | false | null; + tagCount: number; + tagHash: string | null; +}>; export type CacheState = { actionRevalidationKind: ActionRevalidationKind; requestScopedCacheLife: CacheLifeConfig | null; + unstableCacheObservations: Map; unstableCacheRevalidation: UnstableCacheRevalidationMode; }; @@ -575,6 +583,7 @@ const _cacheAls = getOrCreateAls("vinext.cache.als"); const _cacheFallbackState = (_g[_FALLBACK_KEY] ??= { actionRevalidationKind: 0, requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", } satisfies CacheState) as CacheState; @@ -602,12 +611,14 @@ export function _runWithCacheState(fn: () => T | Promise): T | Promise return runWithUnifiedStateMutation((uCtx) => { uCtx.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; uCtx.requestScopedCacheLife = null; + uCtx.unstableCacheObservations = new Map(); uCtx.unstableCacheRevalidation = "foreground"; }, fn); } const state: CacheState = { actionRevalidationKind: ACTION_DID_NOT_REVALIDATE, requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", }; return _cacheAls.run(state, fn); @@ -622,6 +633,7 @@ export function _initRequestScopedCacheState(): void { const state = _getCacheState(); state.actionRevalidationKind = ACTION_DID_NOT_REVALIDATE; state.requestScopedCacheLife = null; + state.unstableCacheObservations = new Map(); } function markActionRevalidation(kind: ActionRevalidationKind): void { @@ -697,6 +709,16 @@ export function _consumeRequestScopedCacheLife(): CacheLifeConfig | null { return config; } +function recordUnstableCacheObservation(observation: UnstableCacheObservation): void { + _getCacheState().unstableCacheObservations.set(observation.keyHash, observation); +} + +export function _peekUnstableCacheObservations(): UnstableCacheObservation[] { + return [..._getCacheState().unstableCacheObservations.values()].sort((a, b) => + a.keyHash.localeCompare(b.keyHash), + ); +} + // --------------------------------------------------------------------------- // cacheLife / cacheTag — Next.js 15+ "use cache" APIs // --------------------------------------------------------------------------- @@ -1034,6 +1056,18 @@ export function unstable_cache Promise>( const cachedFn = async (...args: Parameters) => { const argsKey = JSON.stringify(args); const cacheKey = `unstable_cache:${baseKey}:${argsKey}`; + recordUnstableCacheObservation({ + kind: "unstable_cache", + keyHash: fnv1a64(cacheKey), + revalidate: + typeof revalidateSeconds === "number" + ? revalidateSeconds + : revalidateSeconds === false + ? false + : null, + tagCount: tags.length, + tagHash: tags.length > 0 ? fnv1a64(JSON.stringify(tags)) : null, + }); // Try to get from cache. Stale entries are usable in normal App Router // requests, but foreground-refresh inside revalidation scopes so the diff --git a/packages/vinext/src/shims/fetch-cache.ts b/packages/vinext/src/shims/fetch-cache.ts index 9a6fa0cbc..f74264171 100644 --- a/packages/vinext/src/shims/fetch-cache.ts +++ b/packages/vinext/src/shims/fetch-cache.ts @@ -474,6 +474,7 @@ const originalFetch: typeof globalThis.fetch = (_gFetch[_ORIG_FETCH_KEY] ??= // multi-environment module instances. // --------------------------------------------------------------------------- export type FetchCacheState = { + cacheableFetchUrls: Set; currentRequestTags: string[]; currentFetchSoftTags: string[]; currentFetchCacheMode: FetchCacheMode | null; @@ -508,6 +509,7 @@ if (globalThis.FinalizationRegistry) { } const _fallbackState = (_g[_FALLBACK_KEY] ??= { + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, @@ -528,6 +530,7 @@ function _getState(): FetchCacheState { * in single-threaded contexts where ALS.run() isn't used. */ function _resetFallbackState(isFetchDedupeActive: boolean): void { + _fallbackState.cacheableFetchUrls = new Set(); _fallbackState.currentRequestTags = []; _fallbackState.currentFetchSoftTags = []; _fallbackState.currentFetchCacheMode = null; @@ -544,6 +547,14 @@ function recordDynamicFetchObservation(input: string | URL | Request): void { _getState().dynamicFetchUrls.add(getFetchObservationUrl(input)); } +function recordCacheableFetchObservation(input: string | URL | Request): void { + _getState().cacheableFetchUrls.add(getFetchObservationUrl(input)); +} + +export function peekCacheableFetchObservations(): string[] { + return [..._getState().cacheableFetchUrls].sort(); +} + export function peekDynamicFetchObservations(): string[] { return [..._getState().dynamicFetchUrls].sort(); } @@ -913,6 +924,7 @@ function createPatchedFetch(): typeof globalThis.fetch { } throw err; } + recordCacheableFetchObservation(input); const handler = getCacheHandler(); // Collect tags for this render pass @@ -1145,6 +1157,7 @@ export async function runWithFetchCache(fn: () => Promise): Promise { _ensurePatchInstalled(); if (isInsideUnifiedScope()) { return await runWithUnifiedStateMutation((uCtx) => { + uCtx.cacheableFetchUrls = new Set(); uCtx.currentRequestTags = []; uCtx.currentFetchSoftTags = []; uCtx.dynamicFetchUrls = new Set(); @@ -1154,6 +1167,7 @@ export async function runWithFetchCache(fn: () => Promise): Promise { } return _als.run( { + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index 7a4297b54..25b9f9ec6 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -2,6 +2,7 @@ import * as React from "react"; import { + APP_SKIPPED_LAYOUT_IDS_KEY, AppElementsWire, UNMATCHED_SLOT, type AppElementValue, @@ -73,6 +74,14 @@ function isSlotBindingListValue(value: unknown): value is readonly AppElementsSl return Array.isArray(value) && value.length > 0 && value.every(isSlotBindingValue); } +function isSkippedLayoutIdsMetadataValue(id: string, value: unknown): value is readonly string[] { + return ( + id === APP_SKIPPED_LAYOUT_IDS_KEY && + Array.isArray(value) && + value.every((entry) => typeof entry === "string") + ); +} + function isInterceptionMetadataValue(value: unknown): value is AppElementsInterception { if (typeof value !== "object" || value === null || Array.isArray(value)) return false; return ( @@ -95,18 +104,21 @@ function isCacheEntryReuseProofValue(value: unknown): value is CacheEntryReusePr } function isTransportMetadataValue( + id: string, value: AppElementValue | undefined, ): value is | LayoutFlags | ArtifactCompatibilityEnvelope | CacheEntryReuseProof | AppElementsInterception + | readonly string[] | readonly AppElementsSlotBinding[] { return ( isLayoutFlagsValue(value) || isArtifactCompatibilityEnvelopeValue(value) || isCacheEntryReuseProofValue(value) || isInterceptionMetadataValue(value) || + isSkippedLayoutIdsMetadataValue(id, value) || isSlotBindingListValue(value) ); } @@ -203,7 +215,7 @@ export function Slot({ } const element = elements[id]; - if (isTransportMetadataValue(element)) { + if (isTransportMetadataValue(id, element)) { warnTransportMetadataEntry(id); return null; } diff --git a/packages/vinext/src/shims/thenable-params.ts b/packages/vinext/src/shims/thenable-params.ts index 1baba4d27..3955d7971 100644 --- a/packages/vinext/src/shims/thenable-params.ts +++ b/packages/vinext/src/shims/thenable-params.ts @@ -62,7 +62,42 @@ function isWellKnownProperty(prop: PropertyKey): boolean { export type ThenableParams> = Promise & Omit; -export function makeThenableParams>(obj: T): ThenableParams { +export type ThenableParamsObserver = Readonly<{ + observeParamAccess: (keys: readonly string[]) => void; +}>; + +function observeParamKeys( + observer: ThenableParamsObserver | undefined, + keys: readonly string[], +): void { + if (observer) { + observer.observeParamAccess(keys); + } +} + +function observeAllParamKeys>( + observer: ThenableParamsObserver | undefined, + plain: T, +): void { + observeParamKeys(observer, Object.keys(plain)); +} + +function observeReadableParamKeys>( + observer: ThenableParamsObserver | undefined, + plain: T, +): void { + const keys = Object.keys(plain).filter((key) => !isWellKnownProperty(key)); + observeParamKeys(observer, keys); +} + +function isPromiseContinuation(prop: PropertyKey): boolean { + return prop === "then" || prop === "catch" || prop === "finally"; +} + +export function makeThenableParams>( + obj: T, + observer?: ThenableParamsObserver, +): ThenableParams { const plain = { ...obj }; const promise = Promise.resolve(plain); @@ -72,6 +107,19 @@ export function makeThenableParams>(obj: T): T // the boundary so the handler above stays fully type-checked. return new Proxy(promise, { get(target, prop, receiver) { + if (isPromiseContinuation(prop)) { + const value = Reflect.get(target, prop, receiver); + if (typeof value !== "function") return value; + return (...args: unknown[]) => { + observeAllParamKeys(observer, plain); + return Reflect.apply(value, target, args); + }; + } + + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { return Reflect.get(plain, prop); } @@ -80,6 +128,10 @@ export function makeThenableParams>(obj: T): T return typeof value === "function" ? value.bind(target) : value; }, getOwnPropertyDescriptor(target, prop) { + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + if (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) { return { configurable: true, @@ -92,11 +144,16 @@ export function makeThenableParams>(obj: T): T return Reflect.getOwnPropertyDescriptor(target, prop); }, has(target, prop) { + if (typeof prop === "string" && !isWellKnownProperty(prop)) { + observeParamKeys(observer, [prop]); + } + return ( Reflect.has(target, prop) || (!isWellKnownProperty(prop) && hasParamProperty(plain, prop)) ); }, ownKeys() { + observeReadableParamKeys(observer, plain); return Reflect.ownKeys(plain).filter((prop) => !isWellKnownProperty(prop)); }, }) as unknown as ThenableParams; diff --git a/packages/vinext/src/shims/unified-request-context.ts b/packages/vinext/src/shims/unified-request-context.ts index 6032a3b77..572ea483a 100644 --- a/packages/vinext/src/shims/unified-request-context.ts +++ b/packages/vinext/src/shims/unified-request-context.ts @@ -96,8 +96,10 @@ export function createRequestContext(opts?: Partial): Uni serverContext: null, serverInsertedHTMLCallbacks: [], requestScopedCacheLife: null, + unstableCacheObservations: new Map(), unstableCacheRevalidation: "foreground", _privateCache: null, + cacheableFetchUrls: new Set(), currentRequestTags: [], currentFetchSoftTags: [], currentFetchCacheMode: null, @@ -160,7 +162,8 @@ export function runWithUnifiedStateMutation( const childCtx = { ...parentCtx }; // NOTE: This is a shallow clone. Array fields (pendingSetCookies, // serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), Set - // fields (renderRequestApiUsage, dynamicFetchUrls), the _privateCache Map, + // fields (renderRequestApiUsage, cacheableFetchUrls, dynamicFetchUrls), + // Map fields (unstableCacheObservations, _privateCache), // requestCache WeakMap, and object fields (headersContext, // i18nContext, serverContext, ssrContext, executionContext, // requestScopedCacheLife) still share references with the parent until diff --git a/tests/app-browser-client-reuse-manifest.test.ts b/tests/app-browser-client-reuse-manifest.test.ts new file mode 100644 index 000000000..f5ffc3cc9 --- /dev/null +++ b/tests/app-browser-client-reuse-manifest.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createArtifactCompatibilityEnvelope } from "../packages/vinext/src/server/artifact-compatibility.js"; +import { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js"; +import { AppElementsWire } from "../packages/vinext/src/server/app-elements.js"; +import { + CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET, + DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + parseClientReuseManifestHeader, +} from "../packages/vinext/src/server/client-reuse-manifest.js"; + +function createVisibleState(input: { + extraEntries?: Record; + graphVersion?: string; + layoutFlags: Record; + layoutIds: readonly string[]; + routeId?: string; + visibleCommitVersion?: number; +}) { + const artifactCompatibility = createArtifactCompatibilityEnvelope({ + deploymentVersion: "deploy:test", + graphVersion: input.graphVersion ?? "graph:test", + rootBoundaryId: "/", + }); + return { + elements: { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds: input.layoutIds, + rootLayoutTreePath: "/", + routeId: input.routeId ?? "route:/current", + }), + [AppElementsWire.keys.artifactCompatibility]: artifactCompatibility, + [AppElementsWire.keys.layoutFlags]: input.layoutFlags, + ...input.extraEntries, + }, + visibleCommitVersion: input.visibleCommitVersion ?? 4, + }; +} + +describe("app browser client reuse manifest", () => { + it("builds a public manifest only for retained static layout entries", () => { + const header = createClientReuseManifestHeaderFromVisibleAppState( + createVisibleState({ + extraEntries: { + "layout:/": "root layout", + "layout:/dynamic": "dynamic layout", + "page:/current": "page", + }, + layoutFlags: { + "layout:/": "s", + "layout:/dynamic": "d", + "layout:/missing": "s", + }, + layoutIds: ["layout:/", "layout:/dynamic", "layout:/missing"], + }), + ); + + const parsed = parseClientReuseManifestHeader(header); + + expect(parsed.kind).toBe("parsed"); + if (parsed.kind !== "parsed") { + throw new Error("Expected client reuse manifest to parse"); + } + expect(parsed.manifest.visibleCommitVersion).toBe(4); + expect(parsed.manifest.replayWindow).toEqual({ + validFromVisibleCommitVersion: 4, + validUntilVisibleCommitVersion: 4, + }); + expect(parsed.manifest.entries.map((entry) => entry.id)).toEqual(["layout:/"]); + expect(parsed.manifest.entries[0]).toMatchObject({ + kind: "layout", + privacy: "public", + }); + }); + + it("keeps retained static layout proofs stable across source routes", () => { + function readLayoutEntry(routeId: string, graphVersion: string) { + const header = createClientReuseManifestHeaderFromVisibleAppState( + createVisibleState({ + extraEntries: { "layout:/dashboard": "dashboard layout" }, + graphVersion, + layoutFlags: { "layout:/dashboard": "s" }, + layoutIds: ["layout:/dashboard"], + routeId, + }), + ); + const parsed = parseClientReuseManifestHeader(header); + expect(parsed.kind).toBe("parsed"); + if (parsed.kind !== "parsed") { + throw new Error("Expected client reuse manifest to parse"); + } + return parsed.manifest.entries[0]; + } + + const settingsEntry = readLayoutEntry("route:/dashboard/settings", "graph:settings"); + const profileEntry = readLayoutEntry("route:/dashboard/profile", "graph:profile"); + + expect(settingsEntry).toEqual(profileEntry); + }); + + it("trims entries rather than emitting an oversized manifest header", () => { + const layoutIds = Array.from({ length: 12 }, (_, index) => `layout:/section-${index}`); + const header = createClientReuseManifestHeaderFromVisibleAppState( + createVisibleState({ + extraEntries: Object.fromEntries(layoutIds.map((layoutId) => [layoutId, layoutId])), + layoutFlags: Object.fromEntries(layoutIds.map((layoutId) => [layoutId, "s"])), + layoutIds, + }), + { + limits: { + ...DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + maxManifestBytes: 900, + }, + }, + ); + + expect(header).not.toBeNull(); + expect(new TextEncoder().encode(header!).length).toBeLessThanOrEqual(900); + + const parsed = parseClientReuseManifestHeader(header, { + limits: { + ...DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + maxManifestBytes: 900, + }, + }); + expect(parsed.kind).toBe("parsed"); + if (parsed.kind !== "parsed") { + throw new Error("Expected trimmed client reuse manifest to parse"); + } + expect(parsed.manifest.entries.length).toBeGreaterThan(0); + expect(parsed.manifest.entries.length).toBeLessThan(layoutIds.length); + }); + + it("caps default browser manifests to the server skip verification budget", () => { + const layoutIds = Array.from( + { length: CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET + 4 }, + (_, index) => `layout:/section-${index}`, + ); + const header = createClientReuseManifestHeaderFromVisibleAppState( + createVisibleState({ + extraEntries: Object.fromEntries(layoutIds.map((layoutId) => [layoutId, layoutId])), + layoutFlags: Object.fromEntries(layoutIds.map((layoutId) => [layoutId, "s"])), + layoutIds, + }), + { + limits: { + ...DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + maxManifestBytes: 16_000, + }, + }, + ); + + const parsed = parseClientReuseManifestHeader(header, { + limits: { + ...DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + maxManifestBytes: 16_000, + }, + }); + + expect(parsed.kind).toBe("parsed"); + if (parsed.kind !== "parsed") { + throw new Error("Expected capped client reuse manifest to parse"); + } + expect(parsed.manifest.entries.map((entry) => entry.id)).toEqual( + layoutIds.slice(0, CLIENT_REUSE_MANIFEST_SKIP_VERIFICATION_ENTRY_BUDGET), + ); + }); +}); diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 8edbf8b2a..5bf3976fb 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -25,10 +25,12 @@ import { devOnUncaughtError, } from "../packages/vinext/src/server/dev-error-overlay.js"; import { + APP_CACHE_ENTRY_REUSE_PROOF_KEY, AppElementsWire, APP_LAYOUT_FLAGS_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, + APP_SKIPPED_LAYOUT_IDS_KEY, UNMATCHED_SLOT, getMountedSlotIds, getMountedSlotIdsHeader, @@ -68,6 +70,7 @@ import { NavigationTraceTransactionCodes, createNavigationTrace, } from "../packages/vinext/src/server/navigation-trace.js"; +import { createCacheEntryReuseProof } from "../packages/vinext/src/server/cache-proof.js"; import { ACTION_REVALIDATED_HEADER, VINEXT_MOUNTED_SLOTS_HEADER, @@ -1329,6 +1332,23 @@ describe("app browser entry state helpers", () => { expect(isCacheRestorableAppPayloadMetadata(AppElementsWire.readMetadata(elements))).toBe(false); }); + it("does not classify skip-pruned payload metadata as cache-restorable", () => { + const layoutId = AppElementsWire.encodeLayoutId("/"); + const elements = createResolvedElements( + "route:/dashboard/settings", + "/", + null, + { + [APP_CACHE_ENTRY_REUSE_PROOF_KEY]: createCacheEntryReuseProof(null), + [APP_SKIPPED_LAYOUT_IDS_KEY]: [layoutId], + "page:/dashboard/settings": React.createElement("main", null, "settings"), + }, + [layoutId], + ); + + expect(isCacheRestorableAppPayloadMetadata(AppElementsWire.readMetadata(elements))).toBe(false); + }); + it("traces unknown root-layout identity without preserving absent slots", async () => { const decision = await resolveTestPendingNavigationCommitDispositionDecision({ activeNavigationId: 2, @@ -3605,6 +3625,70 @@ describe("app browser entry previousNextUrl helpers", () => { expect(nextState.layoutIds).toEqual([]); }); + it("preserves explicitly skipped retained layouts on approved navigate commits", async () => { + const rootLayout = React.createElement("div", null, "root layout"); + const staleLayout = React.createElement("div", null, "stale layout"); + const currentState = createState({ + elements: createResolvedElements( + "route:/dashboard", + "/", + null, + { + "layout:/": rootLayout, + "layout:/stale": staleLayout, + }, + ["layout:/"], + ), + layoutFlags: { + "layout:/": "s", + "layout:/stale": "s", + }, + layoutIds: ["layout:/"], + }); + const pending = await createPendingNavigationCommit({ + currentState, + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/settings", {}), + nextElements: Promise.resolve( + createResolvedElements( + "route:/settings", + "/", + null, + { + [APP_LAYOUT_FLAGS_KEY]: {}, + [APP_SKIPPED_LAYOUT_IDS_KEY]: ["layout:/", "layout:/stale"], + "page:/settings": React.createElement("main", null, "settings"), + }, + ["layout:/"], + ), + ), + operationLane: "navigation", + payloadOrigin: FRESH_APP_NAVIGATION_PAYLOAD_ORIGIN, + renderId: 1, + type: "navigate", + }); + const approval = approvePendingNavigationCommit({ + activeNavigationId: 1, + currentState, + pending, + routeManifest: null, + startedNavigationId: 1, + targetHref: "https://example.com/settings", + }); + + expect(approval.approvedCommit).not.toBeNull(); + if (approval.approvedCommit === null) return; + + expect(approval.decision.preserveElementIds).toEqual(["layout:/"]); + + const nextState = applyApprovedVisibleCommit(currentState, approval.approvedCommit); + + expect(nextState.elements["layout:/"]).toBe(rootLayout); + expect(Object.hasOwn(nextState.elements, "layout:/stale")).toBe(false); + expect(nextState.layoutFlags).toEqual({ + "layout:/": "s", + }); + }); + it("clears stale parallel slots on approved traverse commits", async () => { const state = createState({ elements: createResolvedElements("route:/feed", "/", null, { diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index d561c6eab..cb7811194 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -14,6 +14,7 @@ import { APP_RENDER_OBSERVATION_KEY, APP_ROOT_LAYOUT_KEY, APP_ROUTE_KEY, + APP_SKIPPED_LAYOUT_IDS_KEY, APP_SLOT_BINDINGS_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, buildOutgoingAppPayload, @@ -80,6 +81,7 @@ describe("AppElementsWire", () => { layoutFlags: {}, rootLayoutTreePath: "/", routeId: "route:/photos/42\0/feed", + skippedLayoutIds: [], slotBindings: [], }); }); @@ -297,6 +299,7 @@ describe("AppElementsWire", () => { layoutFlags: { [AppElementsWire.encodeLayoutId("/")]: "s" }, rootLayoutTreePath: "/", routeId: "route:/dashboard", + skippedLayoutIds: [], slotBindings: [], }); }); @@ -552,6 +555,36 @@ describe("app elements payload helpers", () => { ).toThrow("[vinext] Invalid __layoutIds in App Router payload: expected layout id string[]"); }); + it.each([ + { + label: "non-array", + value: "layout:/dashboard", + message: + "[vinext] Invalid __skippedLayoutIds in App Router payload: expected layout id string[]", + }, + { + label: "non-string", + value: ["layout:/", 1], + message: + "[vinext] Invalid __skippedLayoutIds in App Router payload: expected layout id string[]", + }, + { + label: "non-layout id", + value: ["page:/dashboard"], + message: "[vinext] Invalid __skippedLayoutIds in App Router payload: expected layout ids", + }, + ])("rejects invalid skipped layout metadata: $label", ({ message, value }) => { + expect(() => + readAppElementsMetadata({ + ...normalizeAppElements({ + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: "route:/dashboard", + }), + [APP_SKIPPED_LAYOUT_IDS_KEY]: value, + }), + ).toThrow(message); + }); + it.each([ { label: "non-array", @@ -900,7 +933,7 @@ describe("buildOutgoingAppPayload", () => { } }); - it("returns canonical record keys regardless of any upstream skip intent", () => { + it("returns canonical record keys when no skip disposition is supplied", () => { const result = buildOutgoingAppPayload({ element: { "layout:/": "root-layout", "page:/": "page" }, layoutFlags: { "layout:/": "s" }, @@ -912,6 +945,40 @@ describe("buildOutgoingAppPayload", () => { } }); + it("omits only proven layout entries when static-layout skip transport is enabled", () => { + const result = buildOutgoingAppPayload({ + element: { + [APP_ROUTE_KEY]: "route:/dashboard", + [APP_ROOT_LAYOUT_KEY]: "/", + "layout:/": "root-layout", + "layout:/dashboard": "dashboard-layout", + "page:/dashboard": "dashboard-page", + }, + layoutFlags: { "layout:/": "s", "layout:/dashboard": "s" }, + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/dashboard", "page:/dashboard"], + }, + }); + + expect(isAppElementsRecord(result)).toBe(true); + if (isAppElementsRecord(result)) { + expect(result["layout:/"]).toBe("root-layout"); + expect(Object.hasOwn(result, "layout:/dashboard")).toBe(false); + expect(result["page:/dashboard"]).toBe("dashboard-page"); + expect(result[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/dashboard": "s", + }); + expect(result[APP_ROUTE_KEY]).toBe("route:/dashboard"); + expect(result[APP_ROOT_LAYOUT_KEY]).toBe("/"); + expect(result[APP_SKIPPED_LAYOUT_IDS_KEY]).toEqual(["layout:/dashboard"]); + expect(AppElementsWire.readMetadata(result).skippedLayoutIds).toEqual(["layout:/dashboard"]); + } + }); + it("preserves non-layout metadata keys", () => { const result = buildOutgoingAppPayload({ element: { diff --git a/tests/app-layout-param-observation.test.ts b/tests/app-layout-param-observation.test.ts new file mode 100644 index 000000000..b0f57e638 --- /dev/null +++ b/tests/app-layout-param-observation.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vite-plus/test"; +import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js"; +import { + cacheLife, + MemoryCacheHandler, + setCacheHandler, + unstable_cache, +} from "../packages/vinext/src/shims/cache.js"; +import { + createRequestContext, + getRequestContext, + runWithRequestContext, +} from "../packages/vinext/src/shims/unified-request-context.js"; +import { markRenderRequestApiUsage } from "../packages/vinext/src/shims/headers.js"; + +describe("app layout param observation", () => { + it("isolates fetch and cacheLife observations to the current layout probe", async () => { + const tracker = createAppLayoutParamAccessTracker(); + + await runWithRequestContext(createRequestContext(), async () => { + await tracker.runLayoutProbe("layout:/dashboard/settings", () => { + const ctx = getRequestContext(); + ctx.currentRequestTags.push("shared-tag"); + ctx.cacheableFetchUrls.add("https://example.com/settings"); + ctx.dynamicFetchUrls.add("https://example.com/settings-dynamic"); + cacheLife("seconds"); + markRenderRequestApiUsage("headers"); + }); + + await tracker.runLayoutProbe("layout:/dashboard", () => null); + + await tracker.runLayoutProbe("layout:/dashboard/profile", () => { + const ctx = getRequestContext(); + ctx.currentRequestTags.push("shared-tag"); + ctx.cacheableFetchUrls.add("https://example.com/profile"); + markRenderRequestApiUsage("cookies"); + }); + }); + + expect(tracker.getLayoutObservation("layout:/dashboard/settings")).toMatchObject({ + cacheLifeObserved: true, + cacheTags: ["shared-tag"], + cacheableFetchCount: 1, + dynamicFetchCount: 1, + requestApis: ["headers"], + }); + expect(tracker.getLayoutObservation("layout:/dashboard")).toMatchObject({ + cacheLifeObserved: false, + cacheTags: [], + cacheableFetchCount: 0, + dynamicFetchCount: 0, + requestApis: [], + }); + expect(tracker.getLayoutObservation("layout:/dashboard/profile")).toMatchObject({ + cacheLifeObserved: false, + cacheTags: ["shared-tag"], + cacheableFetchCount: 1, + dynamicFetchCount: 0, + requestApis: ["cookies"], + }); + }); + + it("records unstable_cache dependencies on cache miss and hit", async () => { + setCacheHandler(new MemoryCacheHandler()); + const tracker = createAppLayoutParamAccessTracker(); + let calls = 0; + const cached = unstable_cache( + async () => { + calls += 1; + return `banner-${calls}`; + }, + ["layout-banner"], + { tags: ["banner"], revalidate: 60 }, + ); + + await runWithRequestContext(createRequestContext(), async () => { + await tracker.runLayoutProbe("layout:/miss", () => cached()); + await tracker.runLayoutProbe("layout:/hit", () => cached()); + }); + + expect(calls).toBe(1); + expect(tracker.getLayoutObservation("layout:/miss")).toMatchObject({ + unstableCaches: [ + { + kind: "unstable_cache", + revalidate: 60, + tagCount: 1, + }, + ], + }); + expect(tracker.getLayoutObservation("layout:/hit")).toMatchObject({ + unstableCaches: [ + { + kind: "unstable_cache", + revalidate: 60, + tagCount: 1, + }, + ], + }); + }); +}); diff --git a/tests/app-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index dd1fbd5aa..348880b15 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1,16 +1,55 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + APP_LAYOUT_FLAGS_KEY, + APP_ROOT_LAYOUT_KEY, + APP_ROUTE_KEY, + AppElementsWire, +} from "../packages/vinext/src/server/app-elements.js"; import { dispatchAppPage } from "../packages/vinext/src/server/app-page-dispatch.js"; +import { createClientReuseManifestHeaderFromVisibleAppState } from "../packages/vinext/src/server/app-browser-client-reuse-manifest.js"; +import type { AppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js"; +import { probeReactServerSubtree } from "../packages/vinext/src/server/app-page-probe.js"; +import { + resolveAppPageSegmentParamScopeKeys, + resolveAppPageSegmentParams, +} from "../packages/vinext/src/server/app-page-params.js"; +import { createAppPageTreePath } from "../packages/vinext/src/server/app-page-route-wiring.js"; +import { + createArtifactCompatibilityEnvelope, + createArtifactCompatibilityGraphVersion, +} from "../packages/vinext/src/server/artifact-compatibility.js"; +import { + parseClientReuseManifestHeader, + type ClientReuseManifestParseResult, +} from "../packages/vinext/src/server/client-reuse-manifest.js"; +import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; import type { AppPageMiddlewareContext } from "../packages/vinext/src/server/app-page-response.js"; import type { ISRCacheEntry } from "../packages/vinext/src/server/isr-cache.js"; -import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; +import { + MemoryCacheHandler, + setCacheHandler, + unstable_cache, + type CachedAppPageValue, +} from "../packages/vinext/src/shims/cache.js"; +import { + runWithExecutionContext, + type ExecutionContextLike, +} from "../packages/vinext/src/shims/request-context.js"; +import { + createRequestContext, + getRequestContext, + runWithRequestContext, +} from "../packages/vinext/src/shims/unified-request-context.js"; +import { markRenderRequestApiUsage } from "../packages/vinext/src/shims/headers.js"; type TestRoute = { + __buildTimeClassifications?: ReadonlyMap | null; error?: { default?: unknown } | null; errors?: readonly ({ default?: unknown } | null | undefined)[]; forbiddens?: readonly ({ default?: unknown } | null | undefined)[]; isDynamic: boolean; - layouts: readonly { default?: unknown }[]; + layouts: readonly { default?: unknown; dynamic?: unknown; revalidate?: unknown }[]; layoutTreePositions?: readonly number[]; loading?: { default?: unknown } | null; notFounds?: readonly ({ default?: unknown } | null | undefined)[]; @@ -32,6 +71,13 @@ function createStream(chunks: string[]): ReadableStream { }); } +function captureRecord(value: unknown): Record { + if (value && typeof value === "object" && !React.isValidElement(value) && !Array.isArray(value)) { + return value as Record; + } + throw new Error("Expected AppElements record payload"); +} + function buildISRCacheEntry(value: CachedAppPageValue, isStale = false): ISRCacheEntry { return { isStale, @@ -80,7 +126,11 @@ function createDispatchOptions( isProduction?: boolean; isRscRequest?: boolean; isrGet?: DispatchOptions["isrGet"]; + clientReuseManifest?: ClientReuseManifestParseResult; + cleanPathname?: string; middlewareContext?: AppPageMiddlewareContext; + params?: Record; + probeLayoutAt?: DispatchOptions["probeLayoutAt"]; renderToReadableStream?: DispatchOptions["renderToReadableStream"]; request?: Request; revalidateSeconds?: number | null; @@ -94,12 +144,13 @@ function createDispatchOptions( overrides.buildPageElement ?? (async () => React.createElement("main", null, "page")); const clearRequestContext = overrides.clearRequestContext ?? (() => {}); const isrGet = overrides.isrGet ?? (async () => null); + const params = overrides.params ?? { slug: "hello" }; const setNavigationContext = overrides.setNavigationContext ?? (() => {}); const renderToReadableStream: DispatchOptions["renderToReadableStream"] = overrides.renderToReadableStream ?? (() => createStream(["flight"])); const options: DispatchOptions = { buildPageElement, - cleanPathname: "/posts/hello", + cleanPathname: overrides.cleanPathname ?? "/posts/hello", clearRequestContext, createRscOnErrorHandler() { return () => null; @@ -149,14 +200,13 @@ function createDispatchOptions( }, }; }, + clientReuseManifest: overrides.clientReuseManifest ?? { kind: "absent" }, middlewareContext: overrides.middlewareContext ?? { headers: null, status: null, }, - params: { slug: "hello" }, - probeLayoutAt() { - return null; - }, + params, + probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []), probePage() { return null; }, @@ -184,6 +234,233 @@ function createDispatchOptions( }; } +function createVerifiedStaticLayoutManifest(input: { + deploymentVersion: string; + layoutId?: string; + layoutIds?: readonly string[]; + rootBoundaryId: string; + routeId: string; + routePattern: string; +}): ClientReuseManifestParseResult { + const layoutIds = input.layoutIds ?? (input.layoutId ? [input.layoutId] : []); + if (layoutIds.length === 0) { + throw new Error("Expected at least one static layout manifest entry"); + } + const artifactCompatibility = createArtifactCompatibilityEnvelope({ + deploymentVersion: input.deploymentVersion, + graphVersion: createArtifactCompatibilityGraphVersion({ + routePattern: input.routePattern, + rootBoundaryId: input.rootBoundaryId, + }), + rootBoundaryId: input.rootBoundaryId, + }); + const retainedLayouts = Object.fromEntries( + layoutIds.map((layoutId) => [layoutId, `retained-${layoutId}`]), + ); + const layoutFlags: Record = {}; + for (const layoutId of layoutIds) { + layoutFlags[layoutId] = "s"; + } + const header = createClientReuseManifestHeaderFromVisibleAppState({ + elements: { + ...AppElementsWire.createMetadataEntries({ + interceptionContext: null, + layoutIds, + rootLayoutTreePath: input.rootBoundaryId, + routeId: input.routeId, + }), + [AppElementsWire.keys.artifactCompatibility]: artifactCompatibility, + [AppElementsWire.keys.layoutFlags]: layoutFlags, + ...retainedLayouts, + }, + visibleCommitVersion: 1, + }); + if (header === null) { + throw new Error("Expected retained static layout manifest"); + } + return parseClientReuseManifestHeader(header); +} + +type LayoutParamProbeReader = (params: unknown) => unknown; + +const paramAccessCases: readonly [string, LayoutParamProbeReader][] = [ + ["direct params.slug access", (params) => Reflect.get(Object(params), "slug")], + [ + "await params access", + async (params) => { + await params; + }, + ], + [ + "destructured params access", + (params) => { + const { slug } = Object(params) as { slug?: string }; + return slug; + }, + ], +]; + +function createLayoutParamProbe( + route: TestRoute, + matchedParams: Record, + readers: readonly (LayoutParamProbeReader | null | undefined)[], +): DispatchOptions["probeLayoutAt"] { + return (layoutIndex, layoutParamAccess) => { + const treePath = createAppPageTreePath( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ); + const layoutId = AppElementsWire.encodeLayoutId(treePath); + const runProbe = (tracker: AppLayoutParamAccessTracker | undefined) => { + const segmentParams = resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + matchedParams, + ); + tracker?.recordLayoutParamScope( + layoutId, + resolveAppPageSegmentParamScopeKeys( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ), + ); + const revalidate = route.layouts[layoutIndex]?.revalidate; + if (typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0) { + tracker?.recordLayoutFiniteRevalidate(layoutId, revalidate); + } + const params = makeThenableParams( + segmentParams, + tracker?.createThenableParamsObserver(layoutId), + ); + return readers[layoutIndex]?.(params) ?? null; + }; + + return layoutParamAccess + ? layoutParamAccess.runLayoutProbe(layoutId, () => runProbe(layoutParamAccess)) + : runProbe(undefined); + }; +} + +function createCacheTagLayoutProbe( + route: TestRoute, + matchedParams: Record, + cacheTag: string, + options: { wrapDataChild?: "forwardRef" | "lazy" | "memo" } = {}, +): DispatchOptions["probeLayoutAt"] { + return (layoutIndex, layoutParamAccess) => { + const treePath = createAppPageTreePath( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ); + const layoutId = AppElementsWire.encodeLayoutId(treePath); + const runProbe = (tracker: AppLayoutParamAccessTracker | undefined) => { + tracker?.recordLayoutParamScope( + layoutId, + resolveAppPageSegmentParamScopeKeys( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ), + ); + const revalidate = route.layouts[layoutIndex]?.revalidate; + if (typeof revalidate === "number" && Number.isFinite(revalidate) && revalidate > 0) { + tracker?.recordLayoutFiniteRevalidate(layoutId, revalidate); + } + resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + matchedParams, + ); + function DataChild() { + getRequestContext().currentRequestTags.push(cacheTag); + return null; + } + const RenderedDataChild = + options.wrapDataChild === "memo" + ? React.memo(DataChild) + : options.wrapDataChild === "forwardRef" + ? React.forwardRef(function ForwardRefDataChild() { + return React.createElement(DataChild); + }) + : options.wrapDataChild === "lazy" + ? React.lazy(() => Promise.resolve({ default: DataChild })) + : DataChild; + function Layout() { + return React.createElement("section", null, React.createElement(RenderedDataChild)); + } + return probeReactServerSubtree(React.createElement(Layout)); + }; + + return layoutParamAccess + ? layoutParamAccess.runLayoutProbe(layoutId, () => runProbe(layoutParamAccess)) + : runProbe(undefined); + }; +} + +function createRequestApiLayoutProbe( + route: TestRoute, + matchedParams: Record, +): DispatchOptions["probeLayoutAt"] { + return (layoutIndex, layoutParamAccess) => { + const treePath = createAppPageTreePath( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ); + const layoutId = AppElementsWire.encodeLayoutId(treePath); + const runProbe = (tracker: AppLayoutParamAccessTracker | undefined) => { + tracker?.recordLayoutParamScope( + layoutId, + resolveAppPageSegmentParamScopeKeys( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ), + ); + resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + matchedParams, + ); + markRenderRequestApiUsage("headers"); + return null; + }; + + return layoutParamAccess + ? layoutParamAccess.runLayoutProbe(layoutId, () => runProbe(layoutParamAccess)) + : runProbe(undefined); + }; +} + +function createUnstableCacheLayoutProbe( + route: TestRoute, + readCachedLayoutData: () => Promise, +): DispatchOptions["probeLayoutAt"] { + return (layoutIndex, layoutParamAccess) => { + const treePath = createAppPageTreePath( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ); + const layoutId = AppElementsWire.encodeLayoutId(treePath); + const runProbe = (tracker: AppLayoutParamAccessTracker | undefined) => { + tracker?.recordLayoutParamScope( + layoutId, + resolveAppPageSegmentParamScopeKeys( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + ), + ); + resolveAppPageSegmentParams( + route.routeSegments, + route.layoutTreePositions?.[layoutIndex] ?? 0, + {}, + ); + return readCachedLayoutData(); + }; + + return layoutParamAccess + ? layoutParamAccess.runLayoutProbe(layoutId, () => runProbe(layoutParamAccess)) + : runProbe(undefined); + }; +} + describe("app page dispatch", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -359,6 +636,901 @@ describe("app page dispatch", () => { expect(response.headers.get("allow")).toBe("GET, HEAD"); }); + it("uses a verified client reuse manifest to omit static layouts only from RSC transport", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const sourceRouteId = "route:/dashboard/settings"; + const sourceRoutePattern = "/dashboard/settings"; + const targetRouteId = "route:/dashboard/profile"; + const targetRoutePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const element = { + [APP_ROUTE_KEY]: targetRouteId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "root-layout", + [pageId]: "profile-page", + }; + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: targetRoutePattern, + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId: sourceRouteId, + routePattern: sourceRoutePattern, + }); + const capturedRscPayloads: Record[] = []; + const capturedHtmlPayloads: Record[] = []; + const waitUntilPromises: Promise[] = []; + const executionContext = { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + } satisfies ExecutionContextLike; + + try { + const { options: rscOptions } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + revalidateSeconds: 60, + renderToReadableStream(payload) { + capturedRscPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const rscResponse = await runWithExecutionContext(executionContext, () => + dispatchAppPage(rscOptions), + ); + + expect(rscResponse.status).toBe(200); + expect(rscResponse.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(rscResponse.headers.get("x-vinext-cache")).toBeNull(); + expect(waitUntilPromises).toHaveLength(0); + expect(capturedRscPayloads).toHaveLength(1); + expect(Object.hasOwn(capturedRscPayloads[0], layoutId)).toBe(false); + expect(capturedRscPayloads[0][pageId]).toBe("profile-page"); + + const capturedDynamicPayloads: Record[] = []; + const { options: dynamicOptions } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + renderToReadableStream(payload) { + capturedDynamicPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route: createRoute({ + __buildTimeClassifications: new Map([[0, "dynamic"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: targetRoutePattern, + }), + }); + + const dynamicResponse = await dispatchAppPage(dynamicOptions); + + expect(dynamicResponse.status).toBe(200); + expect(capturedDynamicPayloads).toHaveLength(1); + expect(capturedDynamicPayloads[0][layoutId]).toBe("root-layout"); + expect(capturedDynamicPayloads[0][pageId]).toBe("profile-page"); + + const { options: htmlOptions } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: false, + renderToReadableStream(payload) { + capturedHtmlPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const htmlResponse = await dispatchAppPage(htmlOptions); + + expect(htmlResponse.status).toBe(200); + expect(capturedHtmlPayloads).toHaveLength(1); + expect(capturedHtmlPayloads[0][layoutId]).toBe("root-layout"); + expect(capturedHtmlPayloads[0][pageId]).toBe("profile-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it.each(paramAccessCases)( + "does not skip a static dynamic-segment layout after %s", + async (_name, readParams) => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const sourceRouteId = "route:/blog/hello-world"; + const targetRouteId = "route:/blog/getting-started"; + const routePattern = "/blog/[slug]"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/blog/[slug]"); + const pageId = AppElementsWire.encodePageId("/blog/getting-started", null); + const params = { slug: "getting-started" }; + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [2], + layouts: [{ default() {} }], + params: ["slug"], + pattern: routePattern, + routeSegments: ["blog", "[slug]"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId: sourceRouteId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: targetRouteId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "blog-slug-layout", + [pageId]: "post-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + cleanPathname: "/blog/getting-started", + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params, + probeLayoutAt: createLayoutParamProbe(route, params, [readParams]), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("blog-slug-layout"); + expect(capturedPayloads[0][pageId]).toBe("post-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }, + ); + + it("does not skip a static dynamic-segment layout with unobserved non-empty params", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const sourceRouteId = "route:/acme/settings"; + const targetRouteId = "route:/globex/settings"; + const routePattern = "/[team]/settings"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/[team]"); + const pageId = AppElementsWire.encodePageId("/globex/settings", null); + const params = { team: "globex" }; + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [1], + layouts: [{ default() {} }], + params: ["team"], + pattern: routePattern, + routeSegments: ["[team]", "settings"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId: sourceRouteId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: targetRouteId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "team-layout", + [pageId]: "settings-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + cleanPathname: "/globex/settings", + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params, + probeLayoutAt: createLayoutParamProbe(route, params, [null]), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("team-layout"); + expect(capturedPayloads[0][pageId]).toBe("settings-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("does not skip a static layout whose probe observes cache-tagged data", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "dashboard-layout", + [pageId]: "profile-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params: {}, + probeLayoutAt: createCacheTagLayoutProbe(route, {}, "tag:dashboard-layout"), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithRequestContext(createRequestContext(), () => + dispatchAppPage(options), + ); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("dashboard-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("does not skip a static layout whose probe observes request APIs", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "tenant-layout", + [pageId]: "profile-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params: {}, + probeLayoutAt: createRequestApiLayoutProbe(route, {}), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithRequestContext(createRequestContext(), () => + dispatchAppPage(options), + ); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("tenant-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + expect(capturedPayloads[0][APP_LAYOUT_FLAGS_KEY]).toEqual({ + [layoutId]: "d", + }); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("does not skip a static layout whose probe observes unstable_cache data", async () => { + setCacheHandler(new MemoryCacheHandler()); + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "banner-layout", + [pageId]: "profile-page", + }; + let calls = 0; + const readCachedLayoutData = unstable_cache( + async () => { + calls += 1; + return `banner-${calls}`; + }, + ["dashboard-banner"], + { tags: ["dashboard-banner"], revalidate: 60 }, + ); + await runWithRequestContext(createRequestContext(), () => readCachedLayoutData()); + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params: {}, + probeLayoutAt: createUnstableCacheLayoutProbe(route, readCachedLayoutData), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithRequestContext(createRequestContext(), () => + dispatchAppPage(options), + ); + + expect(calls).toBe(1); + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("banner-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + expect(capturedPayloads[0][APP_LAYOUT_FLAGS_KEY]).toEqual({ + [layoutId]: "d", + }); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + setCacheHandler(new MemoryCacheHandler()); + } + }); + + it("marks runtime-probed request-api layouts dynamic in payload metadata", async () => { + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "tenant-layout", + [pageId]: "profile-page", + }; + const capturedPayloads: Record[] = []; + + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + isProduction: true, + isRscRequest: true, + params: {}, + probeLayoutAt: createRequestApiLayoutProbe(route, {}), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithRequestContext(createRequestContext(), () => + dispatchAppPage(options), + ); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("tenant-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + expect(capturedPayloads[0][APP_LAYOUT_FLAGS_KEY]).toEqual({ + [layoutId]: "d", + }); + }); + + it.each(["memo", "forwardRef", "lazy"] as const)( + "does not skip a static layout whose %s child observes cache-tagged data", + async (wrapDataChild) => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "dashboard-layout", + [pageId]: "profile-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params: {}, + probeLayoutAt: createCacheTagLayoutProbe(route, {}, "tag:dashboard-layout", { + wrapDataChild, + }), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithRequestContext(createRequestContext(), () => + dispatchAppPage(options), + ); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("dashboard-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }, + ); + + it("does not skip a static optional-catchall layout when the target params are empty", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const sourceRouteId = "route:/docs/a"; + const targetRouteId = "route:/docs"; + const routePattern = "/docs/[[...slug]]"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/docs/[[...slug]]"); + const pageId = AppElementsWire.encodePageId("/docs", null); + const params = { slug: [] }; + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [2], + layouts: [{ default() {} }], + params: ["slug"], + pattern: routePattern, + routeSegments: ["docs", "[[...slug]]"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId: sourceRouteId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: targetRouteId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "docs-index-layout", + [pageId]: "docs-index-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + cleanPathname: "/docs", + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params, + probeLayoutAt: createLayoutParamProbe(route, params, [null]), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("docs-index-layout"); + expect(capturedPayloads[0][pageId]).toBe("docs-index-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("keeps param scope isolated across nested static layouts and sibling dynamic values", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const sourceRouteId = "route:/acme/settings"; + const targetRouteId = "route:/globex/settings"; + const routePattern = "/[team]/settings"; + const rootBoundaryId = "/"; + const rootLayoutId = AppElementsWire.encodeLayoutId("/"); + const teamLayoutId = AppElementsWire.encodeLayoutId("/[team]"); + const settingsLayoutId = AppElementsWire.encodeLayoutId("/[team]/settings"); + const pageId = AppElementsWire.encodePageId("/globex/settings", null); + const params = { team: "globex" }; + const route = createRoute({ + __buildTimeClassifications: new Map([ + [0, "static"], + [1, "static"], + [2, "static"], + ]), + layoutTreePositions: [0, 1, 2], + layouts: [{ default() {} }, { default() {} }, { default() {} }], + params: ["team"], + pattern: routePattern, + routeSegments: ["[team]", "settings"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutIds: [rootLayoutId, teamLayoutId, settingsLayoutId], + rootBoundaryId, + routeId: sourceRouteId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: targetRouteId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [rootLayoutId]: "root-layout", + [teamLayoutId]: "team-layout", + [settingsLayoutId]: "settings-layout", + [pageId]: "settings-page", + }; + const capturedPayloads: Record[] = []; + const waitUntilPromises: Promise[] = []; + const executionContext = { + waitUntil(promise) { + waitUntilPromises.push(promise); + }, + } satisfies ExecutionContextLike; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + cleanPathname: "/globex/settings", + clientReuseManifest, + isProduction: true, + isRscRequest: true, + params, + probeLayoutAt: createLayoutParamProbe(route, params, [ + null, + (layoutParams) => Reflect.get(Object(layoutParams), "team"), + null, + ]), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await runWithExecutionContext(executionContext, () => + dispatchAppPage(options), + ); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(waitUntilPromises).toHaveLength(0); + expect(capturedPayloads).toHaveLength(1); + expect(Object.hasOwn(capturedPayloads[0], rootLayoutId)).toBe(false); + expect(capturedPayloads[0][teamLayoutId]).toBe("team-layout"); + expect(capturedPayloads[0][settingsLayoutId]).toBe("settings-layout"); + expect(capturedPayloads[0][pageId]).toBe("settings-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("falls back to render-and-send when static-layout param observation is incomplete", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {} }], + pattern: routePattern, + routeSegments: ["dashboard", "profile"], + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "root-layout", + [pageId]: "profile-page", + }; + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + probeLayoutAt() { + return null; + }, + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("root-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("does not skip layouts marked dynamic by runtime segment config", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/posts/hello"; + const routePattern = "/posts/[slug]"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/posts/hello", null); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "root-layout", + [pageId]: "post-page", + }; + const route = createRoute({ + layoutTreePositions: [0], + layouts: [{ default() {}, dynamic: "force-dynamic" }], + pattern: routePattern, + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: false, + isRscRequest: true, + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("root-layout"); + expect(capturedPayloads[0][pageId]).toBe("post-page"); + expect(capturedPayloads[0][APP_LAYOUT_FLAGS_KEY]).toEqual({ + [layoutId]: "d", + }); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + + it("does not skip static layouts with finite revalidate segment config", async () => { + const originalBuildId = process.env.__VINEXT_BUILD_ID; + process.env.__VINEXT_BUILD_ID = "deploy-test"; + const routeId = "route:/dashboard/profile"; + const routePattern = "/dashboard/profile"; + const rootBoundaryId = "/"; + const layoutId = AppElementsWire.encodeLayoutId("/"); + const pageId = AppElementsWire.encodePageId("/dashboard/profile", null); + const element = { + [APP_ROUTE_KEY]: routeId, + [APP_ROOT_LAYOUT_KEY]: rootBoundaryId, + [layoutId]: "revalidating-root-layout", + [pageId]: "profile-page", + }; + const route = createRoute({ + __buildTimeClassifications: new Map([[0, "static"]]), + layoutTreePositions: [0], + layouts: [{ default() {}, revalidate: 60 }], + pattern: routePattern, + }); + const clientReuseManifest = createVerifiedStaticLayoutManifest({ + deploymentVersion: "deploy-test", + layoutId, + rootBoundaryId, + routeId, + routePattern, + }); + const capturedPayloads: Record[] = []; + + try { + const { options } = createDispatchOptions({ + buildPageElement: async () => element, + clientReuseManifest, + isProduction: true, + isRscRequest: true, + probeLayoutAt: createLayoutParamProbe(route, {}, [null]), + renderToReadableStream(payload) { + capturedPayloads.push(captureRecord(payload)); + return createStream(["flight"]); + }, + route, + }); + + const response = await dispatchAppPage(options); + + expect(response.status).toBe(200); + expect(capturedPayloads).toHaveLength(1); + expect(capturedPayloads[0][layoutId]).toBe("revalidating-root-layout"); + expect(capturedPayloads[0][pageId]).toBe("profile-page"); + } finally { + if (originalBuildId === undefined) { + delete process.env.__VINEXT_BUILD_ID; + } else { + process.env.__VINEXT_BUILD_ID = originalBuildId; + } + } + }); + it("returns not found for dynamicParams=false paths outside generated params", async () => { const { options } = createDispatchOptions({ async buildPageElement() { diff --git a/tests/app-page-params.test.ts b/tests/app-page-params.test.ts index be550bd7c..7dda347c1 100644 --- a/tests/app-page-params.test.ts +++ b/tests/app-page-params.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { resolveAppPageSegmentParams } from "../packages/vinext/src/server/app-page-params.js"; +import { + resolveAppPageSegmentParamScopeKeys, + resolveAppPageSegmentParams, +} from "../packages/vinext/src/server/app-page-params.js"; describe("app page params helpers", () => { it("passes only params that apply to each layout", () => { @@ -44,4 +47,11 @@ describe("app page params helpers", () => { params: ["something", "another"], }); }); + + it("keeps optional catch-all names in structural layout param scope", () => { + const routeSegments = ["docs", "[[...slug]]"]; + + expect(resolveAppPageSegmentParams(routeSegments, 2, { slug: [] })).toEqual({}); + expect(resolveAppPageSegmentParamScopeKeys(routeSegments, 2)).toEqual(["slug"]); + }); }); diff --git a/tests/app-page-probe.test.ts b/tests/app-page-probe.test.ts index a9d776bd3..2f333d83c 100644 --- a/tests/app-page-probe.test.ts +++ b/tests/app-page-probe.test.ts @@ -1,7 +1,9 @@ +import React from "react"; import { describe, expect, it, vi } from "vite-plus/test"; import { probeAppPage, probeAppPageBeforeRender, + probeReactServerSubtree, } from "../packages/vinext/src/server/app-page-probe.js"; // Mirrors makeThenableParams() from app-rsc-entry.ts — the function that @@ -13,6 +15,113 @@ function makeThenableParams>(obj: T): Promise< } describe("app page probe helpers", () => { + it("probes server components returned below a layout result", async () => { + const calls: string[] = []; + + function Child() { + calls.push("child"); + return null; + } + + function Layout() { + calls.push("layout"); + return React.createElement("section", null, React.createElement(Child)); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "child"]); + }); + + it("probes memo and forwardRef server components returned below a layout result", async () => { + const calls: string[] = []; + + const MemoChild = React.memo(function MemoChild() { + calls.push("memo"); + return null; + }); + const ForwardRefChild = React.forwardRef(function ForwardRefChild() { + calls.push("forwardRef"); + return null; + }); + const MemoForwardRefChild = React.memo( + React.forwardRef(function MemoForwardRefChild() { + calls.push("memoForwardRef"); + return null; + }), + ); + + function Layout() { + calls.push("layout"); + return React.createElement( + "section", + null, + React.createElement(MemoChild), + React.createElement(ForwardRefChild), + React.createElement(MemoForwardRefChild), + ); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "memo", "forwardRef", "memoForwardRef"]); + }); + + it("probes lazy server components returned below a layout result", async () => { + const calls: string[] = []; + + const LazyChild = React.lazy(() => + Promise.resolve({ + default() { + calls.push("lazy"); + return null; + }, + }), + ); + + function Layout() { + calls.push("layout"); + return React.createElement("section", null, React.createElement(LazyChild)); + } + + await probeReactServerSubtree(React.createElement(Layout)); + + expect(calls).toEqual(["layout", "lazy"]); + }); + + it("enforces subtree depth limits for nested arrays", async () => { + await expect( + probeReactServerSubtree([[[React.createElement("span")]]], { maxDepth: 1 }), + ).rejects.toThrow("App page layout subtree probe exceeded max depth"); + }); + + it("enforces subtree node limits for large arrays", async () => { + await expect(probeReactServerSubtree([1, 2, 3], { maxNodes: 2 })).rejects.toThrow( + "App page layout subtree probe exceeded max nodes", + ); + }); + + it("does not consume single-use iterables while probing layout children", async () => { + function Child() { + return null; + } + + function* createChildren() { + yield React.createElement(Child); + } + + const sharedChildren = createChildren(); + + function Layout() { + return React.createElement("section", null, sharedChildren); + } + + await expect(probeReactServerSubtree(React.createElement(Layout))).rejects.toThrow( + "App page layout subtree probe cannot safely inspect iterable children", + ); + expect(sharedChildren.next().value).toMatchObject({ type: Child }); + }); + it("handles layout special errors before probing the page", async () => { const layoutError = new Error("layout failed"); const pageProbe = vi.fn(() => "page"); diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index 35cefae9a..b101a80d2 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -17,6 +17,7 @@ import { } from "../packages/vinext/src/server/artifact-compatibility.js"; import type { LayoutClassificationOptions } from "../packages/vinext/src/server/app-page-execution.js"; import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js"; +import type { ClientReuseManifestSkipDisposition } from "../packages/vinext/src/server/client-reuse-manifest.js"; import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; function captureRecord(value: ReactNode | AppOutgoingElements): Record { @@ -369,6 +370,52 @@ describe("app page render lifecycle", () => { expect(consumeDynamicUsage).toHaveBeenCalledTimes(2); }); + it("does not cache RSC responses when skip transport omits layout records", async () => { + const common = createCommonOptions(); + const isrDebug = vi.fn(); + let capturedElement: Record | null = null; + + const response = await renderAppPageLifecycle({ + ...common.options, + element: { + [APP_ROOT_LAYOUT_KEY]: "/", + "layout:/": "root-layout", + "page:/posts/post": "post-page", + }, + isProduction: true, + isRscRequest: true, + isrDebug, + renderToReadableStream(element) { + capturedElement = captureRecord(element); + return createStream(["flight-data"]); + }, + revalidateSeconds: 60, + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/"], + }, + }); + + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toBe("no-store, must-revalidate"); + expect(response.headers.get("x-vinext-cache")).toBeNull(); + await expect(response.text()).resolves.toBe("flight-data"); + + if (capturedElement === null) { + throw new Error("Expected renderToReadableStream to receive AppElements payload"); + } + expect(Object.hasOwn(capturedElement, "layout:/")).toBe(false); + expect(capturedElement["page:/posts/post"]).toBe("post-page"); + expect(common.waitUntilPromises).toHaveLength(0); + expect(common.isrSet).not.toHaveBeenCalled(); + expect(isrDebug).toHaveBeenCalledWith( + "RSC cache write skipped (skip transport payload)", + "/posts/post", + ); + }); + it("does not wait for the full captured RSC payload before returning production RSC responses", async () => { const common = createCommonOptions(); const releaseRsc = createDeferred(); @@ -738,6 +785,7 @@ describe("layoutFlags injection into RSC payload", () => { layoutCount?: number; probeLayoutAt?: (index: number) => unknown; classification?: LayoutClassificationOptions | null; + skipDisposition?: ClientReuseManifestSkipDisposition; }) { let capturedElement: Record | null = null; @@ -783,6 +831,7 @@ describe("layoutFlags injection into RSC payload", () => { runWithSuppressedHookWarning: (probe: () => Promise) => probe(), element: overrides.element ?? { "page:/test": "test-page" }, classification: overrides.classification, + skipDisposition: overrides.skipDisposition, }; return { @@ -979,6 +1028,82 @@ describe("layoutFlags injection into RSC payload", () => { }); }); + it("applies enabled static-layout skip transport after preserving all layout flags", async () => { + const { options, getCapturedElement } = createRscOptions({ + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + probeLayoutAt: () => null, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/blog"], + }, + }); + + await renderAppPageLifecycle(options); + expect(getCapturedElement()["layout:/"]).toBe("root-layout"); + expect(Object.hasOwn(getCapturedElement(), "layout:/blog")).toBe(false); + expect(getCapturedElement()["page:/blog/post"]).toBe("post-page"); + expect(getCapturedElement()[APP_LAYOUT_FLAGS_KEY]).toEqual({ + "layout:/": "s", + "layout:/blog": "s", + }); + }); + + it("does not apply skip transport while producing an HTML response", async () => { + const common = createCommonOptions(); + let capturedElement: Record | null = null; + + await renderAppPageLifecycle({ + ...common.options, + element: { + "layout:/": "root-layout", + "layout:/blog": "blog-layout", + "page:/blog/post": "post-page", + }, + layoutCount: 2, + classification: { + getLayoutId: (index: number) => (index === 0 ? "layout:/" : "layout:/blog"), + buildTimeClassifications: null, + async runWithIsolatedDynamicScope(fn) { + const result = await fn(); + return { result, dynamicDetected: false }; + }, + }, + isRscRequest: false, + renderToReadableStream(element) { + capturedElement = captureRecord(element); + return createStream(["flight-data"]); + }, + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/blog"], + }, + }); + + if (capturedElement === null) { + throw new Error("Expected renderToReadableStream to be called"); + } + expect(capturedElement["layout:/"]).toBe("root-layout"); + expect(capturedElement["layout:/blog"]).toBe("blog-layout"); + expect(capturedElement["page:/blog/post"]).toBe("post-page"); + }); + it("wire payload layoutFlags uses only the shorthand 's'/'d' values, never tagged reasons", async () => { const { options, getCapturedElement } = createRscOptions({ element: { diff --git a/tests/app-page-request.test.ts b/tests/app-page-request.test.ts index 555cfb88b..c6d22a177 100644 --- a/tests/app-page-request.test.ts +++ b/tests/app-page-request.test.ts @@ -238,6 +238,7 @@ describe("app page request helpers", () => { interceptSlotKey: "modal@app/feed/@modal", }, new URLSearchParams("from=feed"), + undefined, ); expect(renderInterceptResponse).toHaveBeenCalledTimes(1); }); diff --git a/tests/app-page-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index 4c19347fd..5cba181f5 100644 --- a/tests/app-page-route-wiring.test.ts +++ b/tests/app-page-route-wiring.test.ts @@ -5,6 +5,8 @@ import { APP_PREFETCH_LOADING_SHELL_MARKER_KEY, APP_SLOT_BINDINGS_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, + buildOutgoingAppPayload, + isAppElementsRecord, type AppElements, } from "../packages/vinext/src/server/app-elements.js"; import type { AppPageParams } from "../packages/vinext/src/server/app-page-boundary.js"; @@ -13,12 +15,20 @@ import { type AppPageSlotOverride, buildAppPageElements, createAppPageLayoutEntries, + probeAppPageLayoutWithTracking, resolveAppPageChildSegments, } from "../packages/vinext/src/server/app-page-route-wiring.js"; +import { createAppLayoutParamAccessTracker } from "../packages/vinext/src/server/app-layout-param-observation.js"; import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; +import { + createRequestContext, + getRequestContext, + runWithRequestContext, +} from "../packages/vinext/src/shims/unified-request-context.js"; function readNode(value: unknown): string { return typeof value === "string" ? value : ""; @@ -283,6 +293,81 @@ function LayoutWithoutChildren() { } describe("app page route wiring helpers", () => { + it("probes returned layout children with param and revalidate tracking", async () => { + const calls: string[] = []; + const layoutParamAccess = createAppLayoutParamAccessTracker(); + + function Child() { + calls.push("child"); + return null; + } + + function Layout() { + calls.push("layout"); + return createElement("section", null, createElement(Child)); + } + + await probeAppPageLayoutWithTracking({ + layoutIndex: 0, + layoutParamAccess, + makeThenableParams, + matchedParams: {}, + route: { + layoutTreePositions: [0], + layouts: [{ default: Layout, revalidate: 60 }], + routeSegments: ["dashboard"], + }, + }); + + expect(calls).toEqual(["layout", "child"]); + expect(layoutParamAccess.getLayoutObservation("layout:/")).toMatchObject({ + completeness: "complete", + finiteRevalidateSeconds: 60, + }); + }); + + it("probes layout branches that render only when children are present", async () => { + const calls: string[] = []; + const layoutParamAccess = createAppLayoutParamAccessTracker(); + + function ChromeThatUsesTaggedData() { + calls.push("chrome"); + getRequestContext().currentRequestTags.push("tag:dashboard-chrome"); + return null; + } + + function Layout(props: { children?: ReactNode }) { + calls.push("layout"); + if (!props.children) return null; + return createElement( + "section", + null, + createElement(ChromeThatUsesTaggedData), + props.children, + ); + } + + await runWithRequestContext(createRequestContext(), () => + probeAppPageLayoutWithTracking({ + layoutIndex: 0, + layoutParamAccess, + makeThenableParams, + matchedParams: {}, + route: { + layoutTreePositions: [0], + layouts: [{ default: Layout }], + routeSegments: ["dashboard"], + }, + }), + ); + + expect(calls).toEqual(["layout", "chrome"]); + expect(layoutParamAccess.getLayoutObservation("layout:/")).toMatchObject({ + cacheTags: ["tag:dashboard-chrome"], + completeness: "complete", + }); + }); + it("resolves child segments from tree positions and preserves route groups", () => { expect( resolveAppPageChildSegments(["(marketing)", "blog", "[slug]", "[...parts]"], 1, { @@ -1067,6 +1152,64 @@ describe("app page route wiring helpers", () => { expect(body).not.toContain("page:en"); }); + it("releases skipped layout dependencies before serializing retained child entries", async () => { + let activeLocale = "en"; + + async function StaticLayout(props: Record) { + await Promise.resolve(); + activeLocale = "de"; + return createElement("div", { "data-layout": "static" }, readChildren(props.children)); + } + + function LocalePage() { + return createElement("main", null, `page:${activeLocale}`); + } + + const elements = buildAppPageElements({ + element: createElement(LocalePage), + makeThenableParams(params) { + return Promise.resolve(params); + }, + matchedParams: {}, + resolvedMetadata: null, + resolvedViewport: {}, + route: { + error: null, + errors: [null], + layoutTreePositions: [0], + layouts: [{ default: StaticLayout }], + loading: null, + notFound: null, + notFounds: [null], + routeSegments: ["skip-layout"], + slots: null, + templateTreePositions: [], + templates: [], + }, + routePath: "/skip-layout", + rootNotFoundModule: null, + }); + + const payload = buildOutgoingAppPayload({ + element: elements, + layoutFlags: { "layout:/": "s" }, + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/"], + }, + }); + + expect(isAppElementsRecord(payload)).toBe(true); + if (!isAppElementsRecord(payload)) return; + expect(Object.hasOwn(payload, "layout:/")).toBe(false); + + const body = await withTimeout(renderHtml(readChildren(payload["page:/skip-layout"])), 1_000); + + expect(body).toContain("page:en"); + }); + it("renders template-only segments in the route entry even without a matching layout", async () => { function BlogTemplate(props: Record) { return createElement("div", { "data-template": "blog" }, readChildren(props.children)); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index eb4bb4c5c..012c54490 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -4131,7 +4131,9 @@ describe("App Router next.config.js features (generateRscEntry)", () => { expect(code).toContain("export default __createAppRscHandler({"); expect(code).toContain("configRedirects: __configRedirects"); expect(code).toContain("dispatchMatchedPage({"); + expect(code).toContain(" clientReuseManifest,"); expect(code).toContain(" rootParams,\n request,"); + expect(code).toContain(" clientReuseManifest,"); expect(code).toContain(" rootParams,\n probeLayoutAt"); expect(code).toContain("dispatchMatchedRouteHandler({"); expect(code).toContain("matchRoute,"); diff --git a/tests/app-rsc-cache-busting.test.ts b/tests/app-rsc-cache-busting.test.ts index 4fe9d5f82..b2f9ea0ae 100644 --- a/tests/app-rsc-cache-busting.test.ts +++ b/tests/app-rsc-cache-busting.test.ts @@ -17,6 +17,7 @@ import { APP_RSC_RENDER_MODE_PREFETCH_LOADING_SHELL, APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI, } from "../packages/vinext/src/server/app-rsc-render-mode.js"; +import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js"; import { fnv1a64 } from "../packages/vinext/src/utils/hash.js"; import { withEnvVar } from "./env-test-helpers.js"; @@ -59,6 +60,14 @@ describe("App Router RSC cache-busting", () => { ); }); + it("attaches client reuse manifests without making them shared cache variants", async () => { + const manifestHeader = '{"entries":[]}'; + const headers = createRscRequestHeaders({ clientReuseManifestHeader: manifestHeader }); + + expect(headers.get(VINEXT_CLIENT_REUSE_MANIFEST_HEADER)).toBe(manifestHeader); + await expect(createRscRequestUrl("/dashboard", headers)).resolves.toBe("/dashboard.rsc?_rsc"); + }); + it("changes the hash when a varying header changes", async () => { const feedHash = await computeRscCacheBustingSearchParam( createRscRequestHeaders({ interceptionContext: "/feed" }), diff --git a/tests/app-rsc-handler.test.ts b/tests/app-rsc-handler.test.ts index a186bd897..fd3b47822 100644 --- a/tests/app-rsc-handler.test.ts +++ b/tests/app-rsc-handler.test.ts @@ -6,6 +6,12 @@ import { VINEXT_RSC_VARY_HEADER, } from "../packages/vinext/src/server/app-rsc-cache-busting.js"; import { createAppRscHandler } from "../packages/vinext/src/server/app-rsc-handler.js"; +import { createArtifactCompatibilityEnvelope } from "../packages/vinext/src/server/artifact-compatibility.js"; +import { + createClientReuseManifest, + createClientReusePayloadHash, +} from "../packages/vinext/src/server/client-reuse-manifest.js"; +import { VINEXT_CLIENT_REUSE_MANIFEST_HEADER } from "../packages/vinext/src/server/headers.js"; import { makeThenableParams } from "../packages/vinext/src/shims/thenable-params.js"; type TestRoute = { @@ -276,6 +282,45 @@ describe("createAppRscHandler", () => { ); }); + it("passes parsed ClientReuseManifest hints from canonical RSC requests to page dispatch", async () => { + const dispatchMatchedPage = vi.fn(async () => new Response("page", { status: 200 })); + const manifest = createClientReuseManifest({ + entries: [ + { + artifactCompatibility: createArtifactCompatibilityEnvelope(), + id: "layout:/", + payloadHash: createClientReusePayloadHash("root-layout"), + privacy: "public", + variantCacheKey: "cp1:root", + }, + ], + visibleCommitVersion: 1, + }); + const handler = createHandler({ + configHeaders: [], + dispatchMatchedPage, + }); + + const response = await handler( + new Request("https://example.test/docs/about.rsc", { + headers: { + [VINEXT_CLIENT_REUSE_MANIFEST_HEADER]: JSON.stringify(manifest), + }, + }), + null, + ); + + expect(response.status).toBe(200); + expect(dispatchMatchedPage).toHaveBeenCalledWith( + expect.objectContaining({ + clientReuseManifest: expect.objectContaining({ + kind: "parsed", + }), + isRscRequest: true, + }), + ); + }); + it("strips internal RSC cache-busting params before setting navigation context", async () => { const setNavigationContext = vi.fn(); const headers = createRscRequestHeaders(); diff --git a/tests/e2e/app-router/build-id-navigation.spec.ts b/tests/e2e/app-router/build-id-navigation.spec.ts index 58356a3a4..41443f489 100644 --- a/tests/e2e/app-router/build-id-navigation.spec.ts +++ b/tests/e2e/app-router/build-id-navigation.spec.ts @@ -4,6 +4,7 @@ import { waitForAppRouterHydration } from "../helpers"; const BASE = "http://localhost:4174"; const VISITED_CACHE_MARKER = "__VINEXT_VISITED_CACHE_MARKER__"; const RSC_NAVIGATION_PROMISE_MARKER = "__VINEXT_TEST_RSC_NAVIGATION_PROMISE__"; +const CLIENT_REUSE_MANIFEST_HEADER = "x-vinext-client-reuse-manifest"; async function pushAppRoute(page: Page, pathname: string): Promise { await page.evaluate((target) => { @@ -71,6 +72,65 @@ async function waitForLastRscNavigation(page: Page): Promise { } test.describe("App Router RSC compatibility navigation", () => { + test("sends a client reuse manifest for retained static layouts on soft navigation", async ({ + page, + }) => { + const manifestHeaders: string[] = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname === "/client-nav-test.rsc" && url.searchParams.has("_rsc")) { + const manifestHeader = request.headers()[CLIENT_REUSE_MANIFEST_HEADER]; + if (manifestHeader) { + manifestHeaders.push(manifestHeader); + } + } + }); + + await page.goto(`${BASE}/`); + await waitForAppRouterHydration(page); + await captureRscNavigationPromises(page); + + const rscResponsePromise = page.waitForResponse((response) => { + const url = new URL(response.url()); + return ( + url.pathname === "/client-nav-test.rsc" && + url.searchParams.has("_rsc") && + response.request().headers()[CLIENT_REUSE_MANIFEST_HEADER] !== undefined + ); + }); + + await pushAppRoute(page, "/client-nav-test"); + await expect(page.locator("h1")).toHaveText("Client Nav Test"); + const rscResponse = await rscResponsePromise; + await waitForLastRscNavigation(page); + + expect(rscResponse.headers()["cache-control"]).toBe("no-store, must-revalidate"); + expect(manifestHeaders).toHaveLength(1); + const manifest = JSON.parse(manifestHeaders[0]!) as { + entries: Array<{ id: string; privacy: string }>; + replayWindow: { + validFromVisibleCommitVersion: number; + validUntilVisibleCommitVersion: number; + }; + visibleCommitVersion: number; + }; + expect(manifest.visibleCommitVersion).toBe(0); + expect(manifest.replayWindow).toEqual({ + validFromVisibleCommitVersion: 0, + validUntilVisibleCommitVersion: 0, + }); + expect(manifest.entries).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "layout:/", + privacy: "public", + }), + ]), + ); + expect(manifest.entries.every((entry) => entry.id.startsWith("layout:"))).toBe(true); + expect(manifestHeaders[0]!.length).toBeLessThanOrEqual(4096); + }); + test("refetches unproofed same-build visited RSC payloads instead of reloading", async ({ page, }) => { diff --git a/tests/skip-cache-proof.test.ts b/tests/skip-cache-proof.test.ts index c2e9bd739..4ab8c94b8 100644 --- a/tests/skip-cache-proof.test.ts +++ b/tests/skip-cache-proof.test.ts @@ -4,7 +4,10 @@ import { type ArtifactCompatibilityEnvelope, } from "../packages/vinext/src/server/artifact-compatibility.js"; import { + DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, + createClientReuseManifest, createClientReusePayloadHash, + parseClientReuseManifestHeader, type ClientReuseManifestEntry, } from "../packages/vinext/src/server/client-reuse-manifest.js"; import { @@ -18,6 +21,7 @@ import { } from "../packages/vinext/src/server/cache-proof.js"; import { crossCheckClientReuseManifestEntryWithCache, + createClientReuseSkipTransportPlan, type SkipCacheInvalidationProof, } from "../packages/vinext/src/server/skip-cache-proof.js"; @@ -146,7 +150,7 @@ function createVerifiedFixture( } describe("skip/cache proof cross-checks", () => { - it("verifies a matching public layout entry without enabling skip transport", () => { + it("enables static-layout skip transport for a verified public layout entry", () => { const fixture = createVerifiedFixture(); const result = crossCheckClientReuseManifestEntryWithCache({ @@ -159,11 +163,245 @@ describe("skip/cache proof cross-checks", () => { code: "SKIP_CACHE_CROSS_CHECK_PASSED", entryId: "layout:/dashboard", kind: "verified", + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/dashboard"], + }, + }); + }); + + it("builds a skip transport plan from verified entries while preserving rejection traces", () => { + const fixture = createVerifiedFixture(); + const manifest = parseClientReuseManifestHeader( + JSON.stringify( + createClientReuseManifest({ + entries: [ + fixture.entry, + createManifestEntry({ + artifactCompatibility: fixture.artifact.compatibility, + id: "layout:/billing", + payloadHash: fixture.entry.payloadHash, + variantCacheKey: fixture.decision.proof.variant.cacheKey, + }), + ], + visibleCommitVersion: 1, + }), + ), + ); + let verificationCount = 0; + + const plan = createClientReuseSkipTransportPlan({ + manifest, + verifyEntry(entry) { + verificationCount++; + return crossCheckClientReuseManifestEntryWithCache({ + artifact: fixture.artifact, + cacheDecision: fixture.decision, + entry, + }); + }, + }); + + expect(verificationCount).toBe(2); + expect(plan).toMatchObject({ + kind: "skip", + skippedEntryIds: ["layout:/dashboard"], + skipDisposition: { + code: "SKIP_STATIC_LAYOUT_VERIFIED", + enabled: true, + mode: "skipStaticLayout", + skippedEntryIds: ["layout:/dashboard"], + }, + entryRejections: [ + { + code: "SKIP_CACHE_ENTRY_ID_MISMATCH", + entryId: "layout:/billing", + }, + ], + }); + }); + + it("uses the verified manifest entry id instead of trusting verifier-provided skipped ids", () => { + const fixture = createVerifiedFixture(); + const manifest = parseClientReuseManifestHeader( + JSON.stringify( + createClientReuseManifest({ + entries: [fixture.entry], + visibleCommitVersion: 1, + }), + ), + ); + + const plan = createClientReuseSkipTransportPlan({ + manifest, + verifyEntry(entry) { + const verified = crossCheckClientReuseManifestEntryWithCache({ + artifact: fixture.artifact, + cacheDecision: fixture.decision, + entry, + }); + if (verified.kind !== "verified") { + throw new Error("Expected fixture entry to verify"); + } + return { + ...verified, + skipDisposition: { + ...verified.skipDisposition, + skippedEntryIds: ["layout:/unverified"], + }, + }; + }, + }); + + expect(plan).toMatchObject({ + kind: "skip", + skippedEntryIds: ["layout:/dashboard"], + }); + }); + + it("falls back without verifier work for oversized manifests", () => { + const manifest = parseClientReuseManifestHeader('{"entries":[]}', { + limits: { ...DEFAULT_CLIENT_REUSE_MANIFEST_LIMITS, maxManifestBytes: 8 }, + }); + let verifierCalled = false; + + const plan = createClientReuseSkipTransportPlan({ + manifest, + verifyEntry() { + verifierCalled = true; + throw new Error("oversized manifests must not enter skip verification"); + }, + }); + + expect(verifierCalled).toBe(false); + expect(plan).toEqual({ + kind: "renderAndSend", + entryRejections: [], + manifestRejection: { + code: "SKIP_MANIFEST_TOO_LARGE", + fields: { + manifestBytes: 14, + maxManifestBytes: 8, + }, + }, skipDisposition: { code: "SKIP_MODEL_DISABLED", enabled: false, mode: "renderAndSend", }, + skippedEntryIds: [], + }); + }); + + it("falls back without verifier work when verification would exceed the local budget", () => { + const fixture = createVerifiedFixture(); + const manifest = parseClientReuseManifestHeader( + JSON.stringify( + createClientReuseManifest({ + entries: [ + fixture.entry, + createManifestEntry({ + artifactCompatibility: fixture.artifact.compatibility, + id: "layout:/profile", + payloadHash: fixture.entry.payloadHash, + variantCacheKey: fixture.decision.proof.variant.cacheKey, + }), + ], + visibleCommitVersion: 1, + }), + ), + ); + let verifierCalled = false; + + const plan = createClientReuseSkipTransportPlan({ + manifest, + maxEntriesToVerify: 1, + verifyEntry() { + verifierCalled = true; + throw new Error("over-budget manifests must not enter skip verification"); + }, + }); + + expect(verifierCalled).toBe(false); + expect(plan).toMatchObject({ + kind: "renderAndSend", + manifestRejection: { + code: "SKIP_ENTRY_COUNT_EXCEEDED", + fields: { + entryCount: 2, + maxEntryCount: 1, + }, + }, + skippedEntryIds: [], + }); + }); + + it("throws for invalid local verification budgets", () => { + const fixture = createVerifiedFixture(); + const manifest = parseClientReuseManifestHeader( + JSON.stringify( + createClientReuseManifest({ + entries: [fixture.entry], + visibleCommitVersion: 1, + }), + ), + ); + + expect(() => + createClientReuseSkipTransportPlan({ + manifest, + maxEntriesToVerify: -1, + verifyEntry() { + throw new Error("invalid budgets must fail before verification"); + }, + }), + ).toThrow("maxEntriesToVerify must be a non-negative safe integer"); + }); + + it("falls back without verifier work when malicious entries all reject at parse time", () => { + const manifest = parseClientReuseManifestHeader( + JSON.stringify( + createClientReuseManifest({ + entries: [ + { + artifactCompatibility: createCompatibility(), + id: "layout:/account", + payloadHash: createClientReusePayloadHash("account"), + privacy: "private", + variantCacheKey: "cp1:account", + }, + { + artifactCompatibility: createCompatibility(), + id: "opaque:future-entry", + payloadHash: createClientReusePayloadHash("future"), + privacy: "public", + variantCacheKey: "cp1:future", + }, + ], + visibleCommitVersion: 1, + }), + ), + ); + let verifierCalled = false; + + const plan = createClientReuseSkipTransportPlan({ + manifest, + verifyEntry() { + verifierCalled = true; + throw new Error("parse-rejected entries must not enter skip verification"); + }, + }); + + expect(verifierCalled).toBe(false); + expect(plan).toMatchObject({ + kind: "renderAndSend", + entryRejections: [ + { code: "SKIP_PRIVATE_ENTRY", entryId: "layout:/account" }, + { code: "SKIP_UNKNOWN_ENTRY", entryId: "opaque:future-entry" }, + ], + skippedEntryIds: [], }); }); @@ -274,7 +512,7 @@ describe("skip/cache proof cross-checks", () => { }); }); - it("verifies canary and rollback skip hints when deployments share a compatibility set", () => { + it("verifies canary and rollback skip hints without enabling transport skips", () => { const rollbackCompatibility = createCompatibility({ deploymentVersion: "deploy-rollback", }); diff --git a/tests/thenable-params.test.ts b/tests/thenable-params.test.ts index 5bcceb372..23b1d1d96 100644 --- a/tests/thenable-params.test.ts +++ b/tests/thenable-params.test.ts @@ -102,4 +102,52 @@ describe("makeThenableParams", () => { expect(Object.keys(params)).toEqual([]); expect(await params).toEqual({}); }); + + it("reports direct param property access to an observer", () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + expect(params.slug).toBe("post"); + expect(observedKeys).toEqual([["slug"]]); + }); + + it("reports awaited params as an all-keys access", async () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post", category: "news" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + await params; + + expect(observedKeys).toEqual([["slug", "category"]]); + }); + + it("reports destructured param property access to an observer", () => { + const observedKeys: string[][] = []; + const params = makeThenableParams( + { slug: "post" }, + { + observeParamAccess(keys) { + observedKeys.push([...keys]); + }, + }, + ); + + const { slug } = params; + + expect(slug).toBe("post"); + expect(observedKeys).toEqual([["slug"]]); + }); });