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