From 712737c65cfda5c4977d0971d12f5cdd5610d0da Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 12:08:06 +1000 Subject: [PATCH] fix(app-router): model RSC redirect and traversal lifecycle RSC redirects were followed by mutating browser history before the redirected payload had passed the visible commit lifecycle. That could collapse the initiating history entry and leave back/forward traversal without an explicit intent model. Keep redirect hops inside the initiating navigation until an approved commit publishes history, add explicit terminal redirect decisions, and record per-entry traversal metadata so unknown browser history state stays unknown instead of being guessed. Add focused unit coverage for redirect decisions and traversal metadata plus a browser regression for router.push redirect back navigation. --- packages/vinext/src/global.d.ts | 6 + .../vinext/src/server/app-browser-entry.ts | 144 ++++++++++++----- .../app-browser-navigation-controller.ts | 4 + .../src/server/app-browser-rsc-redirect.ts | 79 +++++++++ .../vinext/src/server/app-browser-state.ts | 5 +- .../vinext/src/server/app-history-state.ts | 62 ++++++- .../vinext/src/server/navigation-planner.ts | 8 +- .../vinext/src/server/navigation-trace.ts | 3 +- tests/app-browser-entry.test.ts | 151 ++++++++++++++++++ .../nextjs-compat/router-push-pending.spec.ts | 28 ++++ tests/navigation-planner.test.ts | 30 ++++ 11 files changed, 477 insertions(+), 43 deletions(-) create mode 100644 packages/vinext/src/server/app-browser-rsc-redirect.ts diff --git a/packages/vinext/src/global.d.ts b/packages/vinext/src/global.d.ts index 018b5a3fd..0435b7f20 100644 --- a/packages/vinext/src/global.d.ts +++ b/packages/vinext/src/global.d.ts @@ -92,6 +92,7 @@ declare global { * @param redirectDepth - Internal parameter used to detect redirect loops. * @param navigationKind - Internal hint for traversal vs regular navigation. * @param historyUpdateMode - Internal hint for when history should publish. + * @param traversalIntent - Internal popstate direction/history metadata. */ __VINEXT_RSC_NAVIGATE__: | (( @@ -101,6 +102,11 @@ declare global { historyUpdateMode?: "push" | "replace", previousNextUrlOverride?: string | null, programmaticTransition?: boolean, + traversalIntent?: { + direction: "back" | "forward" | "unknown"; + historyState: unknown; + targetHistoryIndex: number | null; + }, ) => Promise) | undefined; diff --git a/packages/vinext/src/server/app-browser-entry.ts b/packages/vinext/src/server/app-browser-entry.ts index 57197cec6..d1297e451 100644 --- a/packages/vinext/src/server/app-browser-entry.ts +++ b/packages/vinext/src/server/app-browser-entry.ts @@ -69,11 +69,14 @@ import { type AppWireElements, } from "./app-elements.js"; import { - createHistoryStateWithPreviousNextUrl, + createHistoryStateWithNavigationMetadata, readHistoryStatePreviousNextUrl, + readHistoryStateTraversalIndex, + resolveHistoryTraversalIntent, resolveInterceptionContextFromPreviousNextUrl, resolveServerActionRequestState, type AppRouterState, + type HistoryTraversalIntent, type OperationLane, } from "./app-browser-state.js"; import { createPopstateRestoreHandler } from "./app-browser-popstate.js"; @@ -96,12 +99,11 @@ import { getVinextRscCompatibilityId, resolveHardNavigationTargetFromRscResponse, resolveRscCompatibilityNavigationDecision, - stripRscCacheBustingSearchParam, - stripRscSuffix, VINEXT_RSC_COMPATIBILITY_ID_HEADER, VINEXT_RSC_CONTENT_TYPE, } from "./app-rsc-cache-busting.js"; import { APP_RSC_RENDER_MODE_REFRESH_PRESERVE_UI } from "./app-rsc-render-mode.js"; +import { resolveRscRedirectLifecycleHop } from "./app-browser-rsc-redirect.js"; import { ACTION_REDIRECT_HEADER, ACTION_REDIRECT_TYPE_HEADER, @@ -190,6 +192,36 @@ let browserRouterStateHasEverCommitted = false; // of stranding them on the previous URL with a blank page. Cleared once the // commit effect runs (URL update succeeded) or the navigation is superseded. let pendingNavigationRecoveryHref: string | null = null; +let currentHistoryTraversalIndex: number | null = + readHistoryStateTraversalIndex(window.history.state) ?? 0; +let nextHistoryTraversalIndex: number = currentHistoryTraversalIndex; + +function allocateNavigationHistoryTraversalIndex( + historyUpdateMode: HistoryUpdateMode | undefined, +): number | null { + switch (historyUpdateMode) { + case "push": + return nextHistoryTraversalIndex + 1; + case "replace": + return currentHistoryTraversalIndex; + case undefined: + return null; + default: { + const _exhaustive: never = historyUpdateMode; + throw new Error("[vinext] Unknown history update mode: " + String(_exhaustive)); + } + } +} + +function commitHistoryTraversalIndex(index: number | null): void { + currentHistoryTraversalIndex = index; + if (index !== null) { + // Keep allocation anchored to the highest app-owned entry we know about. + // Traversing to metadata-less entries makes the current index unknown, but + // the next app-owned push should still continue from known app history. + nextHistoryTraversalIndex = Math.max(nextHistoryTraversalIndex, index); + } +} function getBrowserRouterState(): AppRouterState { return browserNavigationController.getBrowserRouterState(); @@ -251,8 +283,9 @@ function createNavigationCommitEffect(options: { navId: number; params: Record; previousNextUrl: string | null; + targetHistoryIndex?: number | null; }): () => void { - const { href, historyUpdateMode, navId, params, previousNextUrl } = options; + const { href, historyUpdateMode, navId, params, previousNextUrl, targetHistoryIndex } = options; return () => { // Only update URL if this is still the active navigation. @@ -267,15 +300,26 @@ function createNavigationCommitEffect(options: { const targetHref = new URL(href, window.location.origin).href; stageClientParams(params); const preserveExistingState = historyUpdateMode === "replace"; - const historyState = createHistoryStateWithPreviousNextUrl( + const navigationHistoryIndex = + targetHistoryIndex !== undefined + ? targetHistoryIndex + : allocateNavigationHistoryTraversalIndex(historyUpdateMode); + const historyState = createHistoryStateWithNavigationMetadata( preserveExistingState ? window.history.state : null, - previousNextUrl, + { + previousNextUrl, + traversalIndex: navigationHistoryIndex, + }, ); if (historyUpdateMode === "replace" && window.location.href !== targetHref) { replaceHistoryStateWithoutNotify(historyState, "", href); + commitHistoryTraversalIndex(navigationHistoryIndex); } else if (historyUpdateMode === "push" && window.location.href !== targetHref) { pushHistoryStateWithoutNotify(historyState, "", href); + commitHistoryTraversalIndex(navigationHistoryIndex); + } else if (targetHistoryIndex !== undefined) { + commitHistoryTraversalIndex(targetHistoryIndex); } // URL has been updated; the recovery hard-nav target is no longer needed. @@ -295,6 +339,7 @@ async function renderNavigationPayload( pendingRouterState: PendingBrowserRouterState | null, actionType: "navigate" | "replace" | "traverse" = "navigate", operationLane: OperationLane = "navigation", + traversalIntent: HistoryTraversalIntent | null = null, ): Promise { try { return await browserNavigationController.renderNavigationPayload({ @@ -310,6 +355,7 @@ async function renderNavigationPayload( params, pendingRouterState, previousNextUrl, + targetHistoryIndex: traversalIntent === null ? undefined : traversalIntent.targetHistoryIndex, targetHref, navId, }); @@ -424,6 +470,7 @@ type NavigationRequestState = { function getRequestState( navigationKind: NavigationKind, previousNextUrlOverride?: string | null, + traverseHistoryState?: unknown, ): NavigationRequestState { if (previousNextUrlOverride !== undefined) { return { @@ -442,7 +489,9 @@ function getRequestState( previousNextUrl: getCurrentNextUrl(), }; case "traverse": { - const previousNextUrl = readHistoryStatePreviousNextUrl(window.history.state); + const previousNextUrl = readHistoryStatePreviousNextUrl( + traverseHistoryState ?? window.history.state, + ); return { interceptionContext: resolveInterceptionContextFromPreviousNextUrl( previousNextUrl, @@ -555,7 +604,10 @@ function BrowserRoot({ } replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(window.history.state, treeState.previousNextUrl), + createHistoryStateWithNavigationMetadata(window.history.state, { + previousNextUrl: treeState.previousNextUrl, + traversalIndex: currentHistoryTraversalIndex, + }), "", window.location.href, ); @@ -961,7 +1013,10 @@ function bootstrapHydration(rscStream: ReadableStream): void { latestClientParams, ); replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(window.history.state, null), + createHistoryStateWithNavigationMetadata(window.history.state, { + previousNextUrl: null, + traversalIndex: currentHistoryTraversalIndex, + }), "", window.location.href, ); @@ -1009,6 +1064,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { historyUpdateMode?: HistoryUpdateMode, previousNextUrlOverride?: string | null, programmaticTransition = false, + traversalIntent?: HistoryTraversalIntent, ): Promise { let pendingRouterState: PendingBrowserRouterState | null = null; // Hoist navId above try so the catch and finally blocks can reference it. @@ -1022,6 +1078,14 @@ function bootstrapHydration(rscStream: ReadableStream): void { let currentHistoryMode = historyUpdateMode; let currentPrevNextUrl = previousNextUrlOverride; let redirectCount = redirectDepth; + const activeTraversalIntent = + navigationKind === "traverse" + ? (traversalIntent ?? + resolveHistoryTraversalIntent({ + currentHistoryIndex: currentHistoryTraversalIndex, + historyState: window.history.state, + })) + : null; try { const shouldUsePendingRouterState = programmaticTransition; @@ -1037,16 +1101,12 @@ function bootstrapHydration(rscStream: ReadableStream): void { } while (true) { - if (redirectCount > 10) { - console.error( - "[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.", - ); - window.location.href = currentHref; - return; - } - const url = new URL(currentHref, window.location.origin); - const requestState = getRequestState(navigationKind, currentPrevNextUrl); + const requestState = getRequestState( + navigationKind, + currentPrevNextUrl, + activeTraversalIntent?.historyState, + ); const requestInterceptionContext = requestState.interceptionContext; const requestPreviousNextUrl = requestState.previousNextUrl; @@ -1117,6 +1177,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { pendingRouterState, toActionType(navigationKind), toOperationLane(navigationKind), + activeTraversalIntent, ); return; } @@ -1190,26 +1251,34 @@ function bootstrapHydration(rscStream: ReadableStream): void { return; } - const finalUrl = new URL(navResponseUrl ?? navResponse.url, window.location.origin); - stripRscCacheBustingSearchParam(finalUrl); - const requestedUrl = new URL(rscUrl, window.location.origin); - - if (finalUrl.pathname !== requestedUrl.pathname) { - // Server-side redirect: update the URL in history and loop to fetch - // the destination without settling pendingRouterState. This keeps - // isPending true across all redirect hops instead of flashing false. - const destinationPath = stripRscSuffix(finalUrl.pathname) + finalUrl.search; - replaceHistoryStateWithoutNotify( - createHistoryStateWithPreviousNextUrl(null, requestPreviousNextUrl), - "", - destinationPath, - ); + const redirectDecision = resolveRscRedirectLifecycleHop({ + currentHref, + historyUpdateMode: currentHistoryMode ?? "replace", + origin: window.location.origin, + redirectDepth: redirectCount, + requestPreviousNextUrl, + responseUrl: navResponseUrl ?? navResponse.url, + }); + + if (redirectDecision.kind === "terminal-hard-navigation") { + if (redirectDecision.reason === "maxRedirectsExceeded") { + console.error( + "[vinext] Too many RSC redirects — aborting navigation to prevent infinite loop.", + ); + } + window.location.href = redirectDecision.href; + return; + } - currentHref = destinationPath; - // URL already written above; the commit effect must not push/replace again. - currentHistoryMode = undefined; - currentPrevNextUrl = requestPreviousNextUrl; - redirectCount += 1; + if (redirectDecision.kind === "follow") { + // Server-side redirect: keep the redirect chain inside this operation + // and defer URL/history mutation to the eventual approved commit. + // This keeps isPending true across all hops and avoids publishing a + // destination URL before its RSC payload is lifecycle-approved. + currentHref = redirectDecision.href; + currentHistoryMode = redirectDecision.historyUpdateMode; + currentPrevNextUrl = redirectDecision.previousNextUrl; + redirectCount = redirectDecision.redirectDepth; continue; } @@ -1264,6 +1333,7 @@ function bootstrapHydration(rscStream: ReadableStream): void { pendingRouterState, toActionType(navigationKind), toOperationLane(navigationKind), + activeTraversalIntent, ); if (renderOutcome !== "committed") return; // Don't cache the response if this navigation was superseded during diff --git a/packages/vinext/src/server/app-browser-navigation-controller.ts b/packages/vinext/src/server/app-browser-navigation-controller.ts index 5c5695274..70b348b7e 100644 --- a/packages/vinext/src/server/app-browser-navigation-controller.ts +++ b/packages/vinext/src/server/app-browser-navigation-controller.ts @@ -39,6 +39,7 @@ type BrowserNavigationCommitEffectFactory = (options: { navId: number; params: Record; previousNextUrl: string | null; + targetHistoryIndex?: number | null; }) => () => void; type BrowserRouterStateRef = { @@ -80,6 +81,7 @@ type BrowserNavigationController = { params: Record; pendingRouterState: PendingBrowserRouterState | null; previousNextUrl: string | null; + targetHistoryIndex?: number | null; targetHref: string; navId: number; }): Promise; @@ -488,6 +490,7 @@ export function createAppBrowserNavigationController( params: Record; pendingRouterState: PendingBrowserRouterState | null; previousNextUrl: string | null; + targetHistoryIndex?: number | null; targetHref: string; navId: number; }): Promise { @@ -545,6 +548,7 @@ export function createAppBrowserNavigationController( navId: options.navId, params: options.params, previousNextUrl: approvedCommit.previousNextUrl, + targetHistoryIndex: options.targetHistoryIndex, }), ); activateNavigationSnapshot(); diff --git a/packages/vinext/src/server/app-browser-rsc-redirect.ts b/packages/vinext/src/server/app-browser-rsc-redirect.ts new file mode 100644 index 000000000..9650399fc --- /dev/null +++ b/packages/vinext/src/server/app-browser-rsc-redirect.ts @@ -0,0 +1,79 @@ +import { + resolveHardNavigationTargetFromRscResponse, + stripRscCacheBustingSearchParam, + stripRscSuffix, +} from "./app-rsc-cache-busting.js"; + +const MAX_RSC_REDIRECT_DEPTH = 10; + +type RscRedirectHistoryUpdateMode = "push" | "replace" | undefined; + +type RscRedirectLifecycleDecision = + | { kind: "no-redirect" } + | { + href: string; + historyUpdateMode: RscRedirectHistoryUpdateMode; + kind: "follow"; + previousNextUrl: string | null; + redirectDepth: number; + } + | { + href: string; + kind: "terminal-hard-navigation"; + reason: "externalRedirect" | "maxRedirectsExceeded"; + redirectDepth: number; + }; + +function toVisibleAppHref(href: string, origin: string): string { + const url = new URL(href, origin); + stripRscCacheBustingSearchParam(url); + return `${stripRscSuffix(url.pathname)}${url.search}${url.hash}`; +} + +export function resolveRscRedirectLifecycleHop(options: { + currentHref: string; + historyUpdateMode: RscRedirectHistoryUpdateMode; + maxRedirectDepth?: number; + origin: string; + redirectDepth: number; + requestPreviousNextUrl: string | null; + responseUrl: string; +}): RscRedirectLifecycleDecision { + const responseUrl = new URL(options.responseUrl, options.origin); + + if (responseUrl.origin !== options.origin) { + return { + href: responseUrl.href, + kind: "terminal-hard-navigation", + reason: "externalRedirect", + redirectDepth: options.redirectDepth, + }; + } + + const redirectedHref = resolveHardNavigationTargetFromRscResponse( + responseUrl.href, + options.currentHref, + options.origin, + ); + if (redirectedHref === toVisibleAppHref(options.currentHref, options.origin)) { + return { kind: "no-redirect" }; + } + + const maxRedirectDepth = options.maxRedirectDepth ?? MAX_RSC_REDIRECT_DEPTH; + if (options.redirectDepth >= maxRedirectDepth) { + return { + href: redirectedHref, + kind: "terminal-hard-navigation", + reason: "maxRedirectsExceeded", + redirectDepth: options.redirectDepth, + }; + } + + return { + href: redirectedHref, + historyUpdateMode: options.historyUpdateMode, + kind: "follow", + previousNextUrl: options.requestPreviousNextUrl, + redirectDepth: options.redirectDepth + 1, + }; +} diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index a66fe4bdb..4381611e3 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -30,8 +30,12 @@ import { } from "./navigation-planner.js"; import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; export { + createHistoryStateWithNavigationMetadata, createHistoryStateWithPreviousNextUrl, readHistoryStatePreviousNextUrl, + readHistoryStateTraversalIndex, + resolveHistoryTraversalIntent, + type HistoryTraversalIntent, } from "./app-history-state.js"; export type { OperationLane } from "./navigation-planner.js"; @@ -107,7 +111,6 @@ type NonDispatchPendingNavigationCommitDispositionDecision = { type PendingNavigationCommitDispositionDecision = | DispatchPendingNavigationCommitDispositionDecision | NonDispatchPendingNavigationCommitDispositionDecision; - function createOperationRecord(options: { id: number; lane: OperationLane; diff --git a/packages/vinext/src/server/app-history-state.ts b/packages/vinext/src/server/app-history-state.ts index cc7d5a8c4..a43104e81 100644 --- a/packages/vinext/src/server/app-history-state.ts +++ b/packages/vinext/src/server/app-history-state.ts @@ -1,9 +1,18 @@ +import type { TraverseDirection } from "./navigation-planner.js"; + const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; +const VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY = "__vinext_historyIndex"; type HistoryStateRecord = { [key: string]: unknown; }; +export type HistoryTraversalIntent = { + direction: TraverseDirection; + historyState: unknown; + targetHistoryIndex: number | null; +}; + function cloneHistoryState(state: unknown): HistoryStateRecord { if (!state || typeof state !== "object") { return {}; @@ -19,13 +28,31 @@ function cloneHistoryState(state: unknown): HistoryStateRecord { export function createHistoryStateWithPreviousNextUrl( state: unknown, previousNextUrl: string | null, +): HistoryStateRecord | null { + return createHistoryStateWithNavigationMetadata(state, { previousNextUrl }); +} + +export function createHistoryStateWithNavigationMetadata( + state: unknown, + metadata: { + previousNextUrl: string | null; + traversalIndex?: number | null; + }, ): HistoryStateRecord | null { const nextState = cloneHistoryState(state); - if (previousNextUrl === null) { + if (metadata.previousNextUrl === null) { delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; } else { - nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; + nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = metadata.previousNextUrl; + } + + if (metadata.traversalIndex !== undefined) { + if (isValidHistoryTraversalIndex(metadata.traversalIndex)) { + nextState[VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY] = metadata.traversalIndex; + } else { + delete nextState[VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY]; + } } return Object.keys(nextState).length > 0 ? nextState : null; @@ -47,3 +74,34 @@ export function readHistoryStatePreviousNextUrl(state: unknown): string | null { const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; return typeof value === "string" ? value : null; } + +function isValidHistoryTraversalIndex(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +export function readHistoryStateTraversalIndex(state: unknown): number | null { + const value = cloneHistoryState(state)[VINEXT_HISTORY_INDEX_HISTORY_STATE_KEY]; + return isValidHistoryTraversalIndex(value) ? value : null; +} + +export function resolveHistoryTraversalIntent(options: { + currentHistoryIndex: number | null; + historyState: unknown; +}): HistoryTraversalIntent { + const targetHistoryIndex = readHistoryStateTraversalIndex(options.historyState); + let direction: TraverseDirection = "unknown"; + + if (options.currentHistoryIndex !== null && targetHistoryIndex !== null) { + if (targetHistoryIndex < options.currentHistoryIndex) { + direction = "back"; + } else if (targetHistoryIndex > options.currentHistoryIndex) { + direction = "forward"; + } + } + + return { + direction, + historyState: options.historyState, + targetHistoryIndex, + }; +} diff --git a/packages/vinext/src/server/navigation-planner.ts b/packages/vinext/src/server/navigation-planner.ts index af8f86e0e..246dc0cda 100644 --- a/packages/vinext/src/server/navigation-planner.ts +++ b/packages/vinext/src/server/navigation-planner.ts @@ -64,17 +64,18 @@ export type NavigationPlannerStateV0 = { }; export type RefreshScope = "visible"; +export type TraverseDirection = "back" | "forward" | "unknown"; export type NavigationEvent = | { kind: "navigate"; href: string; mode: "push" | "replace" } | { kind: "refresh"; scope: RefreshScope } - | { kind: "traverse"; direction: "back" | "forward"; historyState: unknown } + | { kind: "traverse"; direction: TraverseDirection; historyState: unknown } | { kind: "prefetch"; href: string } | { kind: "flightResponseArrived"; token: OperationToken; result: FlightResultV0 }; export type RequestedWork = | { kind: "flight"; href: string; mode: "push" | "replace" | "refresh" } - | { direction: "back" | "forward"; historyState: unknown; kind: "traverseFlight" } + | { direction: TraverseDirection; historyState: unknown; kind: "traverseFlight" } | { kind: "prefetch"; href: string }; export type CommitProposal = { @@ -138,6 +139,8 @@ function createRequestWorkDecision(options: { state: NavigationPlannerStateV0; work: RequestedWork; }): NavigationDecisionV0 { + const traverseFields = + options.work.kind === "traverseFlight" ? { traverseDirection: options.work.direction } : {}; return { kind: "requestWork", token: options.state.nextOperationToken, @@ -145,6 +148,7 @@ function createRequestWorkDecision(options: { trace: createNavigationTrace(NavigationTraceReasonCodes.requestWork, { eventKind: options.eventKind, targetHref: getRequestedWorkTargetHref(options.work), + ...traverseFields, }), }; } diff --git a/packages/vinext/src/server/navigation-trace.ts b/packages/vinext/src/server/navigation-trace.ts index 697d05d35..369abf801 100644 --- a/packages/vinext/src/server/navigation-trace.ts +++ b/packages/vinext/src/server/navigation-trace.ts @@ -46,7 +46,8 @@ export type NavigationTraceFieldName = | "pendingOperationId" | "startedVisibleCommitVersion" | "startedNavigationId" - | "targetHref"; + | "targetHref" + | "traverseDirection"; export type NavigationTraceFieldValue = string | number | boolean | null; diff --git a/tests/app-browser-entry.test.ts b/tests/app-browser-entry.test.ts index 8045f32b5..3c595186c 100644 --- a/tests/app-browser-entry.test.ts +++ b/tests/app-browser-entry.test.ts @@ -39,15 +39,19 @@ import { import { createClientNavigationRenderSnapshot } from "../packages/vinext/src/shims/navigation.js"; import * as navigationShim from "../packages/vinext/src/shims/navigation.js"; import { + createHistoryStateWithNavigationMetadata, createHistoryStateWithPreviousNextUrl, createPendingNavigationCommit, readHistoryStatePreviousNextUrl, + readHistoryStateTraversalIndex, resolveInterceptionContextFromPreviousNextUrl, + resolveHistoryTraversalIntent, resolveServerActionRequestState, resolvePendingNavigationCommitDispositionDecision, type AppRouterState, type OperationLane, } from "../packages/vinext/src/server/app-browser-state.js"; +import { resolveRscRedirectLifecycleHop } from "../packages/vinext/src/server/app-browser-rsc-redirect.js"; import { applyApprovedVisibleCommit, approveHmrVisibleCommit, @@ -2640,6 +2644,60 @@ describe("app browser entry previousNextUrl helpers", () => { expect(resolveInterceptionContextFromPreviousNextUrl(null)).toBeNull(); }); + it("stores traversal index alongside existing history state", () => { + const state = createHistoryStateWithNavigationMetadata( + { + __vinext_scrollY: 120, + }, + { + previousNextUrl: "/feed?tab=latest", + traversalIndex: 4, + }, + ); + + expect(state).toEqual({ + __vinext_historyIndex: 4, + __vinext_previousNextUrl: "/feed?tab=latest", + __vinext_scrollY: 120, + }); + expect(readHistoryStateTraversalIndex(state)).toBe(4); + }); + + it("resolves back, forward, and unknown traversal intent from history state", () => { + expect( + resolveHistoryTraversalIntent({ + currentHistoryIndex: 5, + historyState: { __vinext_historyIndex: 3 }, + }).direction, + ).toBe("back"); + expect( + resolveHistoryTraversalIntent({ + currentHistoryIndex: 5, + historyState: { __vinext_historyIndex: 7 }, + }).direction, + ).toBe("forward"); + expect( + resolveHistoryTraversalIntent({ + currentHistoryIndex: 5, + historyState: {}, + }), + ).toEqual({ + direction: "unknown", + historyState: {}, + targetHistoryIndex: null, + }); + expect( + resolveHistoryTraversalIntent({ + currentHistoryIndex: null, + historyState: { __vinext_historyIndex: 7 }, + }), + ).toEqual({ + direction: "unknown", + historyState: { __vinext_historyIndex: 7 }, + targetHistoryIndex: 7, + }); + }); + it("classifies pending commits in one step for same-url payloads", async () => { const currentState = createState({ rootLayoutTreePath: "/(marketing)", @@ -3097,6 +3155,99 @@ describe("createPopstateRestoreHandler", () => { }); }); +describe("app browser RSC redirect lifecycle", () => { + it("keeps RSC redirect hops in the initiating lifecycle and preserves push history intent", () => { + const decision = resolveRscRedirectLifecycleHop({ + currentHref: "https://example.com/start", + historyUpdateMode: "push", + origin: "https://example.com", + redirectDepth: 0, + requestPreviousNextUrl: "/feed", + responseUrl: "https://example.com/target.rsc?tab=1&_rsc=abc", + }); + + expect(decision).toEqual({ + href: "/target?tab=1", + historyUpdateMode: "push", + kind: "follow", + previousNextUrl: "/feed", + redirectDepth: 1, + }); + }); + + it("treats same-path search changes as RSC redirects", () => { + const decision = resolveRscRedirectLifecycleHop({ + currentHref: "https://example.com/items?sort=old", + historyUpdateMode: "replace", + origin: "https://example.com", + redirectDepth: 2, + requestPreviousNextUrl: null, + responseUrl: "https://example.com/items.rsc?sort=new&_rsc=abc", + }); + + expect(decision).toMatchObject({ + href: "/items?sort=new", + historyUpdateMode: "replace", + kind: "follow", + redirectDepth: 3, + }); + }); + + it("allows callers to model terminal traverse/refresh redirects as replace commits", () => { + const decision = resolveRscRedirectLifecycleHop({ + currentHref: "https://example.com/old", + historyUpdateMode: "replace", + origin: "https://example.com", + redirectDepth: 0, + requestPreviousNextUrl: null, + responseUrl: "https://example.com/new.rsc?_rsc=abc", + }); + + expect(decision).toMatchObject({ + href: "/new", + historyUpdateMode: "replace", + kind: "follow", + }); + }); + + it("turns external RSC redirects into terminal hard navigations", () => { + const decision = resolveRscRedirectLifecycleHop({ + currentHref: "https://example.com/account", + historyUpdateMode: "push", + origin: "https://example.com", + redirectDepth: 0, + requestPreviousNextUrl: null, + responseUrl: "https://idp.example/login", + }); + + expect(decision).toEqual({ + href: "https://idp.example/login", + kind: "terminal-hard-navigation", + reason: "externalRedirect", + redirectDepth: 0, + }); + }); + + it("turns an over-budget redirect chain into a terminal hard navigation", () => { + const decision = resolveRscRedirectLifecycleHop({ + currentHref: "https://example.com/a", + historyUpdateMode: "push", + maxRedirectDepth: 2, + origin: "https://example.com", + redirectDepth: 2, + requestPreviousNextUrl: null, + responseUrl: "https://example.com/b.rsc?_rsc=abc", + }); + + expect(decision).toEqual({ + href: "/b", + kind: "terminal-hard-navigation", + reason: "maxRedirectsExceeded", + redirectDepth: 2, + }); + }); +}); + describe("devOnCaughtError (hydrateRoot dev handler)", () => { it("ignores redirect sentinels handled by RedirectBoundary", () => { const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}); diff --git a/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts b/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts index d9634d920..dffda145b 100644 --- a/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts +++ b/tests/e2e/app-router/nextjs-compat/router-push-pending.spec.ts @@ -162,4 +162,32 @@ test.describe("Next.js compat: router pending state (browser)", () => { timeout: 10_000, }); }); + + /** + * Redirected RSC responses should stay inside the initiating router.push() + * lifecycle. The final destination is the committed history entry; vinext + * must not replace the source entry before the redirected payload is approved. + * + * Next.js source reference: + * .nextjs-ref/packages/next/src/client/components/router-reducer/fetch-server-response.ts + * uses the post-redirect canonical URL while preserving the navigation type. + */ + test("back after router.push to a server-redirecting page returns to the source entry", async ({ + page, + }) => { + await page.goto(`${BASE}/nextjs-compat/router-push-pending`); + await waitForAppRouterHydration(page); + + await page.click("#push-redirect"); + await expect(page.locator("#redirect-destination")).toBeVisible({ + timeout: 10_000, + }); + expect(new URL(page.url()).pathname).toBe("/nextjs-compat/router-push-pending-destination"); + + await page.goBack(); + await expect(page.locator("#router-push-pending-title")).toBeVisible({ + timeout: 10_000, + }); + expect(new URL(page.url()).pathname).toBe("/nextjs-compat/router-push-pending"); + }); }); diff --git a/tests/navigation-planner.test.ts b/tests/navigation-planner.test.ts index c1e828d87..514ae852d 100644 --- a/tests/navigation-planner.test.ts +++ b/tests/navigation-planner.test.ts @@ -662,5 +662,35 @@ describe("navigationPlanner root-boundary decisions", () => { kind: "traverseFlight", }); expect(decision.trace.entries[0]?.fields.targetHref).toBeNull(); + expect(decision.trace.entries[0]?.fields.traverseDirection).toBe("back"); + }); + + it("keeps unknown traversal direction explicit instead of guessing", () => { + const token = createOperationToken(); + const historyState = { key: "external-entry" }; + const decision = navigationPlanner.plan({ + routeManifest: null, + state: { + nextOperationToken: token, + visibleCommitVersion: 2, + visibleSnapshot: createRouteSnapshot("/"), + }, + event: { + direction: "unknown", + historyState, + kind: "traverse", + }, + }); + + expect(decision.kind).toBe("requestWork"); + if (decision.kind !== "requestWork") { + throw new Error("Expected requestWork decision"); + } + expect(decision.work).toEqual({ + direction: "unknown", + historyState, + kind: "traverseFlight", + }); + expect(decision.trace.entries[0]?.fields.traverseDirection).toBe("unknown"); }); });