diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 47918ce16..f16ee32d2 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -26,6 +26,7 @@ import { import { createCacheEntryReuseProof, type CacheEntryReuseProof } from "./cache-proof.js"; import { navigationPlanner, + resolveDefaultOrUnmatchedSlotPersistenceForLayouts, type MountedParallelSlotSnapshotV0, type NavigationDecisionV0, type OperationLane, @@ -106,6 +107,7 @@ export type AppRouterAction = { renderId: number; rootLayoutTreePath: string | null; routeId: string; + skippedLayoutIds: readonly string[]; slotBindings: readonly AppElementsSlotBinding[]; type: "navigate" | "replace" | "traverse"; }; @@ -118,6 +120,7 @@ export type PendingNavigationCommit = { previousNextUrl: string | null; rootLayoutTreePath: string | null; routeId: string; + skippedLayoutIds: readonly string[]; }; export type AppNavigationPayloadOrigin = Readonly< @@ -134,6 +137,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"; @@ -370,7 +374,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 { @@ -485,7 +489,7 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { }; } - return mapNavigationDecisionToPendingDisposition( + const decision = mapNavigationDecisionToPendingDisposition( planPendingRootBoundaryFlightResponse({ currentState: options.currentState, pending: options.pending, @@ -494,6 +498,12 @@ export function resolvePendingNavigationCommitDispositionDecision(options: { traceFields, }), ); + + return mergeSkippedLayoutPreservation({ + currentState: options.currentState, + decision, + pending: options.pending, + }); } function createPendingNavigationTraceFields(options: { @@ -671,6 +681,90 @@ 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); + const newlyPreservedLayoutIds: string[] = []; + + for (const id of options.pending.skippedLayoutIds) { + if (seenPreservedIds.has(id)) continue; + if (AppElementsWire.parseElementKey(id)?.kind !== "layout") continue; + // Set membership here is intentionally broader than the planner's + // prefix-based persistence (resolveSameLayoutAncestorPersistenceForTopologies + // breaks at the first divergence). A layout present in both the current and + // target chains but past that divergence point is admitted here even though + // the planner would not preserve it. That is correct rather than a + // divergence bug: the server only emits a skip for a layout it proved + // byte-identical via the static-layout cache proof, so preserving the + // retained-and-identical layout — together with its owned slots derived + // below — is sound regardless of ancestor-chain position. + if (!currentLayoutIds.has(id) || !targetLayoutIds.has(id)) continue; + if (!Object.hasOwn(options.currentState.elements, id)) continue; + + preserveElementIds.push(id); + seenPreservedIds.add(id); + newlyPreservedLayoutIds.push(id); + } + + if (newlyPreservedLayoutIds.length === 0) { + return options.decision; + } + + // Restoring a skipped layout into preserveElementIds without restoring the + // default/unmatched parallel slots it owns would break the planner invariant + // documented at resolveCurrentRootBoundaryCommitElementPersistence: every + // preserved slot's owner layout is present in preserveElementIds, and vice + // versa. The topology-unknown path returns empty slot persistence, so a + // slot-owning layout skipped server-side would otherwise commit with a + // missing slot (mergeElements starts from the next payload and, with + // preserveAbsentSlots: false, never restores it). Derive the owned slots the + // same way the planner does so the preserved layout keeps its slot content. + const preservePreviousSlotIds = mergeSkippedLayoutSlotPreservation({ + currentSlotBindings: options.currentState.slotBindings, + preservePreviousSlotIds: options.decision.preservePreviousSlotIds, + skippedLayoutIds: newlyPreservedLayoutIds, + targetSlotBindings: options.pending.action.slotBindings, + }); + + return { + ...options.decision, + preserveElementIds, + preservePreviousSlotIds, + }; +} + +function mergeSkippedLayoutSlotPreservation(options: { + currentSlotBindings: readonly AppElementsSlotBinding[]; + preservePreviousSlotIds: readonly string[]; + skippedLayoutIds: readonly string[]; + targetSlotBindings: readonly AppElementsSlotBinding[]; +}): readonly string[] { + const ownedSlotIds = resolveDefaultOrUnmatchedSlotPersistenceForLayouts({ + currentSlotBindings: options.currentSlotBindings, + preservedLayoutIds: options.skippedLayoutIds, + targetSlotBindings: options.targetSlotBindings, + }); + if (ownedSlotIds.length === 0) return options.preservePreviousSlotIds; + + const preservePreviousSlotIds = [...options.preservePreviousSlotIds]; + const seenSlotIds = new Set(preservePreviousSlotIds); + for (const slotId of ownedSlotIds) { + if (seenSlotIds.has(slotId)) continue; + preservePreviousSlotIds.push(slotId); + seenSlotIds.add(slotId); + } + return preservePreviousSlotIds; +} + export async function createPendingNavigationCommit(options: { currentState: AppRouterState; nextElements: Promise; @@ -724,6 +818,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. @@ -733,5 +828,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 1b9d32725..621f40538 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,6 +27,7 @@ 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"; /** * Static sibling segment names for the matched route, surfaced so the client @@ -37,6 +41,7 @@ export const APP_STATIC_SIBLINGS_KEY = "__staticSiblings"; 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 & @@ -151,6 +156,7 @@ export type AppElementValue = | ArtifactCompatibilityEnvelope | CacheEntryReuseProof | AppElementsInterception + | readonly string[] | readonly AppElementsSlotBinding[]; type AppWireElementValue = | ReactNode @@ -160,6 +166,7 @@ type AppWireElementValue = | ArtifactCompatibilityEnvelope | CacheEntryReuseProof | AppElementsInterception + | readonly string[] | readonly AppElementsSlotBinding[]; export type AppElements = Readonly>; @@ -193,6 +200,7 @@ type AppElementsMetadata = { layoutFlags: LayoutFlags; routeId: string; rootLayoutTreePath: string | null; + skippedLayoutIds: readonly string[]; slotBindings: readonly AppElementsSlotBinding[]; }; @@ -236,8 +244,8 @@ export type AppOutgoingElements = Readonly< | CacheEntryReuseProof | AppElementsInterception | RenderObservation - | readonly AppElementsSlotBinding[] | readonly string[] + | readonly AppElementsSlotBinding[] > >; @@ -251,6 +259,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; }; @@ -262,21 +271,12 @@ type AppElementsWireCodec = { encodeCacheKey(rscUrl: string, interceptionContext: string | null): string; encodeLayoutId(treePath: string): string; encodeOutgoingPayload(input: { - element: - | ReactNode - | Readonly< - Record< - string, - | ReactNode - | AppElementsInterception - | readonly AppElementsSlotBinding[] - | readonly string[] - > - >; + 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; @@ -442,20 +442,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[]`, ); } @@ -463,13 +459,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); @@ -477,6 +473,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"; } @@ -495,7 +499,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"); } @@ -579,7 +583,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"); } @@ -628,25 +632,17 @@ export function withLayoutFlags>( } export function buildOutgoingAppPayload(input: { - element: - | ReactNode - | Readonly< - Record< - string, - | ReactNode - | AppElementsInterception - | readonly AppElementsSlotBinding[] - | readonly string[] - > - >; + 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 @@ -655,14 +651,22 @@ export function buildOutgoingAppPayload(input: { | CacheEntryReuseProof | AppElementsInterception | RenderObservation - | readonly AppElementsSlotBinding[] | readonly string[] - > = { - ...input.element, - [APP_LAYOUT_FLAGS_KEY]: input.layoutFlags, - [APP_ARTIFACT_COMPATIBILITY_KEY]: - input.artifactCompatibility ?? createArtifactCompatibilityEnvelope(), - }; + | readonly AppElementsSlotBinding[] + > = {}; + 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; } @@ -672,6 +676,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(); @@ -708,13 +726,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" && @@ -785,6 +803,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( @@ -803,6 +822,7 @@ export function readAppElementsMetadata( layoutFlags, routeId, rootLayoutTreePath, + skippedLayoutIds, slotBindings, }; } @@ -820,6 +840,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 97b71a8ce..22a692382 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_STATIC_SIBLINGS_KEY, APP_UNMATCHED_SLOT_WIRE_VALUE, diff --git a/packages/vinext/src/server/app-page-render.ts b/packages/vinext/src/server/app-page-render.ts index 9e4514877..7fdbeb97d 100644 --- a/packages/vinext/src/server/app-page-render.ts +++ b/packages/vinext/src/server/app-page-render.ts @@ -42,6 +42,26 @@ 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 { + ClientReuseManifestParseResult, + ClientReuseManifestSkipDisposition, +} from "./client-reuse-manifest.js"; +import { NO_STORE_CACHE_CONTROL } from "./cache-control.js"; +import { + createClientReuseSkipTransportPlan, + createStaticLayoutClientReuseArtifactCompatibility, + createStaticLayoutClientReusePayloadHash, + createStaticLayoutClientReuseRouteId, + crossCheckClientReuseManifestEntryWithCache, +} from "./skip-cache-proof.js"; import { createAppPageHtmlOutputScope, createAppPageRenderObservation, @@ -49,7 +69,6 @@ import { createEmptyAppPageRenderObservationState, type AppPageRenderObservationState, } from "./app-page-render-observation.js"; -import type { ClientReuseManifestParseResult } from "./client-reuse-manifest.js"; import type { AppLayoutParamAccessTracker } from "./app-layout-param-observation.js"; type AppPageBoundaryOnError = ( @@ -141,14 +160,13 @@ type RenderAppPageLifecycleOptions = { routePattern: string; runWithSuppressedHookWarning(probe: () => Promise): Promise; scriptNonce?: string; + clientReuseManifest?: ClientReuseManifestParseResult; + skipDisposition?: ClientReuseManifestSkipDisposition; mountedSlotsHeader?: string | null; renderMode?: AppRscRenderMode; waitUntil?: (promise: Promise) => void; - // Parsed manifest threaded through dispatch for future skip planner use. - // PR3 does not consume it; the planner call lives in the enable-transport slice. - clientReuseManifest?: ClientReuseManifestParseResult; // Per-layout observation tracker. Constructed in dispatch, consumed by the - // planner in the enable-transport slice. + // skip transport planner to reject layouts that are unsafe for static reuse. layoutParamAccess?: AppLayoutParamAccessTracker; element: ReactNode | Readonly>; classification?: LayoutClassificationOptions | null; @@ -230,6 +248,184 @@ 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 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, + 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 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], + // Invariant: reaching this point requires staticLayoutIds.has(entry.id), + // and a layout that observed any request API is flagged "d" by + // isLayoutObservationDynamic (isAppLayoutObservationUnsafeForStaticReuse + // rejects requestApis.length > 0) and excluded from staticLayoutIds. So + // the observed request-API set is necessarily empty here. Hardcoded + // rather than read back from the per-layout observation so that a future + // reordering of the classification gate cannot feed stale request-API + // reads into this synthetic cache proof. + requestApis: buildRenderRequestApiObservations({ + completeness: "complete", + observed: [], + }), + }), + 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 @@ -367,11 +563,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(); @@ -397,7 +607,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; @@ -422,16 +633,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, { // Only emit on dynamic renders — Next.js gates on !workStore.isStaticGeneration (line 2223). // https://github.com/vercel/next.js/blob/canary/packages/next/src/server/app-render/app-render.tsx#L2223-L2229 diff --git a/packages/vinext/src/server/app-render-dependency.tsx b/packages/vinext/src/server/app-render-dependency.tsx index 435191b67..b4b996063 100644 --- a/packages/vinext/src/server/app-render-dependency.tsx +++ b/packages/vinext/src/server/app-render-dependency.tsx @@ -10,10 +10,6 @@ const appElementRenderDependencies = new WeakMap< ReadonlyMap >(); -// Write-only until the enable slice: this map is populated here so the -// per-element render dependencies are registered ahead of the consumer -// (`releaseAppElementRenderDependency`) that lands with enable-transport. It is -// keyed by the elements object and GCs with it, so it is harmless while unread. export function registerAppElementRenderDependencies( elements: Readonly>, dependenciesByElementId: ReadonlyMap, @@ -22,6 +18,13 @@ export function registerAppElementRenderDependencies( 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/client-reuse-manifest.ts b/packages/vinext/src/server/client-reuse-manifest.ts index 221ad0d6a..9717beefa 100644 --- a/packages/vinext/src/server/client-reuse-manifest.ts +++ b/packages/vinext/src/server/client-reuse-manifest.ts @@ -95,10 +95,6 @@ export type ClientReuseManifestRejectionCode = | "SKIP_CACHE_PROOF_REJECTED" | "SKIP_CACHE_REUSE_CLASS_UNSUPPORTED" | "SKIP_CACHE_VARIANT_MISMATCH" - // Forward declarations — emitted by the render-observation tracker in a - // later slice. The planner never produces them, but the rejection code - // union must carry them so the tracker's entry rejection is assignable to - // ClientReuseManifestRejectionCode without a cast. | "SKIP_LAYOUT_CACHE_LIFE_OBSERVED" | "SKIP_LAYOUT_CACHE_TAGS_OBSERVED" | "SKIP_LAYOUT_CACHEABLE_FETCHES_OBSERVED" diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 770a91476..72e778a19 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -555,7 +555,7 @@ function resolveCurrentRootBoundaryCommitSlotPersistence(options: { * * Wire absence and UNMATCHED_SLOT markers are not semantic proof. */ -function resolveDefaultOrUnmatchedSlotPersistenceForLayouts(options: { +export function resolveDefaultOrUnmatchedSlotPersistenceForLayouts(options: { currentSlotBindings: readonly ParallelSlotBindingSnapshotV0[]; preservedLayoutIds: readonly string[]; targetSlotBindings: readonly ParallelSlotBindingSnapshotV0[]; diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index b31140238..ad14166e6 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, @@ -75,6 +76,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 ( @@ -97,18 +106,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) ); } @@ -211,7 +223,7 @@ export function Slot({ } const element = elements[id]; - if (isTransportMetadataValue(element)) { + if (isTransportMetadataValue(id, element)) { warnTransportMetadataEntry(id); return null; } diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 4a3d221ef..3767f7b47 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -33,10 +33,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, @@ -81,6 +83,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, @@ -1584,6 +1587,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, @@ -3863,6 +3883,160 @@ 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("preserves the default parallel slot owned by a skipped slot-owning layout", async () => { + const rootLayout = React.createElement("div", null, "root layout"); + const dashboardLayout = React.createElement("div", null, "dashboard layout"); + const modalSlot = React.createElement("div", null, "modal"); + const modalSlotId = AppElementsWire.encodeSlotId("modal", "/dashboard"); + const currentModalBinding = { + ownerLayoutId: "layout:/dashboard", + slotId: modalSlotId, + state: "active", + } satisfies AppElementsSlotBinding; + const currentState = createState({ + elements: createResolvedElements( + "route:/dashboard", + "/", + null, + { + "layout:/": rootLayout, + "layout:/dashboard": dashboardLayout, + [modalSlotId]: modalSlot, + }, + ["layout:/", "layout:/dashboard"], + [currentModalBinding], + ), + layoutFlags: { + "layout:/": "s", + "layout:/dashboard": "s", + }, + layoutIds: ["layout:/", "layout:/dashboard"], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/dashboard", {}), + routeId: "route:/dashboard", + slotBindings: [currentModalBinding], + }); + // Sibling navigation: the server proves layout:/dashboard reusable and omits + // it from the payload, and the modal slot resolves to its default (no active + // content) for the target route. In the topology-unknown path the planner + // preserves neither the layout nor its slot, so the skip merge must restore + // both — otherwise the retained layout commits with a missing slot. + const pending = await createPendingNavigationCommit({ + currentState, + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/dashboard/settings", + {}, + ), + nextElements: Promise.resolve( + createResolvedElements( + "route:/dashboard/settings", + "/", + null, + { + [APP_LAYOUT_FLAGS_KEY]: {}, + [APP_SKIPPED_LAYOUT_IDS_KEY]: ["layout:/dashboard"], + "page:/dashboard/settings": React.createElement("main", null, "settings"), + }, + ["layout:/", "layout:/dashboard"], + [ + { + ownerLayoutId: "layout:/dashboard", + slotId: modalSlotId, + state: "default", + }, + ], + ), + ), + 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/dashboard/settings", + }); + + expect(approval.approvedCommit).not.toBeNull(); + if (approval.approvedCommit === null) return; + expect(approval.decision.disposition).toBe("commit"); + if (approval.decision.disposition !== "commit") return; + + expect(approval.decision.preserveElementIds).toContain("layout:/dashboard"); + expect(approval.decision.preservePreviousSlotIds).toContain(modalSlotId); + + const nextState = applyApprovedVisibleCommit(currentState, approval.approvedCommit); + expect(nextState.elements["layout:/dashboard"]).toBe(dashboardLayout); + expect(nextState.elements[modalSlotId]).toBe(modalSlot); + }); + 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-page-dispatch.test.ts b/tests/app-page-dispatch.test.ts index a30391bee..6398932e0 100644 --- a/tests/app-page-dispatch.test.ts +++ b/tests/app-page-dispatch.test.ts @@ -1,17 +1,42 @@ import React from "react"; import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import { + 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 { + 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 { ClientReuseManifestParseResult } from "../packages/vinext/src/server/client-reuse-manifest.js"; import type { CachedAppPageValue } from "../packages/vinext/src/shims/cache.js"; +import { + runWithExecutionContext, + type ExecutionContextLike, +} from "../packages/vinext/src/shims/request-context.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)[]; @@ -33,6 +58,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, @@ -90,6 +122,8 @@ function createDispatchOptions( loadSsrHandler?: DispatchOptions["loadSsrHandler"]; middlewareContext?: AppPageMiddlewareContext; mountedSlotsHeader?: string | null; + params?: Record; + probeLayoutAt?: DispatchOptions["probeLayoutAt"]; renderToReadableStream?: DispatchOptions["renderToReadableStream"]; request?: Request; revalidateSeconds?: number | null; @@ -105,6 +139,7 @@ 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"])); @@ -165,10 +200,8 @@ function createDispatchOptions( status: null, }, mountedSlotsHeader: overrides.mountedSlotsHeader, - params: { slug: "hello" }, - probeLayoutAt() { - return null; - }, + params, + probeLayoutAt: overrides.probeLayoutAt ?? createLayoutParamProbe(route, params, []), probePage() { return null; }, @@ -197,6 +230,96 @@ 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; + +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); + }; +} + describe("app page dispatch", () => { afterEach(() => { vi.unstubAllEnvs(); @@ -370,6 +493,122 @@ 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("returns not found for dynamicParams=false paths outside generated params", async () => { const { options } = createDispatchOptions({ async buildPageElement() { diff --git a/tests/app-page-render.test.ts b/tests/app-page-render.test.ts index c280d5da2..70fe23b14 100644 --- a/tests/app-page-render.test.ts +++ b/tests/app-page-render.test.ts @@ -18,6 +18,7 @@ import { import type { LayoutClassificationOptions } from "../packages/vinext/src/server/app-page-execution.js"; import { renderAppPageLifecycle } from "../packages/vinext/src/server/app-page-render.js"; import { VINEXT_DYNAMIC_STALE_TIME_HEADER } from "../packages/vinext/src/server/headers.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 { @@ -370,6 +371,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(); @@ -812,6 +859,7 @@ describe("layoutFlags injection into RSC payload", () => { layoutCount?: number; probeLayoutAt?: (index: number) => unknown; classification?: LayoutClassificationOptions | null; + skipDisposition?: ClientReuseManifestSkipDisposition; }) { let capturedElement: Record | null = null; @@ -857,6 +905,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 { @@ -1053,6 +1102,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-route-wiring.test.ts b/tests/app-page-route-wiring.test.ts index cfb90d2ce..662384faf 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"; @@ -1230,6 +1232,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 73a0a0780..8339682d7 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -3388,7 +3388,7 @@ describe("App Router Static export", () => { // Explicit appDir enables static metadata asset export for App Router apps. expect(result.files).toContain("metadata-dynamic-static/-/apple-icon.png"); - }); + }, 60_000); it("pre-renders dynamic routes from generateStaticParams", async () => { // blog/[slug] has generateStaticParams returning hello-world and getting-started @@ -4275,7 +4275,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/e2e/app-router/build-id-navigation.spec.ts b/tests/e2e/app-router/build-id-navigation.spec.ts index 77abfbae5..b4977e40e 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 { isAppRouterRscRequestForPath, waitForAppRouterHydration } from "../help 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) => { @@ -74,6 +75,61 @@ 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) => { + if (isAppRouterRscRequestForPath(request, "/client-nav-test")) { + 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) => + isAppRouterRscRequestForPath(response.request(), "/client-nav-test") && + 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, }) => {