From e63e6ad3744115228bf6934fa60fcee8067d15fb Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 12:27:06 +1000 Subject: [PATCH 1/4] feat(router): promote intercepted preservation through planner Intercepted navigation could still preserve source UI through context-bearing payload shape and same-URL history state. That made modal preservation depend on transport metadata instead of the visible route world that had actually been committed. The planner now requires explicit interception proof before preserving source layouts or unrelated slots, and the browser lifecycle clears stale previous-next history state when normal target payloads commit. Wire metadata carries proof, while missing, stale, or incompatible proof falls back to hard navigation. Tests cover approved source-slot preservation, missing, stale, and malformed proof rejection, current-context refresh and action handling, traverse restoration, and direct-target refresh clearing stale interception state. --- packages/vinext/src/entries/app-rsc-entry.ts | 2 + .../vinext/src/server/app-browser-entry.ts | 49 ++++- .../app-browser-navigation-controller.ts | 3 + .../vinext/src/server/app-browser-state.ts | 13 +- .../src/server/app-browser-visible-commit.ts | 4 + .../vinext/src/server/app-elements-wire.ts | 105 ++++++++- packages/vinext/src/server/app-elements.ts | 2 + .../vinext/src/server/app-page-dispatch.ts | 5 + .../src/server/app-page-element-builder.ts | 24 ++- .../vinext/src/server/app-page-request.ts | 1 + .../src/server/app-page-route-wiring.tsx | 8 +- .../src/server/app-rsc-route-matching.ts | 21 +- .../src/server/app-server-action-execution.ts | 1 + .../vinext/src/server/navigation-planner.ts | 169 ++++++++++++++- .../vinext/src/server/navigation-trace.ts | 10 + packages/vinext/src/shims/slot.tsx | 19 +- tests/app-browser-entry.test.ts | 110 +++++++++- tests/app-elements.test.ts | 53 +++++ tests/app-router.test.ts | 13 +- tests/app-rsc-route-matching.test.ts | 20 ++ tests/e2e/app-router/advanced.spec.ts | 80 +++++++ tests/navigation-planner.test.ts | 199 ++++++++++++++++++ 22 files changed, 876 insertions(+), 35 deletions(-) diff --git a/packages/vinext/src/entries/app-rsc-entry.ts b/packages/vinext/src/entries/app-rsc-entry.ts index 57ab27b2c..a9a3b4b88 100644 --- a/packages/vinext/src/entries/app-rsc-entry.ts +++ b/packages/vinext/src/entries/app-rsc-entry.ts @@ -736,7 +736,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 57197cec6..5f59541e1 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,22 @@ function clearClientNavigationCaches(): void { clearPrefetchState(); } +function syncCurrentHistoryStatePreviousNextUrl(previousNextUrl: string | null): void { + if (readHistoryStatePreviousNextUrl(window.history.state) === previousNextUrl) { + return; + } + + const nextHistoryState = createHistoryStateWithPreviousNextUrl( + window.history.state, + previousNextUrl, + ); + 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 +283,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. @@ -436,11 +463,22 @@ function getRequestState( } 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 +538,7 @@ function BrowserRoot({ const [treeStateValue, setTreeStateValue] = useState>({ activeOperation: null, elements: resolvedElements, + interception: initialMetadata.interception, interceptionContext: initialMetadata.interceptionContext, layoutIds: initialMetadata.layoutIds, layoutFlags: initialMetadata.layoutFlags, @@ -830,6 +869,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, @@ -1049,6 +1089,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 a66fe4bdb..5eb1a82c9 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"; @@ -56,6 +57,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[]; @@ -70,6 +72,7 @@ export type AppRouterState = { export type AppRouterAction = { elements: AppElements; + interception: AppElementsInterception | null; interceptionContext: string | null; layoutFlags: LayoutFlags; layoutIds: readonly string[]; @@ -85,6 +88,7 @@ export type AppRouterAction = { export type PendingNavigationCommit = { action: AppRouterAction; + interception: AppElementsInterception | null; interceptionContext: string | null; previousNextUrl: string | null; rootLayoutTreePath: string | null; @@ -252,6 +256,8 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { const displayUrl = createNavigationSnapshotUrl(state.navigationSnapshot); 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 @@ -268,6 +274,8 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap const displayUrl = createNavigationSnapshotUrl(pending.action.navigationSnapshot); 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. @@ -375,14 +383,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, @@ -400,6 +410,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..827bda29f 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; @@ -307,10 +327,11 @@ function createAppElementsWireMetadataEntries( const slotBindings = normalizeAppElementsSlotBindings(input.slotBindings, { layoutIds }); return { ...entries, + ...(input.interception ? { [APP_INTERCEPTION_KEY]: input.interception } : {}), [APP_SLOT_BINDINGS_KEY]: slotBindings, }; } - return entries; + return input.interception ? { ...entries, [APP_INTERCEPTION_KEY]: input.interception } : entries; } export function normalizeAppElements(elements: AppWireElements): AppElements { @@ -428,6 +449,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 +543,11 @@ export function withLayoutFlags>( } export function buildOutgoingAppPayload(input: { - element: ReactNode | Readonly>; + element: + | ReactNode + | Readonly< + Record + >; artifactCompatibility?: ArtifactCompatibilityEnvelope; layoutFlags: LayoutFlags; renderObservation?: RenderObservation; @@ -465,6 +560,7 @@ export function buildOutgoingAppPayload(input: { | ReactNode | LayoutFlags | ArtifactCompatibilityEnvelope + | AppElementsInterception | RenderObservation | readonly AppElementsSlotBinding[] > = { @@ -518,12 +614,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 +636,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 7225a8413..84b4e80b5 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 = { @@ -312,7 +315,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..fa14cc733 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -10,7 +10,7 @@ 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 type { MetadataFileRoute } from "./metadata-routes.js"; @@ -38,7 +38,9 @@ export type AppPageInterceptOptions = { @@ -118,6 +120,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 +139,7 @@ export async function buildPageElements< } return { ...AppElementsWire.createMetadataEntries({ + interception, interceptionContext, layoutIds: noExportLayoutIds, rootLayoutTreePath: noExportRootLayout, @@ -193,6 +197,7 @@ export async function buildPageElements< resolvedMetadata, resolvedViewport, interceptionContext: opts?.interceptionContext ?? null, + interception, routePath, rootNotFoundModule: rootNotFoundModule ?? null, rootForbiddenModule: rootForbiddenModule ?? null, @@ -203,6 +208,23 @@ export async function buildPageElements< }); } +function createAppPageInterceptionProof( + routePath: string, + opts?: AppPageInterceptOptions | null, +): AppElementsInterception | null { + const sourceMatchedUrl = opts?.interceptSourceMatchedUrl ?? null; + const slotId = opts?.interceptSlotId ?? null; + if (sourceMatchedUrl === null || slotId === null) return null; + + return { + sourceMatchedUrl, + sourceRouteId: AppElementsWire.encodeRouteId(sourceMatchedUrl, null), + slotId, + targetMatchedUrl: routePath, + targetRouteId: AppElementsWire.encodeRouteId(routePath, null), + }; +} + /** * 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 96bfbb334..93655e4fd 100644 --- a/packages/vinext/src/server/app-server-action-execution.ts +++ b/packages/vinext/src/server/app-server-action-execution.ts @@ -93,6 +93,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..1e29295af 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.interceptedRejectedUnknownSource, + }; + } + + 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,42 @@ function planFlightResponseArrived(options: { }; } + const targetSnapshot = options.event.result.targetSnapshot; + 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 +535,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 +557,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..0dbb2d65a 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -4,6 +4,11 @@ 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", + interceptedRejectedUnknownSource: "NC_INTERCEPT_REJECT_SOURCE", prefetchOnly: "NC_PREFETCH_ONLY", requestWork: "NC_REQUEST", rootBoundaryChanged: "NC_ROOT", @@ -11,6 +16,11 @@ 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"; + 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..c27db63f7 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,28 @@ 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 && + "sourceRouteId" in value && + "slotId" in value && + "targetMatchedUrl" in value && + "targetRouteId" in value + ); +} + 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 8045f32b5..5759d2bec 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -34,6 +34,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"; @@ -75,9 +76,11 @@ function createResolvedElements( ? [] : [AppElementsWire.encodeLayoutId(rootLayoutTreePath)], slotBindings: readonly AppElementsSlotBinding[] = [], + interception: AppElementsInterception | null = null, ) { return normalizeAppElements({ ...AppElementsWire.createMetadataEntries({ + interception, interceptionContext, layoutIds, rootLayoutTreePath, @@ -91,6 +94,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", {}), @@ -106,6 +110,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; @@ -146,8 +164,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)) { @@ -167,6 +188,7 @@ function createControllerHarness(initialState: AppRouterState = createState()) { type ApprovedTestCommitOptions = { activeNavigationId?: number; extraEntries?: Record; + interception?: AppElementsInterception | null; interceptionContext?: string | null; layoutIds?: readonly string[]; layoutFlags?: AppRouterState["layoutFlags"]; @@ -199,6 +221,7 @@ async function applyApprovedTestCommit( }, options.layoutIds, options.slotBindings, + options.interception ?? null, ), ), navigationSnapshot: options.navigationSnapshot ?? state.navigationSnapshot, @@ -592,9 +615,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", @@ -604,8 +635,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"); }); @@ -1014,7 +1047,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 () => { @@ -1322,14 +1355,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"); }); @@ -1595,6 +1651,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-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:/"]); From ddd20711dc7d29e001553c346cb7bf192d9425ad Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 16:57:56 +1000 Subject: [PATCH 2/4] fix(router): normalize intercepted proof paths Interception proof could be built from browser-derived percent-encoded paths while committed route snapshots used the server-normalized route-state form. Non-ASCII intercepted source or target paths could then fail proof validation and fall back to a hard navigation. Normalize proof matched URLs before encoding source and target route IDs, and normalize client planner snapshot matched URLs to the same route-state representation without changing the user-facing navigation snapshot. Adds regressions for encoded non-ASCII proof generation and planner validation. --- .../vinext/src/server/app-browser-state.ts | 21 ++++-- .../src/server/app-page-element-builder.ts | 28 ++++++-- tests/app-browser-entry.test.ts | 66 +++++++++++++++++++ tests/app-page-element-builder.test.ts | 53 +++++++++++++++ 4 files changed, 159 insertions(+), 9 deletions(-) diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 5eb1a82c9..4cbd1aca6 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -30,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"; export { createHistoryStateWithPreviousNextUrl, readHistoryStatePreviousNextUrl, @@ -125,6 +127,10 @@ function createOperationRecord(options: { }; } +function normalizeNavigationSnapshotMatchedUrl(pathname: string): string { + return normalizePath(normalizePathnameForRouteMatch(pathname)); +} + export function resolveInterceptionContextFromPreviousNextUrl( previousNextUrl: string | null, basePath: string = "", @@ -254,15 +260,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, @@ -272,6 +280,9 @@ 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, @@ -279,7 +290,7 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap 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, diff --git a/packages/vinext/src/server/app-page-element-builder.ts b/packages/vinext/src/server/app-page-element-builder.ts index fa14cc733..c0adbcd6a 100644 --- a/packages/vinext/src/server/app-page-element-builder.ts +++ b/packages/vinext/src/server/app-page-element-builder.ts @@ -13,8 +13,10 @@ import { 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"; @@ -212,19 +214,37 @@ function createAppPageInterceptionProof( routePath: string, opts?: AppPageInterceptOptions | null, ): AppElementsInterception | null { - const sourceMatchedUrl = opts?.interceptSourceMatchedUrl ?? null; + const sourceMatchedUrl = normalizeInterceptionProofMatchedUrl( + opts?.interceptSourceMatchedUrl ?? null, + ); + const targetMatchedUrl = normalizeInterceptionProofMatchedUrl(routePath); const slotId = opts?.interceptSlotId ?? null; - if (sourceMatchedUrl === null || slotId === null) return null; + if (sourceMatchedUrl === null || targetMatchedUrl === null || slotId === null) return null; return { sourceMatchedUrl, sourceRouteId: AppElementsWire.encodeRouteId(sourceMatchedUrl, null), slotId, - targetMatchedUrl: routePath, - targetRouteId: AppElementsWire.encodeRouteId(routePath, null), + 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/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 5759d2bec..b17eb98e4 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1033,6 +1033,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(), 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"); From ca0c4badf54475321bce73afb1f1a747c20db087 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 17 May 2026 02:35:04 +1000 Subject: [PATCH 3/4] review: address ask-bonk PR review comments - Add interceptedRejectedTargetMismatch reason code for target URL mismatch in planner traces (was misusing UnknownSource) - Document double-attempt replaceState fallback for browser quirks - Clarify navigate branches: proven interception vs legacy fallback - Document cache migration safety for legacy interceptionContext - Tighten isInterceptionMetadataValue type guard with string checks - Extract interception attachment into shared step in wire metadata entries to remove asymmetric branching --- packages/vinext/src/server/app-browser-entry.ts | 13 +++++++++++++ packages/vinext/src/server/app-elements-wire.ts | 10 ++++++---- packages/vinext/src/server/navigation-planner.ts | 7 ++++++- packages/vinext/src/server/navigation-trace.ts | 2 ++ packages/vinext/src/shims/slot.tsx | 7 ++++++- 5 files changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 5f59541e1..73f5b42e7 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -245,6 +245,12 @@ function syncCurrentHistoryStatePreviousNextUrl(previousNextUrl: string | null): 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; @@ -462,6 +468,13 @@ 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": { const currentPreviousNextUrl = getBrowserRouterState().previousNextUrl; diff --git a/packages/vinext/src/server/app-elements-wire.ts b/packages/vinext/src/server/app-elements-wire.ts index 827bda29f..1018b3836 100644 --- a/packages/vinext/src/server/app-elements-wire.ts +++ b/packages/vinext/src/server/app-elements-wire.ts @@ -323,15 +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, - ...(input.interception ? { [APP_INTERCEPTION_KEY]: input.interception } : {}), - [APP_SLOT_BINDINGS_KEY]: slotBindings, + ...interceptionEntry, + [APP_SLOT_BINDINGS_KEY]: normalizeAppElementsSlotBindings(input.slotBindings, { layoutIds }), }; } - return input.interception ? { ...entries, [APP_INTERCEPTION_KEY]: input.interception } : entries; + return { ...entries, ...interceptionEntry }; } export function normalizeAppElements(elements: AppWireElements): AppElements { diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 1e29295af..202ec3076 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -405,7 +405,7 @@ function validateInterceptedPreservation(options: { if (proof.targetMatchedUrl !== options.targetSnapshot.matchedUrl) { return { kind: "rejected", - reasonCode: NavigationTraceReasonCodes.interceptedRejectedUnknownSource, + reasonCode: NavigationTraceReasonCodes.interceptedRejectedTargetMismatch, }; } @@ -476,6 +476,11 @@ 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) { diff --git a/packages/vinext/src/server/navigation-trace.ts b/packages/vinext/src/server/navigation-trace.ts index 0dbb2d65a..bcac04b65 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -8,6 +8,7 @@ export const NavigationTraceReasonCodes = { 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", @@ -20,6 +21,7 @@ export const NavigationTraceReasonCodes = { 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"; diff --git a/packages/vinext/src/shims/slot.tsx b/packages/vinext/src/shims/slot.tsx index c27db63f7..e13fd2125 100644 --- a/packages/vinext/src/shims/slot.tsx +++ b/packages/vinext/src/shims/slot.tsx @@ -76,10 +76,15 @@ function isInterceptionMetadataValue(value: unknown): value is AppElementsInterc 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 && - "targetRouteId" in value + typeof value.targetMatchedUrl === "string" && + "targetRouteId" in value && + typeof value.targetRouteId === "string" ); } From b84597ee57dbd135abbfe7744480ab106285d6a2 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sun, 17 May 2026 14:29:17 +1000 Subject: [PATCH 4/4] fix(router): ignore context-only interception metadata Context-only AppElements payloads could enter intercepted preservation planning or leave context-suffixed route identity as later proof authority. That made transport metadata influence visible-world preservation when a normal response was rendered from an intercepted request context. Only explicit interception proof now enters the preservation branch. Planner snapshots also canonicalize context-suffixed route IDs when no proof is present, keeping render keys partitioned while preventing stale context from poisoning future explicit interception proof. Tests cover the direct planner decision and the two-step browser lifecycle regression. --- .../vinext/src/server/app-browser-state.ts | 26 +++- .../vinext/src/server/navigation-planner.ts | 11 +- tests/app-browser-entry.test.ts | 114 ++++++++++++++++++ tests/navigation-planner.test.ts | 16 +-- 4 files changed, 150 insertions(+), 17 deletions(-) diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index 4cbd1aca6..f4bf74aa3 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -131,6 +131,22 @@ function normalizeNavigationSnapshotMatchedUrl(pathname: string): string { return normalizePath(normalizePathnameForRouteMatch(pathname)); } +function createRouteSnapshotRouteId(options: { + interception: AppElementsInterception | null; + routeId: string; +}): string { + if (options.interception !== null) return options.routeId; + + const parsed = AppElementsWire.parseElementKey(options.routeId); + if (parsed?.kind !== "route" || parsed.interceptionContext === null) { + return options.routeId; + } + + // A context suffix keeps AppElements render keys partitioned, but without + // explicit interception proof it is not semantic route authority. + return AppElementsWire.encodeRouteId(parsed.path, null); +} + export function resolveInterceptionContextFromPreviousNextUrl( previousNextUrl: string | null, basePath: string = "", @@ -273,7 +289,10 @@ function createVisibleRouteSnapshot(state: AppRouterState): RouteSnapshotV0 { matchedUrl, mountedParallelSlots: createMountedParallelSlotSnapshots(state.elements), rootBoundaryId: state.rootLayoutTreePath, - routeId: state.routeId, + routeId: createRouteSnapshotRouteId({ + interception: state.interception, + routeId: state.routeId, + }), slotBindings: state.slotBindings, }; } @@ -293,7 +312,10 @@ function createPendingRouteSnapshot(pending: PendingNavigationCommit): RouteSnap matchedUrl, mountedParallelSlots: createMountedParallelSlotSnapshots(pending.action.elements), rootBoundaryId: pending.rootLayoutTreePath, - routeId: pending.routeId, + routeId: createRouteSnapshotRouteId({ + interception: pending.action.interception, + routeId: pending.routeId, + }), slotBindings: pending.action.slotBindings, }; } diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index 202ec3076..e2bacc0e8 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -476,13 +476,10 @@ 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; + // interceptionContext is transport evidence, not authority. Normal payloads + // can carry it when a request was sent from an intercepted visible world, so + // only explicit __interception proof enters the preservation branch. + const hasInterceptedPayload = targetSnapshot.interception !== null; if (hasInterceptedPayload) { const validation = validateInterceptedPreservation({ currentSnapshot: options.state.visibleSnapshot, diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index b17eb98e4..f5f159924 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -1116,6 +1116,120 @@ describe("app browser entry state helpers", () => { expect(refreshCommit.previousNextUrl).toBeNull(); }); + it("commits non-intercepted context-only payloads without preserving stale interception state", async () => { + const currentState = createState({ + interception: createInterceptionProof("/feed", "/photos/42"), + interceptionContext: "/feed", + layoutIds: [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/feed")], + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/photos/42", {}), + previousNextUrl: "/feed", + rootLayoutTreePath: "/", + routeId: AppElementsWire.encodeRouteId("/photos/42", "/feed"), + }); + + const pending = await createPendingNavigationCommit({ + currentState, + nextElements: Promise.resolve( + createResolvedElements( + AppElementsWire.encodeRouteId("/feed", "/feed"), + "/", + "/feed", + { + [AppElementsWire.encodePageId("/feed", "/feed")]: React.createElement( + "main", + null, + "feed", + ), + }, + [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/feed")], + ), + ), + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/feed", {}), + operationLane: "refresh", + previousNextUrl: "/feed", + renderId: 1, + type: "navigate", + }); + + const decision = resolvePendingNavigationCommitDispositionDecision({ + activeNavigationId: 1, + currentState, + pending, + startedNavigationId: 1, + }); + + expect(pending.interception).toBeNull(); + expect(pending.interceptionContext).toBe("/feed"); + expect(pending.previousNextUrl).toBeNull(); + expect(decision.disposition).toBe("dispatch"); + expect(decision.trace.entries[0]?.code).toBe(NavigationTraceReasonCodes.commitCurrent); + + const approval = approvePendingNavigationCommit({ + activeNavigationId: 1, + currentState, + pending, + startedNavigationId: 1, + targetHref: "https://example.com/feed", + }); + if (approval.approvedCommit === null) { + throw new Error("Expected context-only payload to commit"); + } + const contextOnlyState = applyApprovedVisibleCommit(currentState, approval.approvedCommit); + expect(contextOnlyState.interception).toBeNull(); + expect(contextOnlyState.previousNextUrl).toBeNull(); + expect(contextOnlyState.routeId).toBe(AppElementsWire.encodeRouteId("/feed", "/feed")); + + const interceptedPending = await createPendingNavigationCommit({ + currentState: contextOnlyState, + nextElements: Promise.resolve( + createResolvedElements( + AppElementsWire.encodeRouteId("/photos/42", "/feed"), + "/", + "/feed", + { + [AppElementsWire.encodeRouteId("/photos/42", "/feed")]: React.createElement( + "main", + null, + "photo", + ), + [AppElementsWire.encodeSlotId("modal", "/feed")]: React.createElement( + "div", + null, + "modal", + ), + }, + [AppElementsWire.encodeLayoutId("/"), AppElementsWire.encodeLayoutId("/feed")], + [ + { + ownerLayoutId: AppElementsWire.encodeLayoutId("/feed"), + slotId: AppElementsWire.encodeSlotId("modal", "/feed"), + state: "active", + }, + ], + createInterceptionProof("/feed", "/photos/42"), + ), + ), + navigationSnapshot: createClientNavigationRenderSnapshot("https://example.com/photos/42", {}), + operationLane: "navigation", + previousNextUrl: "/feed", + renderId: 2, + type: "navigate", + }); + + const interceptedApproval = approvePendingNavigationCommit({ + activeNavigationId: 2, + currentState: contextOnlyState, + pending: interceptedPending, + startedNavigationId: 2, + targetHref: "https://example.com/photos/42", + }); + + expect(interceptedApproval.decision.disposition).toBe("commit"); + expect(interceptedApproval.decision.trace.entries[1]?.code).toBe( + NavigationTraceReasonCodes.interceptedCommitCurrent, + ); + }); + it("creates an approved visible commit only after the current operation decision allows mutation", async () => { const currentState = createState(); const pending = await createPendingNavigationCommit({ diff --git a/tests/navigation-planner.test.ts b/tests/navigation-planner.test.ts index 5f083d8ad..b50f126be 100644 --- a/tests/navigation-planner.test.ts +++ b/tests/navigation-planner.test.ts @@ -600,7 +600,7 @@ describe("navigationPlanner root-boundary decisions", () => { ); }); - it("rejects intercepted payloads that only carry legacy context metadata", () => { + it("does not treat legacy context-only payloads as intercepted preservation proof", () => { const currentSnapshot: RouteSnapshotV0 = { ...createRouteSnapshot("/", ["layout:/", "layout:/feed"]), matchedUrl: "/feed", @@ -616,14 +616,14 @@ describe("navigationPlanner root-boundary decisions", () => { const decision = planFlightResponseFromSnapshots({ currentSnapshot, targetSnapshot }); - expect(decision.kind).toBe("hardNavigate"); - if (decision.kind !== "hardNavigate") { - throw new Error("Expected hardNavigate decision"); + expect(decision.kind).toBe("proposeCommit"); + if (decision.kind !== "proposeCommit") { + throw new Error("Expected proposeCommit decision"); } - expect(decision.reason).toBe("interceptionProofRejected"); - expect(decision.trace.entries[0]?.code).toBe( - NavigationTraceReasonCodes.interceptedRejectedMissingProof, - ); + expect(decision.proposal.reason).toBe("currentRootBoundary"); + expect(decision.proposal.preserveElementIds).toEqual(["layout:/", "layout:/feed"]); + expect(decision.proposal.preservePreviousSlotIds).toEqual([]); + expect(decision.trace.entries[0]?.code).toBe(NavigationTraceReasonCodes.commitCurrent); }); it("rejects intercepted preservation when the visible source route is stale", () => {