diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index f08b13227..19fe47ca6 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -732,7 +732,9 @@ export default __createAppRscHandler({ return { interceptionContext, interceptLayouts: intercept.interceptLayouts, + interceptSlotId: intercept.slotId, interceptSlotKey: intercept.slotKey, + interceptSourceMatchedUrl: interceptionContext, interceptPage: intercept.page, interceptParams: intercept.matchedParams, }; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 1c40bcd21..62be459ee 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -147,7 +147,9 @@ const MAX_VISITED_RESPONSE_CACHE_SIZE = 50; const VISITED_RESPONSE_CACHE_TTL = 5 * 60_000; const MAX_TRAVERSAL_CACHE_TTL = 30 * 60_000; const CLIENT_RSC_COMPATIBILITY_ID = getVinextRscCompatibilityId(); -const browserNavigationController = createAppBrowserNavigationController(); +const browserNavigationController = createAppBrowserNavigationController({ + syncHistoryStatePreviousNextUrl: syncCurrentHistoryStatePreviousNextUrl, +}); const discardedServerActionRefreshScheduler = createDiscardedServerActionRefreshScheduler({ runRefresh() { clearClientNavigationCaches(); @@ -234,6 +236,28 @@ function clearClientNavigationCaches(): void { clearPrefetchState(); } +function syncCurrentHistoryStatePreviousNextUrl(previousNextUrl: string | null): void { + if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) { + return; + } + + const nextHistoryState = createHistoryStateWithPreviousNextUrl( + window.history.state, + previousNextUrl, + ); + // First attempt: use replaceHistoryStateWithoutNotify which fires no popstate + // or hashchange events. If the browser accepted the state update (checked via + // readHistoryStatePreviousNextUrl), we're done. The double-read is needed + // because some browsers (notably Safari) can silently coalesce or ignore + // replaceState calls when called in rapid succession (e.g. back-to-back + // navigation commits). The fallback fires only when the state didn't stick. + replaceHistoryStateWithoutNotify(nextHistoryState, "", window.location.href); + if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) { + return; + } + window.history.replaceState(nextHistoryState, "", window.location.href); +} + function createActionInitiationSnapshot() { const routerState = getBrowserRouterState(); return createServerActionInitiationSnapshot({ @@ -265,17 +289,26 @@ function createNavigationCommitEffect(options: { } const targetHref = new URL(href, window.location.origin).href; - stageClientParams(params); const preserveExistingState = historyUpdateMode === "replace"; const historyState = createHistoryStateWithPreviousNextUrl( preserveExistingState ? window.history.state : null, previousNextUrl, ); + let wroteHistoryState = false; if (historyUpdateMode === "replace" && window.location.href !== targetHref) { + stageClientParams(params); replaceHistoryStateWithoutNotify(historyState, "", href); + wroteHistoryState = true; } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { + stageClientParams(params); pushHistoryStateWithoutNotify(historyState, "", href); + wroteHistoryState = true; + } + + if (!wroteHistoryState) { + syncCurrentHistoryStatePreviousNextUrl(previousNextUrl); + stageClientParams(params); } // URL has been updated; the recovery hard-nav target is no longer needed. @@ -435,12 +468,30 @@ function getRequestState( }; } + // Two branches for "navigate": + // 1. previousNextUrl !== null → a committed intercepted navigation set this + // in browser state (requires proof). This is the proven interception path. + // 2. previousNextUrl === null → fall through to legacy DOM-derived context. + // This fires for non-intercepted navigations (direct loads, normal client + // navs) where no proven interception state exists. The legacy path returns + // whatever the current DOM/history context reflects. switch (navigationKind) { - case "navigate": + case "navigate": { + const currentPreviousNextUrl = getBrowserRouterState().previousNextUrl; + if (currentPreviousNextUrl !== null) { + return { + interceptionContext: resolveInterceptionContextFromPreviousNextUrl( + currentPreviousNextUrl, + __basePath, + ), + previousNextUrl: currentPreviousNextUrl, + }; + } return { interceptionContext: getCurrentInterceptionContext(), previousNextUrl: getCurrentNextUrl(), }; + } case "traverse": { const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); return { @@ -500,6 +551,7 @@ function BrowserRoot({ const [treeStateValue, setTreeStateValue] = useState>({ activeOperation: null, elements: resolvedElements, + interception: initialMetadata.interception, interceptionContext: initialMetadata.interceptionContext, layoutIds: initialMetadata.layoutIds, layoutFlags: initialMetadata.layoutFlags, @@ -842,6 +894,7 @@ function registerServerActionCallback(): void { // which sends `Next-URL` on action POSTs when the current tree contains // an interception route. const actionInitiation = createActionInitiationSnapshot(); + syncCurrentHistoryStatePreviousNextUrl(actionInitiation.routerState.previousNextUrl); const body = await encodeReply(args, { temporaryReferences }); const { headers } = resolveServerActionRequestState({ actionId: id, @@ -1061,6 +1114,9 @@ function bootstrapHydration(rscStream: ReadableStream): void { const requestState = getRequestState(navigationKind, currentPrevNextUrl); const requestInterceptionContext = requestState.interceptionContext; const requestPreviousNextUrl = requestState.previousNextUrl; + if (navigationKind === "refresh") { + syncCurrentHistoryStatePreviousNextUrl(requestPreviousNextUrl); + } // Set this navigation as the pending pathname, overwriting any previous. // Pass navId so only this navigation (or a newer one) can clear it later. diff --git a/packages/vinext/src/server/app-browser-navigation-controller.ts b/packages/vinext/src/server/app-browser-navigation-controller.ts index 5c5695274..06f0f9999 100644 --- a/packages/vinext/src/server/app-browser-navigation-controller.ts +++ b/packages/vinext/src/server/app-browser-navigation-controller.ts @@ -55,6 +55,7 @@ type SameUrlServerActionLifecycleOptions = { type BrowserNavigationControllerDeps = { commitClientNavigationState?: typeof commitClientNavigationState; performHardNavigation?: (href: string, mode?: HardNavigationMode) => boolean; + syncHistoryStatePreviousNextUrl?: (previousNextUrl: string | null) => void; }; type BrowserNavigationController = { @@ -190,6 +191,7 @@ export function createAppBrowserNavigationController( const commitClientNavigationStateImpl = deps.commitClientNavigationState ?? commitClientNavigationState; const performHardNavigation = deps.performHardNavigation ?? performHardNavigationWithLoopGuard; + const syncHistoryStatePreviousNextUrl = deps.syncHistoryStatePreviousNextUrl ?? (() => {}); // These are plain module-level variables (inside the controller closure), // unlike ClientNavigationState which uses Symbol.for to survive multiple @@ -624,6 +626,7 @@ export function createAppBrowserNavigationController( if (latestApproval.approvedCommit) { dispatchSynchronousVisibleCommit(latestApproval.approvedCommit); + syncHistoryStatePreviousNextUrl(latestApproval.approvedCommit.previousNextUrl); } else { notifyDiscardedServerActionRevalidation(lifecycleOptions); } diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index fa50de76b..030b01592 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -4,6 +4,7 @@ import { getMountedSlotIds, getMountedSlotIdsHeader, type AppElements, + type AppElementsInterception, type AppElementsSlotBinding, type LayoutFlags, } from "./app-elements.js"; @@ -29,6 +30,8 @@ import { type RouteSnapshotV0, } from "./navigation-planner.js"; import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; +import { normalizePathnameForRouteMatch } from "../routing/utils.js"; +import { normalizePath } from "./normalize-path.js"; const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; @@ -58,6 +61,7 @@ export type OperationRecord = PendingOperationRecord | CommittedOperationRecord; export type AppRouterState = { activeOperation: OperationRecord | null; elements: AppElements; + interception: AppElementsInterception | null; interceptionContext: string | null; layoutFlags: LayoutFlags; layoutIds: readonly string[]; @@ -72,6 +76,7 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; + interception: AppElementsInterception | null; interceptionContext: string | null; layoutFlags: LayoutFlags; layoutIds: readonly string[]; @@ -87,6 +92,7 @@ export type AppRouterAction = { export type PendingNavigationCommit = { action: AppRouterAction; + interception: AppElementsInterception | null; interceptionContext: string | null; previousNextUrl: string | null; rootLayoutTreePath: string | null; @@ -155,6 +161,10 @@ function createOperationRecord(options: { }; } +function normalizeNavigationSnapshotMatchedUrl(pathname: string): string { + return normalizePath(normalizePathnameForRouteMatch(pathname)); +} + export function resolveInterceptionContextFromPreviousNextUrl( previousNextUrl: string | null, basePath: string = "", @@ -284,13 +294,17 @@ function createMountedParallelSlotSnapshots( function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { const displayUrl = createNavigationSnapshotUrl(state.navigationSnapshot); + const matchedUrl = normalizeNavigationSnapshotMatchedUrl(state.navigationSnapshot.pathname); return { displayUrl, + interception: state.interception, + interceptionContext: state.interceptionContext, layoutIds: state.layoutIds, - // `displayUrl` preserves the browser-visible query string for decisions and - // traces. `matchedUrl` stays path-only because route matching has already - // consumed query params before AppElements metadata reaches this boundary. - matchedUrl: state.navigationSnapshot.pathname, + // `displayUrl` preserves the browser-visible URL for decisions and traces. + // `matchedUrl` uses the route-state canonical pathname, matching the + // server's segment-decoded representation without changing user-facing + // navigation state such as usePathname(). + matchedUrl, mountedParallelSlots: createMountedParallelSlotSnapshots(state.elements), rootBoundaryId: state.rootLayoutTreePath, routeId: state.routeId, @@ -300,12 +314,17 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnapshotV0 { const displayUrl = createNavigationSnapshotUrl(pending.action.navigationSnapshot); + const matchedUrl = normalizeNavigationSnapshotMatchedUrl( + pending.action.navigationSnapshot.pathname, + ); return { displayUrl, + interception: pending.action.interception, + interceptionContext: pending.action.interceptionContext, layoutIds: pending.action.layoutIds, // See createVisibleRouteSnapshot: matchedUrl intentionally models the route // identity, not the address bar URL. - matchedUrl: pending.action.navigationSnapshot.pathname, + matchedUrl, mountedParallelSlots: createMountedParallelSlotSnapshots(pending.action.elements), rootBoundaryId: pending.rootLayoutTreePath, routeId: pending.routeId, @@ -409,14 +428,16 @@ export async function createPendingNavigationCommit(options: { }): Promise { const elements = await options.nextElements; const metadata = AppElementsWire.readMetadata(elements); - const previousNextUrl = + const requestedPreviousNextUrl = options.previousNextUrl !== undefined ? options.previousNextUrl : options.currentState.previousNextUrl; + const previousNextUrl = metadata.interception === null ? null : requestedPreviousNextUrl; return { action: { elements, + interception: metadata.interception, interceptionContext: metadata.interceptionContext, layoutIds: metadata.layoutIds, layoutFlags: metadata.layoutFlags, @@ -434,6 +455,7 @@ export async function createPendingNavigationCommit(options: { type: options.type, }, // Convenience aliases — always equal action.interceptionContext / action.rootLayoutTreePath / action.routeId. + interception: metadata.interception, interceptionContext: metadata.interceptionContext, previousNextUrl, rootLayoutTreePath: metadata.rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-browser-visible-commit.ts b/packages/vinext/src/server/app-browser-visible-commit.ts index ff04f9598..42e12cdba 100644 --- a/packages/vinext/src/server/app-browser-visible-commit.ts +++ b/packages/vinext/src/server/app-browser-visible-commit.ts @@ -46,6 +46,7 @@ export type ApprovedVisibleCommit = { readonly [approvedVisibleCommitBrand]: true; readonly action: AppRouterAction; readonly decision: VisibleCommitDecision; + readonly interception: AppRouterAction["interception"]; readonly interceptionContext: string | null; readonly previousNextUrl: string | null; readonly rootLayoutTreePath: string | null; @@ -156,6 +157,7 @@ function reduceApprovedVisibleCommitState( preserveElementIds: commit.decision.preserveElementIds, preservePreviousSlotIds: commit.decision.preservePreviousSlotIds, }), + interception: action.interception, interceptionContext: action.interceptionContext, layoutFlags: mergeLayoutFlags( state.layoutFlags, @@ -182,6 +184,7 @@ function reduceApprovedVisibleCommitState( state, { elements: action.elements, + interception: action.interception, interceptionContext: action.interceptionContext, layoutFlags: action.layoutFlags, layoutIds: action.layoutIds, @@ -266,6 +269,7 @@ function createApprovedVisibleCommit(options: { [approvedVisibleCommitBrand]: true, action: options.pending.action, decision: options.decision, + interception: options.pending.interception, interceptionContext: options.pending.interceptionContext, previousNextUrl: options.pending.previousNextUrl, rootLayoutTreePath: options.pending.rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-elements-wire.ts b/packages/vinext/src/server/app-elements-wire.ts index bbc728aeb..1018b3836 100644 --- a/packages/vinext/src/server/app-elements-wire.ts +++ b/packages/vinext/src/server/app-elements-wire.ts @@ -9,6 +9,7 @@ import type { RenderObservation } from "./cache-proof.js"; const APP_INTERCEPTION_SEPARATOR = "\0"; export const APP_ARTIFACT_COMPATIBILITY_KEY = "__artifactCompatibility"; +export const APP_INTERCEPTION_KEY = "__interception"; export const APP_INTERCEPTION_CONTEXT_KEY = "__interceptionContext"; export const APP_LAYOUT_IDS_KEY = "__layoutIds"; export const APP_LAYOUT_FLAGS_KEY = "__layoutFlags"; @@ -28,6 +29,14 @@ export type AppElementsSlotBinding = Readonly<{ state: AppElementsSlotBindingState; }>; +export type AppElementsInterception = Readonly<{ + sourceMatchedUrl: string; + sourceRouteId: string; + slotId: string; + targetMatchedUrl: string; + targetRouteId: string; +}>; + export function compareAppElementsSlotIds(left: string, right: string): number { if (left < right) return -1; if (left > right) return 1; @@ -78,6 +87,7 @@ export type AppElementValue = | null | LayoutFlags | ArtifactCompatibilityEnvelope + | AppElementsInterception | readonly AppElementsSlotBinding[]; type AppWireElementValue = | ReactNode @@ -85,6 +95,7 @@ type AppWireElementValue = | null | LayoutFlags | ArtifactCompatibilityEnvelope + | AppElementsInterception | readonly AppElementsSlotBinding[]; export type AppElements = Readonly>; @@ -111,6 +122,7 @@ export type LayoutFlags = Readonly>; type AppElementsMetadata = { artifactCompatibility: ArtifactCompatibilityEnvelope; + interception: AppElementsInterception | null; interceptionContext: string | null; layoutIds: readonly string[]; layoutFlags: LayoutFlags; @@ -127,6 +139,7 @@ type AppElementsWireElementKey = | { kind: "template"; treePath: string }; type AppElementsWireMetadataInput = { + interception?: AppElementsInterception | null; interceptionContext: string | null; layoutIds?: readonly string[]; routeId: string; @@ -136,6 +149,7 @@ type AppElementsWireMetadataInput = { type AppElementsWireMetadataEntries = Readonly<{ [APP_ROUTE_KEY]: string; + [APP_INTERCEPTION_KEY]?: AppElementsInterception; [APP_INTERCEPTION_CONTEXT_KEY]: string | null; [APP_LAYOUT_IDS_KEY]: readonly string[]; [APP_ROOT_LAYOUT_KEY]: string | null; @@ -154,6 +168,7 @@ export type AppOutgoingElements = Readonly< | ReactNode | LayoutFlags | ArtifactCompatibilityEnvelope + | AppElementsInterception | RenderObservation | readonly AppElementsSlotBinding[] > @@ -161,6 +176,7 @@ export type AppOutgoingElements = Readonly< type AppElementsWireKeys = { readonly artifactCompatibility: typeof APP_ARTIFACT_COMPATIBILITY_KEY; + readonly interception: typeof APP_INTERCEPTION_KEY; readonly interceptionContext: typeof APP_INTERCEPTION_CONTEXT_KEY; readonly layoutIds: typeof APP_LAYOUT_IDS_KEY; readonly layoutFlags: typeof APP_LAYOUT_FLAGS_KEY; @@ -178,7 +194,11 @@ type AppElementsWireCodec = { encodeCacheKey(rscUrl: string, interceptionContext: string | null): string; encodeLayoutId(treePath: string): string; encodeOutgoingPayload(input: { - element: ReactNode | Readonly>; + element: + | ReactNode + | Readonly< + Record + >; artifactCompatibility?: ArtifactCompatibilityEnvelope; layoutFlags: LayoutFlags; renderObservation?: RenderObservation; @@ -303,14 +323,17 @@ function createAppElementsWireMetadataEntries( // Empty slot binding metadata is intentionally omitted. Missing // __slotBindings round-trips as [] and means "no route-state proof", so // default/unmatched slot preservation is not promoted for that payload. + const interceptionEntry = input.interception + ? { [APP_INTERCEPTION_KEY]: input.interception } + : {}; if (input.slotBindings && input.slotBindings.length > 0) { - const slotBindings = normalizeAppElementsSlotBindings(input.slotBindings, { layoutIds }); return { ...entries, - [APP_SLOT_BINDINGS_KEY]: slotBindings, + ...interceptionEntry, + [APP_SLOT_BINDINGS_KEY]: normalizeAppElementsSlotBindings(input.slotBindings, { layoutIds }), }; } - return entries; + return { ...entries, ...interceptionEntry }; } export function normalizeAppElements(elements: AppWireElements): AppElements { @@ -428,6 +451,76 @@ function parseSlotBindings( return normalizeAppElementsSlotBindings(slotBindings, options); } +function readRequiredInterceptionString( + entry: Record, + fieldName: keyof AppElementsInterception, +): string { + const value = entry[fieldName]; + if (typeof value !== "string") { + throw new Error("[vinext] Invalid __interception in App Router payload: expected strings"); + } + return value; +} + +function parseInterceptionMatchedUrl(value: string): string { + if ( + !value.startsWith("/") || + value.startsWith("//") || + value.includes("?") || + value.includes("#") || + value.includes("\0") + ) { + throw new Error("[vinext] Invalid __interception in App Router payload: expected path URLs"); + } + return value; +} + +function parseInterceptionRouteId(value: string, matchedUrl: string): string { + const parsed = parseAppElementsWireElementKey(value); + if ( + parsed?.kind !== "route" || + parsed.path !== matchedUrl || + parsed.interceptionContext !== null + ) { + throw new Error("[vinext] Invalid __interception in App Router payload: expected route ids"); + } + return value; +} + +function parseInterceptionSlotId(value: string): string { + if (parseAppElementsWireElementKey(value)?.kind !== "slot") { + throw new Error("[vinext] Invalid __interception in App Router payload: expected slot id"); + } + return value; +} + +function parseInterceptionMetadata(value: unknown): AppElementsInterception | null { + if (value === undefined || value === null) return null; + if (!isRecord(value)) { + throw new Error("[vinext] Invalid __interception in App Router payload: expected object"); + } + + const sourceMatchedUrl = parseInterceptionMatchedUrl( + readRequiredInterceptionString(value, "sourceMatchedUrl"), + ); + const targetMatchedUrl = parseInterceptionMatchedUrl( + readRequiredInterceptionString(value, "targetMatchedUrl"), + ); + return { + sourceMatchedUrl, + sourceRouteId: parseInterceptionRouteId( + readRequiredInterceptionString(value, "sourceRouteId"), + sourceMatchedUrl, + ), + slotId: parseInterceptionSlotId(readRequiredInterceptionString(value, "slotId")), + targetMatchedUrl, + targetRouteId: parseInterceptionRouteId( + readRequiredInterceptionString(value, "targetRouteId"), + targetMatchedUrl, + ), + }; +} + /** * Type predicate for a plain (non-null, non-array) record of app payload values. * Used to distinguish the App Router payload object from bare React elements at @@ -452,7 +545,11 @@ export function withLayoutFlags>( } export function buildOutgoingAppPayload(input: { - element: ReactNode | Readonly>; + element: + | ReactNode + | Readonly< + Record + >; artifactCompatibility?: ArtifactCompatibilityEnvelope; layoutFlags: LayoutFlags; renderObservation?: RenderObservation; @@ -465,6 +562,7 @@ export function buildOutgoingAppPayload(input: { | ReactNode | LayoutFlags | ArtifactCompatibilityEnvelope + | AppElementsInterception | RenderObservation | readonly AppElementsSlotBinding[] > = { @@ -518,12 +616,14 @@ export function readAppElementsMetadata( const layoutFlags = parseLayoutFlags(elements[APP_LAYOUT_FLAGS_KEY]); const layoutIds = parseLayoutIds(elements[APP_LAYOUT_IDS_KEY]); const slotBindings = parseSlotBindings(elements[APP_SLOT_BINDINGS_KEY], { layoutIds }); + const interception = parseInterceptionMetadata(elements[APP_INTERCEPTION_KEY]); const artifactCompatibility = readArtifactCompatibilityMetadata( elements[APP_ARTIFACT_COMPATIBILITY_KEY], ); return { artifactCompatibility, + interception, interceptionContext: interceptionContext ?? null, layoutIds, layoutFlags, @@ -538,6 +638,7 @@ export const AppElementsWire: AppElementsWireCodec = { // behind the codec boundary. keys: { artifactCompatibility: APP_ARTIFACT_COMPATIBILITY_KEY, + interception: APP_INTERCEPTION_KEY, interceptionContext: APP_INTERCEPTION_CONTEXT_KEY, layoutIds: APP_LAYOUT_IDS_KEY, layoutFlags: APP_LAYOUT_FLAGS_KEY, diff --git a/packages/vinext/src/server/app-elements.ts b/packages/vinext/src/server/app-elements.ts index 9621ed114..f3f4e0af6 100644 --- a/packages/vinext/src/server/app-elements.ts +++ b/packages/vinext/src/server/app-elements.ts @@ -4,6 +4,7 @@ import { AppElementsWire, UNMATCHED_SLOT, type AppElements } from "./app-element export { AppElementsWire, APP_ARTIFACT_COMPATIBILITY_KEY, + APP_INTERCEPTION_KEY, APP_INTERCEPTION_CONTEXT_KEY, APP_LAYOUT_IDS_KEY, APP_LAYOUT_FLAGS_KEY, @@ -21,6 +22,7 @@ export { readAppElementsMetadata, withLayoutFlags, type AppElementValue, + type AppElementsInterception, type AppElementsSlotBinding, type AppElements, type AppOutgoingElements, diff --git a/packages/vinext/src/server/app-page-dispatch.ts b/packages/vinext/src/server/app-page-dispatch.ts index 26733c676..6ca6fd3ca 100644 --- a/packages/vinext/src/server/app-page-dispatch.ts +++ b/packages/vinext/src/server/app-page-dispatch.ts @@ -106,6 +106,7 @@ type AppPageDispatchIntercept = { interceptLayouts?: readonly AppPageModule[] | null; matchedParams: AppPageParams; page: TPage; + slotId?: string | null; slotKey: string; sourceRouteIndex: number; }; @@ -115,7 +116,9 @@ type AppPageDispatchInterceptOptions = { interceptLayouts?: readonly AppPageModule[] | null; interceptPage: TPage; interceptParams: AppPageParams; + interceptSlotId?: string | null; interceptSlotKey: string; + interceptSourceMatchedUrl?: string | null; }; type AppPageModule = { @@ -310,7 +313,9 @@ function toInterceptOptions( interceptLayouts: intercept.interceptLayouts, interceptPage: intercept.page, interceptParams: intercept.matchedParams, + interceptSlotId: intercept.slotId ?? null, interceptSlotKey: intercept.slotKey, + interceptSourceMatchedUrl: interceptionContext, }; } diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index e940aeae9..c0adbcd6a 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -10,11 +10,13 @@ import { type AppPageRouteWiringRoute, type AppPageSlotOverride, } from "./app-page-route-wiring.js"; -import { AppElementsWire, type AppElements } from "./app-elements.js"; +import { AppElementsWire, type AppElements, type AppElementsInterception } from "./app-elements.js"; import type { AppPageParams } from "./app-page-boundary.js"; import { matchRoutePattern } from "../routing/route-pattern.js"; +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 { normalizePath } from "./normalize-path.js"; export type { AppPageErrorModule, AppPageRouteWiringRoute } from "./app-page-route-wiring.js"; @@ -38,7 +40,9 @@ export type AppPageInterceptOptions = { @@ -118,6 +122,7 @@ export async function buildPageElements< const pageModule: AppPageModule | null | undefined = route.page; const PageComponent = pageModule?.default; const hasPageModule = !!pageModule; + const interception = createAppPageInterceptionProof(routePath, opts); if (hasPageModule && !PageComponent) { const interceptionContext = opts?.interceptionContext ?? null; @@ -136,6 +141,7 @@ export async function buildPageElements< } return { ...AppElementsWire.createMetadataEntries({ + interception, interceptionContext, layoutIds: noExportLayoutIds, rootLayoutTreePath: noExportRootLayout, @@ -193,6 +199,7 @@ export async function buildPageElements< resolvedMetadata, resolvedViewport, interceptionContext: opts?.interceptionContext ?? null, + interception, routePath, rootNotFoundModule: rootNotFoundModule ?? null, rootForbiddenModule: rootForbiddenModule ?? null, @@ -203,6 +210,41 @@ export async function buildPageElements< }); } +function createAppPageInterceptionProof( + routePath: string, + opts?: AppPageInterceptOptions | null, +): AppElementsInterception | null { + const sourceMatchedUrl = normalizeInterceptionProofMatchedUrl( + opts?.interceptSourceMatchedUrl ?? null, + ); + const targetMatchedUrl = normalizeInterceptionProofMatchedUrl(routePath); + const slotId = opts?.interceptSlotId ?? null; + if (sourceMatchedUrl === null || targetMatchedUrl === null || slotId === null) return null; + + return { + sourceMatchedUrl, + sourceRouteId: AppElementsWire.encodeRouteId(sourceMatchedUrl, null), + slotId, + targetMatchedUrl, + targetRouteId: AppElementsWire.encodeRouteId(targetMatchedUrl, null), + }; +} + +function normalizeInterceptionProofMatchedUrl(value: string | null): string | null { + if ( + value === null || + !value.startsWith("/") || + value.startsWith("//") || + value.includes("?") || + value.includes("#") || + value.includes("\0") + ) { + return null; + } + + return normalizePath(normalizePathnameForRouteMatch(value)); +} + /** * Build the per-request `slotOverrides` map. Combines: * - Interception overrides (existing behavior — swap in the intercepting page diff --git a/packages/vinext/src/server/app-page-request.ts b/packages/vinext/src/server/app-page-request.ts index ad04a7824..1c9c722f2 100644 --- a/packages/vinext/src/server/app-page-request.ts +++ b/packages/vinext/src/server/app-page-request.ts @@ -49,6 +49,7 @@ type BuildAppPageElementResult = { type AppPageInterceptMatch = { matchedParams: AppPageParams; page: TPage; + slotId?: string | null; slotKey: string; sourceRouteIndex: number; }; diff --git a/packages/vinext/src/server/app-page-route-wiring.tsx b/packages/vinext/src/server/app-page-route-wiring.tsx index 59dc9bfe2..63fe0525c 100644 --- a/packages/vinext/src/server/app-page-route-wiring.tsx +++ b/packages/vinext/src/server/app-page-route-wiring.tsx @@ -3,6 +3,7 @@ import { AppElementsWire, normalizeAppElementsSlotBindings, type AppElements, + type AppElementsInterception, type AppElementsSlotBinding, } from "./app-elements.js"; import { @@ -155,6 +156,7 @@ type BuildAppPageElementsOptions< TModule extends AppPageModule = AppPageModule, TErrorModule extends AppPageErrorModule = AppPageErrorModule, > = BuildAppPageRouteElementOptions & { + interception?: AppElementsInterception | null; interceptionContext?: string | null; isRscRequest?: boolean; mountedSlotIds?: ReadonlySet | null; @@ -416,8 +418,12 @@ export function buildAppPageElements< return undefined; }; - const elements: Record = { + const elements: Record< + string, + ReactNode | string | null | AppElementsInterception | readonly AppElementsSlotBinding[] + > = { ...AppElementsWire.createMetadataEntries({ + interception: options.interception ?? null, interceptionContext, layoutIds: options.route.ids?.layouts ?? layoutEntries.map((entry) => entry.id), rootLayoutTreePath, diff --git a/packages/vinext/src/server/app-rsc-route-matching.ts b/packages/vinext/src/server/app-rsc-route-matching.ts index db505d0a1..61f04b6ce 100644 --- a/packages/vinext/src/server/app-rsc-route-matching.ts +++ b/packages/vinext/src/server/app-rsc-route-matching.ts @@ -12,6 +12,7 @@ type AppRscInterceptForMatching = { }; type AppRscSlotForMatching = { + id?: string | null; intercepts?: readonly AppRscInterceptForMatching[]; }; @@ -32,6 +33,7 @@ type AppRscInterceptLookupEntry = { interceptLayouts: readonly unknown[]; page: unknown; params: readonly string[]; + slotId: string | null; }; function createRouteParams(): AppRscRouteParams { @@ -58,21 +60,17 @@ export function createAppRscRouteMatcher( return trieMatch(routeTrie, appRscPathnameParts(url)); }, findIntercept(pathname, sourcePathname = null) { + if (sourcePathname === null) return null; const urlParts = appRscPathnameParts(pathname); + const sourceParts = appRscPathnameParts(sourcePathname); for (const entry of interceptLookup) { const params = matchAppRscRoutePattern(urlParts, entry.targetPatternParts); if (params !== null) { - let sourceParams = createRouteParams(); - if (sourcePathname !== null) { - const sourceRoute = routes[entry.sourceRouteIndex]; - const sourceParts = appRscPathnameParts(sourcePathname); - const matchedSourceParams = sourceRoute - ? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts) - : null; - if (matchedSourceParams !== null) { - sourceParams = matchedSourceParams; - } - } + const sourceRoute = routes[entry.sourceRouteIndex]; + const sourceParams = sourceRoute + ? matchAppRscRoutePattern(sourceParts, sourceRoute.patternParts) + : null; + if (sourceParams === null) continue; return { ...entry, matchedParams: mergeMatchedParams(sourceParams, params) }; } } @@ -94,6 +92,7 @@ function createInterceptLookup( interceptLookup.push({ sourceRouteIndex: routeIndex, slotKey, + slotId: typeof slotModule.id === "string" ? slotModule.id : null, targetPattern: intercept.targetPattern, targetPatternParts: intercept.targetPattern.split("/").filter(Boolean), interceptLayouts: intercept.interceptLayouts, diff --git a/packages/vinext/src/server/app-server-action-execution.ts b/packages/vinext/src/server/app-server-action-execution.ts index 9aef8217a..3feacaa99 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -86,6 +86,7 @@ type AppServerActionMatch = { type AppServerActionIntercept = { matchedParams: AppPageParams; page: TPage; + slotId?: string | null; slotKey: string; sourceRouteIndex: number; }; diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index af8f86e0e..202ec3076 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -6,6 +6,7 @@ import { createNavigationTrace, type NavigationTrace, type NavigationTraceFields, + type NavigationTraceReasonCode, } from "./navigation-trace.js"; export type OperationLane = @@ -27,6 +28,8 @@ export type OperationToken = { }; export type RouteSnapshotV0 = { + interception: InterceptionSnapshotV0 | null; + interceptionContext: string | null; routeId: string; // Ordered ancestor-first, with the root layout at index 0. Same-layout // persistence uses prefix comparison, so callers must preserve this order. @@ -38,6 +41,14 @@ export type RouteSnapshotV0 = { slotBindings: readonly ParallelSlotBindingSnapshotV0[]; }; +export type InterceptionSnapshotV0 = { + sourceMatchedUrl: string; + sourceRouteId: string; + slotId: string; + targetMatchedUrl: string; + targetRouteId: string; +}; + export type MountedParallelSlotSnapshotV0 = { slotId: string; ownerLayoutId: string | null; @@ -81,12 +92,12 @@ export type CommitProposal = { preserveAbsentSlots: boolean; preserveElementIds: readonly string[]; preservePreviousSlotIds: readonly string[]; - reason: "currentRootBoundary" | "rootBoundaryUnknownFallback"; + reason: "currentRootBoundary" | "interceptedCurrentRootBoundary" | "rootBoundaryUnknownFallback"; targetSnapshot: RouteSnapshotV0; }; export type NoCommitReason = "prefetchOnly"; -export type HardNavigationReason = "rootBoundaryChanged"; +export type HardNavigationReason = "interceptionProofRejected" | "rootBoundaryChanged"; export type RootBoundaryTransition = | "currentRootBoundary" | "rootBoundaryChanged" @@ -334,6 +345,121 @@ function resolveDefaultOrUnmatchedSlotPersistenceForLayouts(options: { return preservedSlotIds.sort(compareAppElementsSlotIds); } +type VisibleInterceptionSourceIdentity = { + matchedUrl: string; + routeId: string; +}; + +type InterceptedPreservationValidation = + | { + kind: "approved"; + preserveElementIds: readonly string[]; + preservePreviousSlotIds: readonly string[]; + } + | { + kind: "rejected"; + reasonCode: NavigationTraceReasonCode; + }; + +function getVisibleInterceptionSourceIdentity( + snapshot: RouteSnapshotV0, +): VisibleInterceptionSourceIdentity { + if (snapshot.interception) { + return { + matchedUrl: snapshot.interception.sourceMatchedUrl, + routeId: snapshot.interception.sourceRouteId, + }; + } + return { + matchedUrl: snapshot.matchedUrl, + routeId: snapshot.routeId, + }; +} + +function createInterceptionProofRejectedDecision(options: { + event: Extract; + reasonCode: NavigationTraceReasonCode; + traceFields: NavigationTraceFields; +}): NavigationDecisionV0 { + return { + kind: "hardNavigate", + reason: "interceptionProofRejected", + token: options.event.token, + trace: createNavigationTrace(options.reasonCode, options.traceFields), + url: options.event.result.href, + }; +} + +function validateInterceptedPreservation(options: { + currentSnapshot: RouteSnapshotV0; + targetSnapshot: RouteSnapshotV0; +}): InterceptedPreservationValidation { + const proof = options.targetSnapshot.interception; + if (!proof) { + return { + kind: "rejected", + reasonCode: NavigationTraceReasonCodes.interceptedRejectedMissingProof, + }; + } + + if (proof.targetMatchedUrl !== options.targetSnapshot.matchedUrl) { + return { + kind: "rejected", + reasonCode: NavigationTraceReasonCodes.interceptedRejectedTargetMismatch, + }; + } + + const sourceIdentity = getVisibleInterceptionSourceIdentity(options.currentSnapshot); + if ( + proof.sourceMatchedUrl !== sourceIdentity.matchedUrl || + proof.sourceRouteId !== sourceIdentity.routeId + ) { + return { + kind: "rejected", + reasonCode: NavigationTraceReasonCodes.interceptedRejectedUnknownSource, + }; + } + + const preservedLayoutIds = resolveSameLayoutAncestorPersistence( + options.currentSnapshot, + options.targetSnapshot, + ); + if (preservedLayoutIds.length === 0) { + return { + kind: "rejected", + reasonCode: NavigationTraceReasonCodes.interceptedRejectedIncompatibleRoot, + }; + } + + const preservedLayoutIdSet = new Set(preservedLayoutIds); + const targetSlotBinding = options.targetSnapshot.slotBindings.find( + (binding) => binding.slotId === proof.slotId, + ); + if ( + !targetSlotBinding || + targetSlotBinding.state !== "active" || + targetSlotBinding.ownerLayoutId === null || + !preservedLayoutIdSet.has(targetSlotBinding.ownerLayoutId) + ) { + return { + kind: "rejected", + reasonCode: NavigationTraceReasonCodes.interceptedRejectedMissingSlotProof, + }; + } + + const preservePreviousSlotIds = resolveDefaultOrUnmatchedSlotPersistenceForLayouts({ + currentSnapshot: options.currentSnapshot, + preservedLayoutIds, + targetSnapshot: options.targetSnapshot, + }).filter((slotId) => slotId !== proof.slotId); + + return { + kind: "approved", + preserveElementIds: preservedLayoutIds, + preservePreviousSlotIds, + }; +} + function planFlightResponseArrived(options: { event: Extract; state: NavigationPlannerStateV0; @@ -349,9 +475,47 @@ function planFlightResponseArrived(options: { }; } + const targetSnapshot = options.event.result.targetSnapshot; + // A payload with legacy interceptionContext (no __interception proof metadata) + // enters validation but gets rejected with interceptedRejectedMissingProof → + // hard navigation. This fences legacy cached RSC payloads from before this + // deploy. The cache is busted by the deploy (different build), so no stale + // artifact should persist across deploys. + const hasInterceptedPayload = + targetSnapshot.interception !== null || targetSnapshot.interceptionContext !== null; + if (hasInterceptedPayload) { + const validation = validateInterceptedPreservation({ + currentSnapshot: options.state.visibleSnapshot, + targetSnapshot, + }); + if (validation.kind === "rejected") { + return createInterceptionProofRejectedDecision({ + event: options.event, + reasonCode: validation.reasonCode, + traceFields, + }); + } + + return { + kind: "proposeCommit", + proposal: { + preserveAbsentSlots: false, + preserveElementIds: validation.preserveElementIds, + preservePreviousSlotIds: validation.preservePreviousSlotIds, + reason: "interceptedCurrentRootBoundary", + targetSnapshot, + }, + token: options.event.token, + trace: createNavigationTrace( + NavigationTraceReasonCodes.interceptedCommitCurrent, + traceFields, + ), + }; + } + const transition = classifyRootBoundaryTransition( options.state.visibleSnapshot.rootBoundaryId, - options.event.result.targetSnapshot.rootBoundaryId, + targetSnapshot.rootBoundaryId, ); if (transition === "rootBoundaryChanged") { @@ -376,7 +540,7 @@ function planFlightResponseArrived(options: { preserveElementIds: [], preservePreviousSlotIds: [], reason: "rootBoundaryUnknownFallback", - targetSnapshot: options.event.result.targetSnapshot, + targetSnapshot, }, token: options.event.token, trace: createNavigationTrace(NavigationTraceReasonCodes.rootBoundaryUnknown, traceFields), @@ -398,7 +562,7 @@ function planFlightResponseArrived(options: { targetSnapshot: options.event.result.targetSnapshot, }), reason: "currentRootBoundary", - targetSnapshot: options.event.result.targetSnapshot, + targetSnapshot, }, token: options.event.token, trace: createNavigationTrace(NavigationTraceReasonCodes.commitCurrent, traceFields), diff --git a/packages/vinext/src/server/navigation-trace.ts b/packages/vinext/src/server/navigation-trace.ts index 697d05d35..bcac04b65 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -4,6 +4,12 @@ export type NavigationTraceSchemaVersion = 0; export const NavigationTraceReasonCodes = { commitCurrent: "NC_COMMIT", + interceptedCommitCurrent: "NC_INTERCEPT_COMMIT", + interceptedRejectedIncompatibleRoot: "NC_INTERCEPT_REJECT_ROOT", + interceptedRejectedMissingProof: "NC_INTERCEPT_REJECT_MISSING_PROOF", + interceptedRejectedMissingSlotProof: "NC_INTERCEPT_REJECT_SLOT", + interceptedRejectedTargetMismatch: "NC_INTERCEPT_REJECT_TARGET", + interceptedRejectedUnknownSource: "NC_INTERCEPT_REJECT_SOURCE", prefetchOnly: "NC_PREFETCH_ONLY", requestWork: "NC_REQUEST", rootBoundaryChanged: "NC_ROOT", @@ -11,6 +17,12 @@ export const NavigationTraceReasonCodes = { staleOperation: "NC_STALE", } satisfies Readonly<{ commitCurrent: "NC_COMMIT"; + interceptedCommitCurrent: "NC_INTERCEPT_COMMIT"; + interceptedRejectedIncompatibleRoot: "NC_INTERCEPT_REJECT_ROOT"; + interceptedRejectedMissingProof: "NC_INTERCEPT_REJECT_MISSING_PROOF"; + interceptedRejectedMissingSlotProof: "NC_INTERCEPT_REJECT_SLOT"; + interceptedRejectedTargetMismatch: "NC_INTERCEPT_REJECT_TARGET"; + interceptedRejectedUnknownSource: "NC_INTERCEPT_REJECT_SOURCE"; prefetchOnly: "NC_PREFETCH_ONLY"; requestWork: "NC_REQUEST"; rootBoundaryChanged: "NC_ROOT"; diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index 6e4f01a20..e13fd2125 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -6,6 +6,7 @@ import { UNMATCHED_SLOT, type AppElementValue, type AppElements, + type AppElementsInterception, type AppElementsSlotBinding, type LayoutFlags, } from "../server/app-elements.js"; @@ -71,12 +72,33 @@ function isSlotBindingListValue(value: unknown): value is readonly AppElementsSl return Array.isArray(value) && value.length > 0 && value.every(isSlotBindingValue); } +function isInterceptionMetadataValue(value: unknown): value is AppElementsInterception { + if (typeof value !== "object" || value === null || Array.isArray(value)) return false; + return ( + "sourceMatchedUrl" in value && + typeof value.sourceMatchedUrl === "string" && + "sourceRouteId" in value && + typeof value.sourceRouteId === "string" && + "slotId" in value && + typeof value.slotId === "string" && + "targetMatchedUrl" in value && + typeof value.targetMatchedUrl === "string" && + "targetRouteId" in value && + typeof value.targetRouteId === "string" + ); +} + function isTransportMetadataValue( value: AppElementValue | undefined, -): value is LayoutFlags | ArtifactCompatibilityEnvelope | readonly AppElementsSlotBinding[] { +): value is + | LayoutFlags + | ArtifactCompatibilityEnvelope + | AppElementsInterception + | readonly AppElementsSlotBinding[] { return ( isLayoutFlagsValue(value) || isArtifactCompatibilityEnvelopeValue(value) || + isInterceptionMetadataValue(value) || isSlotBindingListValue(value) ); } diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 6366446ee..272db2c95 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -33,6 +33,7 @@ import { getMountedSlotIdsHeader, normalizeAppElements, type AppElements, + type AppElementsInterception, type AppElementsSlotBinding, } from "../packages/vinext/src/server/app-elements.js"; import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; @@ -74,9 +75,11 @@ function createResolvedElements( ? [] : [AppElementsWire.encodeLayoutId(rootLayoutTreePath)], slotBindings: readonly AppElementsSlotBinding[] = [], + interception: AppElementsInterception | null = null, ) { return normalizeAppElements({ ...AppElementsWire.createMetadataEntries({ + interception, interceptionContext, layoutIds, rootLayoutTreePath, @@ -90,6 +93,7 @@ function createResolvedElements( function createState(overrides: Partial = {}): AppRouterState { return { elements: createResolvedElements("route:/initial", "/"), + interception: null, layoutIds: [AppElementsWire.encodeLayoutId("/")], layoutFlags: {}, navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/initial", {}), @@ -105,6 +109,20 @@ function createState(overrides: Partial = {}): AppRouterState { }; } +function createInterceptionProof( + sourceMatchedUrl: string, + targetMatchedUrl: string, + slotId: string = AppElementsWire.encodeSlotId("modal", sourceMatchedUrl), +): AppElementsInterception { + return { + sourceMatchedUrl, + sourceRouteId: AppElementsWire.encodeRouteId(sourceMatchedUrl, null), + slotId, + targetMatchedUrl, + targetRouteId: AppElementsWire.encodeRouteId(targetMatchedUrl, null), + }; +} + type TestPendingDispositionOptions = { activeNavigationId: number; currentRootLayoutTreePath: string | null; @@ -145,8 +163,11 @@ async function resolveTestPendingNavigationCommitDispositionDecision( }); } -function createControllerHarness(initialState: AppRouterState = createState()) { - const controller = createAppBrowserNavigationController(); +function createControllerHarness( + initialState: AppRouterState = createState(), + deps?: Parameters[0], +) { + const controller = createAppBrowserNavigationController(deps); const stateRef: { current: AppRouterState } = { current: initialState }; const setBrowserRouterState = vi.fn((value: AppRouterState | Promise) => { if (!(value instanceof Promise)) { @@ -166,6 +187,7 @@ function createControllerHarness(initialState: AppRouterState = createState()) { type ApprovedTestCommitOptions = { activeNavigationId?: number; extraEntries?: Record; + interception?: AppElementsInterception | null; interceptionContext?: string | null; layoutIds?: readonly string[]; layoutFlags?: AppRouterState["layoutFlags"]; @@ -198,6 +220,7 @@ async function applyApprovedTestCommit( }, options.layoutIds, options.slotBindings, + options.interception ?? null, ), ), navigationSnapshot: options.navigationSnapshot ?? state.navigationSnapshot, @@ -581,9 +604,17 @@ describe("app browser entry state helpers", () => { const pending = await createPendingNavigationCommit({ currentState: createState(), nextElements: Promise.resolve( - createResolvedElements("route:/photos/42\0/feed", "/", "/feed", { - "page:/photos/42": React.createElement("main", null, "photo"), - }), + createResolvedElements( + "route:/photos/42\0/feed", + "/", + "/feed", + { + "page:/photos/42": React.createElement("main", null, "photo"), + }, + [AppElementsWire.encodeLayoutId("/")], + [], + createInterceptionProof("/feed", "/photos/42"), + ), ), navigationSnapshot: createState().navigationSnapshot, operationLane: "navigation", @@ -593,8 +624,10 @@ describe("app browser entry state helpers", () => { }); expect(pending.routeId).toBe("route:/photos/42\0/feed"); + expect(pending.interception).toEqual(createInterceptionProof("/feed", "/photos/42")); expect(pending.interceptionContext).toBe("/feed"); expect(pending.previousNextUrl).toBe("/feed"); + expect(pending.action.interception).toEqual(createInterceptionProof("/feed", "/photos/42")); expect(pending.action.interceptionContext).toBe("/feed"); expect(pending.action.previousNextUrl).toBe("/feed"); }); @@ -989,6 +1022,72 @@ describe("app browser entry state helpers", () => { ]); }); + it("normalizes encoded route-state paths before validating interception proof", async () => { + const slotId = AppElementsWire.encodeSlotId("modal", "/café"); + const currentState = createState({ + layoutIds: [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/café")], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/caf%C3%A9", {}), + rootLayoutTreePath: "/", + routeId: AppElementsWire.encodeRouteId("/café", null), + slotBindings: [ + { + ownerLayoutId: AppElementsWire.encodeLayoutId("/café"), + slotId, + state: "default", + }, + ], + }); + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve( + createResolvedElements( + AppElementsWire.encodeRouteId("/photos/café", "/caf%C3%A9"), + "/", + "/caf%C3%A9", + { + "page:/photos/café": React.createElement("main", null, "photo"), + }, + [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/café")], + [ + { + ownerLayoutId: AppElementsWire.encodeLayoutId("/café"), + slotId, + state: "active", + }, + ], + createInterceptionProof("/café", "/photos/café", slotId), + ), + ), + navigationSnapshot: createClientNavigationRenderSnapshot( + "https://example.com/photos/caf%C3%A9", + {}, + ), + operationLane: "navigation", + previousNextUrl: "/caf%C3%A9", + renderId: 1, + type: "navigate", + }); + + const decision = resolvePendingNavigationCommitDispositionDecision({ + activeNavigationId: 1, + currentState, + pending, + startedNavigationId: 1, + }); + + expect(decision.disposition).toBe("dispatch"); + if (decision.disposition !== "dispatch") { + throw new Error("Expected dispatch decision"); + } + expect(decision.preserveElementIds).toEqual([ + AppElementsWire.encodeLayoutId("/"), + AppElementsWire.encodeLayoutId("/café"), + ]); + expect(decision.trace.entries[0]?.code).toBe( + NavigationTraceReasonCodes.interceptedCommitCurrent, + ); + }); + it("builds a merge commit for refresh and server-action payloads", async () => { const refreshCommit = await createPendingNavigationCommit({ currentState: createState(), @@ -1003,7 +1102,7 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.action.type).toBe("navigate"); expect(refreshCommit.routeId).toBe("route:/dashboard"); expect(refreshCommit.rootLayoutTreePath).toBe("/"); - expect(refreshCommit.previousNextUrl).toBe("/feed"); + expect(refreshCommit.previousNextUrl).toBeNull(); }); it("creates an approved visible commit only after the current operation decision allows mutation", async () => { @@ -1311,14 +1410,37 @@ describe("app browser entry state helpers", () => { }); it("stores previousNextUrl on approved navigate commits", async () => { - const state = createState(); + const state = createState({ + layoutIds: [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/feed")], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/feed", {}), + routeId: "route:/feed", + slotBindings: [ + { + ownerLayoutId: AppElementsWire.encodeLayoutId("/feed"), + slotId: AppElementsWire.encodeSlotId("modal", "/feed"), + state: "default", + }, + ], + }); + const interception = createInterceptionProof("/feed", "/photos/42"); const nextState = await applyApprovedTestCommit(state, { + interception, interceptionContext: "/feed", + layoutIds: [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/feed")], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/photos/42", {}), previousNextUrl: "/feed", rootLayoutTreePath: "/", routeId: "route:/photos/42\0/feed", + slotBindings: [ + { + ownerLayoutId: AppElementsWire.encodeLayoutId("/feed"), + slotId: AppElementsWire.encodeSlotId("modal", "/feed"), + state: "active", + }, + ], }); + expect(nextState.interception).toEqual(interception); expect(nextState.interceptionContext).toBe("/feed"); expect(nextState.previousNextUrl).toBe("/feed"); }); @@ -1584,6 +1706,46 @@ describe("app browser navigation controller", () => { } }); + it("syncs cleared previousNextUrl after same-URL server action commits", async () => { + const interception = createInterceptionProof("/feed", "/photos/42"); + const initialState = createState({ + interception, + interceptionContext: "/feed", + previousNextUrl: "/feed", + rootLayoutTreePath: "/", + routeId: "route:/photos/42\0/feed", + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/photos/42", {}), + }); + const syncHistoryStatePreviousNextUrl = vi.fn(); + const { controller, detach, stateRef } = createControllerHarness(initialState, { + syncHistoryStatePreviousNextUrl, + }); + const { assign } = stubWindow("https://example.com/photos/42"); + const nextElements = Promise.resolve( + createResolvedElements("route:/photos/42", "/", null, { + "page:/photos/42": React.createElement("main", null, "photo page"), + }), + ); + + try { + await controller.commitSameUrlNavigatePayload( + nextElements, + stateRef.current.navigationSnapshot, + undefined, + stateRef.current, + { targetHref: "https://example.com/photos/42" }, + ); + + expect(assign).not.toHaveBeenCalled(); + expect(stateRef.current.routeId).toBe("route:/photos/42"); + expect(stateRef.current.previousNextUrl).toBeNull(); + expect(syncHistoryStatePreviousNextUrl).toHaveBeenCalledTimes(1); + expect(syncHistoryStatePreviousNextUrl).toHaveBeenCalledWith(null); + } finally { + detach(); + } + }); + it("does not let older same-URL server action payloads overwrite newer visible commits", async () => { const initialState = createState({ rootLayoutTreePath: "/", diff --git a/tests/app-elements.test.ts b/tests/app-elements.test.ts index 0c3946f14..e018f8c14 100644 --- a/tests/app-elements.test.ts +++ b/tests/app-elements.test.ts @@ -6,6 +6,7 @@ import { UNMATCHED_SLOT } from "../packages/vinext/src/shims/slot.js"; import { APP_ARTIFACT_COMPATIBILITY_KEY, AppElementsWire, + APP_INTERCEPTION_KEY, APP_INTERCEPTION_CONTEXT_KEY, APP_LAYOUT_IDS_KEY, APP_LAYOUT_FLAGS_KEY, @@ -69,6 +70,7 @@ describe("AppElementsWire", () => { expect(decoded["slot:modal:/"]).toBe(UNMATCHED_SLOT); expect(AppElementsWire.readMetadata(decoded)).toEqual({ artifactCompatibility: createArtifactCompatibilityEnvelope(), + interception: null, interceptionContext: "/feed", layoutIds: [], layoutFlags: {}, @@ -94,6 +96,56 @@ describe("AppElementsWire", () => { }); }); + it("round-trips explicit interception proof metadata through the codec", () => { + const interception = { + sourceMatchedUrl: "/feed", + sourceRouteId: AppElementsWire.encodeRouteId("/feed", null), + slotId: AppElementsWire.encodeSlotId("modal", "/feed"), + targetMatchedUrl: "/photos/42", + targetRouteId: AppElementsWire.encodeRouteId("/photos/42", null), + }; + const metadata = AppElementsWire.createMetadataEntries({ + interception, + interceptionContext: "/feed", + rootLayoutTreePath: "/", + routeId: AppElementsWire.encodeRouteId("/photos/42", "/feed"), + }); + + expect(metadata[APP_INTERCEPTION_KEY]).toEqual(interception); + expect(AppElementsWire.readMetadata(metadata).interception).toEqual(interception); + }); + + it("rejects malformed path URLs in explicit interception proof metadata", () => { + const validInterception = { + sourceMatchedUrl: "/feed", + sourceRouteId: AppElementsWire.encodeRouteId("/feed", null), + slotId: AppElementsWire.encodeSlotId("modal", "/feed"), + targetMatchedUrl: "/photos/42", + targetRouteId: AppElementsWire.encodeRouteId("/photos/42", null), + }; + const malformedMatchedUrls = [ + "//example.test/feed", + "/feed?tab=latest", + "/feed#modal", + "/fe\0ed", + ]; + + for (const sourceMatchedUrl of malformedMatchedUrls) { + expect(() => + readAppElementsMetadata( + normalizeAppElements({ + [APP_INTERCEPTION_KEY]: { + ...validInterception, + sourceMatchedUrl, + }, + [APP_ROOT_LAYOUT_KEY]: "/", + [APP_ROUTE_KEY]: AppElementsWire.encodeRouteId("/photos/42", "/feed"), + }), + ), + ).toThrow("[vinext] Invalid __interception in App Router payload: expected path URLs"); + } + }); + it("normalizes slot binding metadata at the wire boundary", () => { const metadata = AppElementsWire.createMetadataEntries({ interceptionContext: null, @@ -235,6 +287,7 @@ describe("AppElementsWire", () => { expect(AppElementsWire.readMetadata(payload)).toEqual({ artifactCompatibility: createArtifactCompatibilityEnvelope(), + interception: null, interceptionContext: null, layoutIds: [], layoutFlags: { [AppElementsWire.encodeLayoutId("/")]: "s" }, diff --git a/tests/app-page-element-builder.test.ts b/tests/app-page-element-builder.test.ts index bd89efd1f..d141b9429 100644 --- a/tests/app-page-element-builder.test.ts +++ b/tests/app-page-element-builder.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; import React from "react"; import { + APP_INTERCEPTION_KEY, APP_INTERCEPTION_CONTEXT_KEY, APP_LAYOUT_IDS_KEY, APP_ROOT_LAYOUT_KEY, @@ -279,6 +280,58 @@ describe("buildPageElements", () => { ]); }); + it("normalizes encoded interception proof paths before encoding route IDs", async () => { + function TestPage(): React.ReactNode { + return React.createElement("div", null, "Hello"); + } + function TestLayout({ children }: { children?: React.ReactNode }): React.ReactNode { + return children; + } + + const route = createSyntheticRoute({ + page: createSyntheticPageModule(TestPage), + layouts: [createSyntheticPageModule(TestLayout), createSyntheticPageModule(TestLayout)], + layoutTreePositions: [0, 1], + routeSegments: ["café"], + pattern: "/café", + slots: { + "modal@café/@modal": { + id: "slot:modal:/café", + name: "modal", + default: createSyntheticPageModule(() => null), + layoutIndex: 1, + routeSegments: null, + }, + }, + }); + + const result = await buildPageElements( + createBaseOptions({ + route, + routePath: "/photos/caf%C3%A9", + opts: { + interceptionContext: "/caf%C3%A9", + interceptSourceMatchedUrl: "/caf%C3%A9", + interceptSlotId: "slot:modal:/café", + interceptSlotKey: "modal@café/@modal", + interceptPage: createSyntheticPageModule(() => + React.createElement("div", null, "Intercepted"), + ), + interceptParams: { id: "café" }, + } as Record, + }), + ); + const record = result as Record; + + expect(record[APP_INTERCEPTION_KEY]).toEqual({ + sourceMatchedUrl: "/café", + sourceRouteId: "route:/café", + slotId: "slot:modal:/café", + targetMatchedUrl: "/photos/café", + targetRouteId: "route:/photos/café", + }); + }); + it("rejects graph slot ids that diverge from the wire slot id", async () => { function TestPage(): React.ReactNode { return React.createElement("div", null, "Hello"); diff --git a/tests/app-router.test.ts b/tests/app-router.test.ts index 95f0a8432..05c116a6c 100644 --- a/tests/app-router.test.ts +++ b/tests/app-router.test.ts @@ -744,7 +744,10 @@ describe("App Router integration", () => { // to /team/[teamId]/settings. The source route has a dynamic :teamId segment. // The intercepting route handler must extract "42" from the URL, not ":teamId". const res = await fetch(`${baseUrl}/team/42/settings.rsc`, { - headers: { Accept: "text/x-component" }, + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/team/42/members", + }, }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/x-component"); @@ -4317,13 +4320,19 @@ describe("App Router middleware with NextRequest", () => { // which must merge _mwCtx.headers into the Response — same as the normal // page path through buildAppPageRscResponse(). const res = await fetch(`${baseUrl}/photos/42.rsc`, { - headers: { Accept: "text/x-component" }, + headers: { + Accept: "text/x-component", + "X-Vinext-Interception-Context": "/feed", + }, }); expect(res.status).toBe(200); expect(res.headers.get("content-type")).toContain("text/x-component"); // Middleware sets x-mw-ran and x-mw-pathname on all matched paths expect(res.headers.get("x-mw-ran")).toBe("true"); expect(res.headers.get("x-mw-pathname")).toBe("/photos/42"); + const payload = await res.text(); + expect(payload).toContain("Photo Modal"); + expect(payload).toContain("Photo Feed"); }); }); diff --git a/tests/app-rsc-route-matching.test.ts b/tests/app-rsc-route-matching.test.ts index e1d6affc4..315c98807 100644 --- a/tests/app-rsc-route-matching.test.ts +++ b/tests/app-rsc-route-matching.test.ts @@ -122,6 +122,26 @@ describe("App RSC route matching", () => { }); }); + it("does not treat a target match as an intercept without a matching source route", () => { + const matcher = createAppRscRouteMatcher([ + route("/feed", ["feed"], { + modal: { + intercepts: [ + { + targetPattern: "/photos/:id", + interceptLayouts: ["modal-layout"], + page: "photo-page", + params: ["id"], + }, + ], + }, + }), + ]); + + expect(matcher.findIntercept("/photos/42", null)).toBeNull(); + expect(matcher.findIntercept("/photos/42", "/gallery")).toBeNull(); + }); + it("canonicalizes encoded source path parts for interception params", () => { const matcher = createAppRscRouteMatcher([ route("/_sites/:tenant", ["_sites", ":tenant"], { diff --git a/tests/e2e/app-router/advanced.spec.ts b/tests/e2e/app-router/advanced.spec.ts index 354b223c2..1dd0b446c 100644 --- a/tests/e2e/app-router/advanced.spec.ts +++ b/tests/e2e/app-router/advanced.spec.ts @@ -129,6 +129,31 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); }); + test("refresh after chained intercepted navigation keeps the proven source context", async ({ + page, + }) => { + // Same user-visible contract as Next.js intercepted refresh coverage: + // test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-revalidation/parallel-routes-revalidation.test.ts + await page.goto(`${BASE}/feed`); + await waitForAppRouterHydration(page); + + await page.click("#feed-photo-42-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 42"); + + await page.click("#modal-photo-43-link"); + await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 43"); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + + await page.click('[data-testid="photo-modal-refresh"]'); + + await expect(page.locator('[data-testid="photo-modal"]')).toContainText("Viewing photo 43"); + await expect(page.locator('[data-testid="feed-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-page"]')).not.toBeVisible(); + expect(new URL(page.url()).pathname).toBe("/photos/43"); + }); + test("refresh on direct photo load preserves the full-page render", async ({ page }) => { await page.goto(`${BASE}/photos/42`); await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); @@ -140,6 +165,61 @@ test.describe("Intercepting Routes", () => { await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); }); + test("refresh on direct target clears stale intercepted history context", async ({ page }) => { + const refreshInterceptionHeaders: Array = []; + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.pathname === "/photos/42.rsc") { + refreshInterceptionHeaders.push(request.headers()["x-vinext-interception-context"] ?? null); + } + }); + + await page.goto(`${BASE}/photos/42`); + await waitForAppRouterHydration(page); + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + + await page.evaluate(() => { + const currentState = window.history.state; + const nextState = + currentState && typeof currentState === "object" ? Object.assign({}, currentState) : {}; + Reflect.set(nextState, "__vinext_previousNextUrl", "/feed"); + window.history.replaceState(nextState, "", window.location.href); + }); + await expect + .poll(() => + page.evaluate(() => { + const currentState = window.history.state; + if (!currentState || typeof currentState !== "object") return null; + const value = Reflect.get(currentState, "__vinext_previousNextUrl"); + return typeof value === "string" ? value : null; + }), + ) + .toBe("/feed"); + + await page.evaluate(async () => { + const navigate = Reflect.get(window, "__VINEXT_RSC_NAVIGATE__"); + if (typeof navigate !== "function") { + throw new Error("Expected Vinext RSC navigation executor to be installed"); + } + await navigate(window.location.href, 0, "refresh", undefined, undefined, true); + }); + + await expect.poll(() => refreshInterceptionHeaders.length).toBeGreaterThan(0); + expect(refreshInterceptionHeaders.at(-1)).toBeNull(); + await expect(page.locator('[data-testid="photo-page"]')).toBeVisible(); + await expect(page.locator('[data-testid="photo-modal"]')).not.toBeVisible(); + await expect + .poll(() => + page.evaluate(() => { + const currentState = window.history.state; + if (!currentState || typeof currentState !== "object") return null; + const value = Reflect.get(currentState, "__vinext_previousNextUrl"); + return typeof value === "string" ? value : null; + }), + ) + .toBeNull(); + }); + test("hard reload after intercepted navigation renders the full page", async ({ page }) => { await page.goto(`${BASE}/feed`); await waitForAppRouterHydration(page); diff --git a/tests/navigation-planner.test.ts b/tests/navigation-planner.test.ts index c1e828d87..5f083d8ad 100644 --- a/tests/navigation-planner.test.ts +++ b/tests/navigation-planner.test.ts @@ -15,6 +15,7 @@ import { type ParallelSlotBindingSnapshotV0, type RefreshScope, type RouteSnapshotV0, + type InterceptionSnapshotV0, type RootBoundaryTransition, } from "../packages/vinext/src/server/navigation-planner.js"; @@ -26,6 +27,8 @@ function createRouteSnapshot( ): RouteSnapshotV0 { return { displayUrl: "https://example.com/dashboard", + interception: null, + interceptionContext: null, layoutIds, matchedUrl: "/dashboard", mountedParallelSlots, @@ -35,6 +38,19 @@ function createRouteSnapshot( }; } +function createInterceptionSnapshot( + overrides: Partial = {}, +): InterceptionSnapshotV0 { + return { + sourceMatchedUrl: "/feed", + sourceRouteId: "route:/feed", + slotId: "slot:modal:/feed", + targetMatchedUrl: "/photos/42", + targetRouteId: "route:/photos/42", + ...overrides, + }; +} + function createSlotBinding( slotId: string, ownerLayoutId: string, @@ -534,6 +550,189 @@ describe("navigationPlanner root-boundary decisions", () => { expect(decision.proposal.preservePreviousSlotIds).toEqual([]); }); + it("approves intercepted preservation only from explicit source and slot proof", () => { + // Core-15 oracle, porting the visible behavior from Next.js: + // test/e2e/app-dir/parallel-routes-and-interception-catchall/parallel-routes-and-interception-catchall.test.ts + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/parallel-routes-and-interception-catchall/parallel-routes-and-interception-catchall.test.ts + const currentSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [ + createSlotBinding("slot:modal:/feed", "layout:/feed", "default"), + createSlotBinding("slot:activity:/feed", "layout:/feed", "active"), + ], + ), + displayUrl: "https://example.com/feed", + matchedUrl: "/feed", + routeId: "route:/feed", + }; + const targetSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [ + createSlotBinding("slot:activity:/feed", "layout:/feed", "default"), + createSlotBinding("slot:modal:/feed", "layout:/feed", "active"), + ], + ), + displayUrl: "https://example.com/photos/42", + interception: createInterceptionSnapshot(), + interceptionContext: "/feed", + matchedUrl: "/photos/42", + routeId: "route:/photos/42\u0000/feed", + }; + + const decision = planFlightResponseFromSnapshots({ currentSnapshot, targetSnapshot }); + + expect(decision.kind).toBe("proposeCommit"); + if (decision.kind !== "proposeCommit") { + throw new Error("Expected proposeCommit decision"); + } + expect(decision.proposal.reason).toBe("interceptedCurrentRootBoundary"); + expect(decision.proposal.preserveAbsentSlots).toBe(false); + expect(decision.proposal.preserveElementIds).toEqual(["layout:/", "layout:/feed"]); + expect(decision.proposal.preservePreviousSlotIds).toEqual(["slot:activity:/feed"]); + expect(decision.trace.entries[0]?.code).toBe( + NavigationTraceReasonCodes.interceptedCommitCurrent, + ); + }); + + it("rejects intercepted payloads that only carry legacy context metadata", () => { + const currentSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), + matchedUrl: "/feed", + routeId: "route:/feed", + }; + const targetSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), + displayUrl: "https://example.com/photos/42", + interceptionContext: "/feed", + matchedUrl: "/photos/42", + routeId: "route:/photos/42\u0000/feed", + }; + + const decision = planFlightResponseFromSnapshots({ currentSnapshot, targetSnapshot }); + + expect(decision.kind).toBe("hardNavigate"); + if (decision.kind !== "hardNavigate") { + throw new Error("Expected hardNavigate decision"); + } + expect(decision.reason).toBe("interceptionProofRejected"); + expect(decision.trace.entries[0]?.code).toBe( + NavigationTraceReasonCodes.interceptedRejectedMissingProof, + ); + }); + + it("rejects intercepted preservation when the visible source route is stale", () => { + const currentSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot("/", ["layout:/", "layout:/gallery"]), + matchedUrl: "/gallery", + routeId: "route:/gallery", + }; + const targetSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [createSlotBinding("slot:modal:/feed", "layout:/feed", "active")], + ), + displayUrl: "https://example.com/photos/42", + interception: createInterceptionSnapshot(), + interceptionContext: "/feed", + matchedUrl: "/photos/42", + routeId: "route:/photos/42\u0000/feed", + }; + + const decision = planFlightResponseFromSnapshots({ currentSnapshot, targetSnapshot }); + + expect(decision.kind).toBe("hardNavigate"); + if (decision.kind !== "hardNavigate") { + throw new Error("Expected hardNavigate decision"); + } + expect(decision.reason).toBe("interceptionProofRejected"); + expect(decision.trace.entries[0]?.code).toBe( + NavigationTraceReasonCodes.interceptedRejectedUnknownSource, + ); + }); + + it("rejects intercepted preservation when the target slot is not proven active", () => { + const currentSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), + matchedUrl: "/feed", + routeId: "route:/feed", + }; + const targetSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [createSlotBinding("slot:modal:/feed", "layout:/feed", "default")], + ), + displayUrl: "https://example.com/photos/42", + interception: createInterceptionSnapshot(), + interceptionContext: "/feed", + matchedUrl: "/photos/42", + routeId: "route:/photos/42\u0000/feed", + }; + + const decision = planFlightResponseFromSnapshots({ currentSnapshot, targetSnapshot }); + + expect(decision.kind).toBe("hardNavigate"); + if (decision.kind !== "hardNavigate") { + throw new Error("Expected hardNavigate decision"); + } + expect(decision.reason).toBe("interceptionProofRejected"); + expect(decision.trace.entries[0]?.code).toBe( + NavigationTraceReasonCodes.interceptedRejectedMissingSlotProof, + ); + }); + + it("allows traverse to restore an intercepted visible world only with proof", () => { + const currentSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [createSlotBinding("slot:activity:/feed", "layout:/feed", "active")], + ), + displayUrl: "https://example.com/feed", + matchedUrl: "/feed", + routeId: "route:/feed", + }; + const targetSnapshot: RouteSnapshotV0 = { + ...createRouteSnapshot( + "/", + ["layout:/", "layout:/feed"], + [], + [ + createSlotBinding("slot:activity:/feed", "layout:/feed", "default"), + createSlotBinding("slot:modal:/feed", "layout:/feed", "active"), + ], + ), + displayUrl: "https://example.com/photos/42", + interception: createInterceptionSnapshot(), + interceptionContext: "/feed", + matchedUrl: "/photos/42", + routeId: "route:/photos/42\u0000/feed", + }; + + const decision = planFlightResponseFromSnapshots({ + currentSnapshot, + lane: "traverse", + targetSnapshot, + }); + + expect(decision.kind).toBe("proposeCommit"); + if (decision.kind !== "proposeCommit") { + throw new Error("Expected proposeCommit decision"); + } + expect(decision.proposal.reason).toBe("interceptedCurrentRootBoundary"); + expect(decision.proposal.preservePreviousSlotIds).toEqual(["slot:activity:/feed"]); + }); + it("does not preserve layouts across root-boundary uncertainty", () => { const currentSnapshot = createRouteSnapshot("/", ["layout:/"]); const targetSnapshot = createRouteSnapshot(null, ["layout:/"]);