From 1db48f8f0a07b7e31c32e28aa5a257e93ce2b2c6 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 17:35:54 +1000 Subject: [PATCH] fix(app-router): preserve history metadata for external state updates External history.pushState and history.replaceState currently store caller data as-is. That drops vinext's App Router previousNextUrl marker when apps perform shallow URL updates inside an intercepted route, so later traversal can lose the interception source. The History API wrappers now copy the current App Router metadata onto caller-provided state before delegating to the browser history method. A focused shim regression covers object, null, and undefined caller state against the same contract Next.js preserves. --- .../vinext/src/server/app-browser-state.ts | 42 +---------- .../vinext/src/server/app-history-state.ts | 49 ++++++++++++ packages/vinext/src/shims/navigation.ts | 15 +++- tests/shims.test.ts | 74 +++++++++++++++++++ 4 files changed, 140 insertions(+), 40 deletions(-) create mode 100644 packages/vinext/src/server/app-history-state.ts diff --git a/packages/vinext/src/server/app-browser-state.ts b/packages/vinext/src/server/app-browser-state.ts index fa50de76b..a66fe4bdb 100644 --- a/packages/vinext/src/server/app-browser-state.ts +++ b/packages/vinext/src/server/app-browser-state.ts @@ -29,12 +29,10 @@ import { type RouteSnapshotV0, } from "./navigation-planner.js"; import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation"; - -const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; - -type HistoryStateRecord = { - [key: string]: unknown; -}; +export { + createHistoryStateWithPreviousNextUrl, + readHistoryStatePreviousNextUrl, +} from "./app-history-state.js"; export type { OperationLane } from "./navigation-planner.js"; @@ -110,38 +108,6 @@ type PendingNavigationCommitDispositionDecision = | DispatchPendingNavigationCommitDispositionDecision | NonDispatchPendingNavigationCommitDispositionDecision; -function cloneHistoryState(state: unknown): HistoryStateRecord { - if (!state || typeof state !== "object") { - return {}; - } - - const nextState: HistoryStateRecord = {}; - for (const [key, value] of Object.entries(state)) { - nextState[key] = value; - } - return nextState; -} - -export function createHistoryStateWithPreviousNextUrl( - state: unknown, - previousNextUrl: string | null, -): HistoryStateRecord | null { - const nextState = cloneHistoryState(state); - - if (previousNextUrl === null) { - delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; - } else { - nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; - } - - return Object.keys(nextState).length > 0 ? nextState : null; -} - -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 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 new file mode 100644 index 000000000..cc7d5a8c4 --- /dev/null +++ b/packages/vinext/src/server/app-history-state.ts @@ -0,0 +1,49 @@ +const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl"; + +type HistoryStateRecord = { + [key: string]: unknown; +}; + +function cloneHistoryState(state: unknown): HistoryStateRecord { + if (!state || typeof state !== "object") { + return {}; + } + + const nextState: HistoryStateRecord = {}; + for (const [key, value] of Object.entries(state)) { + nextState[key] = value; + } + return nextState; +} + +export function createHistoryStateWithPreviousNextUrl( + state: unknown, + previousNextUrl: string | null, +): HistoryStateRecord | null { + const nextState = cloneHistoryState(state); + + if (previousNextUrl === null) { + delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + } else { + nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl; + } + + return Object.keys(nextState).length > 0 ? nextState : null; +} + +export function createExternalHistoryStatePreservingMetadata( + callerState: unknown, + currentHistoryState: unknown, +): unknown { + const previousNextUrl = readHistoryStatePreviousNextUrl(currentHistoryState); + if (previousNextUrl === null) { + return callerState; + } + + return createHistoryStateWithPreviousNextUrl(callerState, previousNextUrl); +} + +export function readHistoryStatePreviousNextUrl(state: unknown): string | null { + const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY]; + return typeof value === "string" ? value : null; +} diff --git a/packages/vinext/src/shims/navigation.ts b/packages/vinext/src/shims/navigation.ts index 0a593255e..bc2727e02 100644 --- a/packages/vinext/src/shims/navigation.ts +++ b/packages/vinext/src/shims/navigation.ts @@ -13,6 +13,7 @@ import * as React from "react"; import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js"; import { AppElementsWire } from "../server/app-elements.js"; +import { createExternalHistoryStatePreservingMetadata } from "../server/app-history-state.js"; import { createRscRequestHeaders, createRscRequestUrl, @@ -2049,7 +2050,12 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalPushState.call(window.history, data, unused, url); + state.originalPushState.call( + window.history, + createExternalHistoryStatePreservingMetadata(data, window.history.state), + unused, + url, + ); if (state.suppressUrlNotifyCount === 0) { commitClientNavigationState(); } @@ -2060,7 +2066,12 @@ if (!isServer) { unused: string, url?: string | URL | null, ): void { - state.originalReplaceState.call(window.history, data, unused, url); + state.originalReplaceState.call( + window.history, + createExternalHistoryStatePreservingMetadata(data, window.history.state), + unused, + url, + ); if (state.suppressUrlNotifyCount === 0) { commitClientNavigationState(); } diff --git a/tests/shims.test.ts b/tests/shims.test.ts index d845a428c..e49ec7fba 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -225,6 +225,80 @@ describe("next/navigation shim", () => { } }); + it("preserves App Router history metadata when external history calls provide caller state", async () => { + // Matches Next.js' external History API wrapper behavior: + // https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx#L114-L127 + // Covered by Next.js shallow-routing tests for object, null, and undefined state: + // https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts + const previousWindow = (globalThis as any).window; + const historyMetadataKey = "__vinext_previousNextUrl"; + const win = { + location: { + pathname: "/photo/1", + search: "", + hash: "", + href: "http://localhost/photo/1", + origin: "http://localhost", + }, + history: { + state: { [historyMetadataKey]: "/feed" } as unknown, + pushState(data: unknown, _unused: string, url?: string | URL | null) { + this.state = data; + if (!url) return; + const parsed = new URL(url, win.location.href); + win.location.pathname = parsed.pathname; + win.location.search = parsed.search; + win.location.hash = parsed.hash; + win.location.href = parsed.href; + }, + replaceState(data: unknown, _unused: string, url?: string | URL | null) { + this.state = data; + if (!url) return; + const parsed = new URL(url, win.location.href); + win.location.pathname = parsed.pathname; + win.location.search = parsed.search; + win.location.hash = parsed.hash; + win.location.href = parsed.href; + }, + }, + addEventListener: vi.fn(), + }; + (globalThis as any).window = win; + + try { + vi.resetModules(); + await import("../packages/vinext/src/shims/navigation.js"); + + win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active"); + expect(win.history.state).toEqual({ + myData: { foo: "bar" }, + [historyMetadataKey]: "/feed", + }); + + win.history.pushState(null, "", "/photo/1?filter=pending"); + expect(win.history.state).toEqual({ + [historyMetadataKey]: "/feed", + }); + + win.history.replaceState(null, "", "/photo/1?filter=archived"); + expect(win.history.state).toEqual({ + [historyMetadataKey]: "/feed", + }); + + win.history.replaceState(undefined, "", "/photo/1?filter=all"); + expect(win.history.state).toEqual({ + [historyMetadataKey]: "/feed", + }); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + } + }); + it("exports redirect, notFound, permanentRedirect", async () => { const nav = await import("../packages/vinext/src/shims/navigation.js"); expect(typeof nav.redirect).toBe("function");