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"); }); });