Skip to content

Commit 4988436

Browse files
fix(app-router): preserve history metadata for external state updates (#1259)
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.
1 parent 3e8db45 commit 4988436

4 files changed

Lines changed: 140 additions & 40 deletions

File tree

packages/vinext/src/server/app-browser-state.ts

Lines changed: 4 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -29,12 +29,10 @@ import {
2929
type RouteSnapshotV0,
3030
} from "./navigation-planner.js";
3131
import type { ClientNavigationRenderSnapshot } from "vinext/shims/navigation";
32-
33-
const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl";
34-
35-
type HistoryStateRecord = {
36-
[key: string]: unknown;
37-
};
32+
export {
33+
createHistoryStateWithPreviousNextUrl,
34+
readHistoryStatePreviousNextUrl,
35+
} from "./app-history-state.js";
3836

3937
export type { OperationLane } from "./navigation-planner.js";
4038

@@ -110,38 +108,6 @@ type PendingNavigationCommitDispositionDecision =
110108
| DispatchPendingNavigationCommitDispositionDecision
111109
| NonDispatchPendingNavigationCommitDispositionDecision;
112110

113-
function cloneHistoryState(state: unknown): HistoryStateRecord {
114-
if (!state || typeof state !== "object") {
115-
return {};
116-
}
117-
118-
const nextState: HistoryStateRecord = {};
119-
for (const [key, value] of Object.entries(state)) {
120-
nextState[key] = value;
121-
}
122-
return nextState;
123-
}
124-
125-
export function createHistoryStateWithPreviousNextUrl(
126-
state: unknown,
127-
previousNextUrl: string | null,
128-
): HistoryStateRecord | null {
129-
const nextState = cloneHistoryState(state);
130-
131-
if (previousNextUrl === null) {
132-
delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY];
133-
} else {
134-
nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl;
135-
}
136-
137-
return Object.keys(nextState).length > 0 ? nextState : null;
138-
}
139-
140-
export function readHistoryStatePreviousNextUrl(state: unknown): string | null {
141-
const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY];
142-
return typeof value === "string" ? value : null;
143-
}
144-
145111
function createOperationRecord(options: {
146112
id: number;
147113
lane: OperationLane;
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
const VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY = "__vinext_previousNextUrl";
2+
3+
type HistoryStateRecord = {
4+
[key: string]: unknown;
5+
};
6+
7+
function cloneHistoryState(state: unknown): HistoryStateRecord {
8+
if (!state || typeof state !== "object") {
9+
return {};
10+
}
11+
12+
const nextState: HistoryStateRecord = {};
13+
for (const [key, value] of Object.entries(state)) {
14+
nextState[key] = value;
15+
}
16+
return nextState;
17+
}
18+
19+
export function createHistoryStateWithPreviousNextUrl(
20+
state: unknown,
21+
previousNextUrl: string | null,
22+
): HistoryStateRecord | null {
23+
const nextState = cloneHistoryState(state);
24+
25+
if (previousNextUrl === null) {
26+
delete nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY];
27+
} else {
28+
nextState[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY] = previousNextUrl;
29+
}
30+
31+
return Object.keys(nextState).length > 0 ? nextState : null;
32+
}
33+
34+
export function createExternalHistoryStatePreservingMetadata(
35+
callerState: unknown,
36+
currentHistoryState: unknown,
37+
): unknown {
38+
const previousNextUrl = readHistoryStatePreviousNextUrl(currentHistoryState);
39+
if (previousNextUrl === null) {
40+
return callerState;
41+
}
42+
43+
return createHistoryStateWithPreviousNextUrl(callerState, previousNextUrl);
44+
}
45+
46+
export function readHistoryStatePreviousNextUrl(state: unknown): string | null {
47+
const value = cloneHistoryState(state)[VINEXT_PREVIOUS_NEXT_URL_HISTORY_STATE_KEY];
48+
return typeof value === "string" ? value : null;
49+
}

packages/vinext/src/shims/navigation.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import * as React from "react";
1414
import { notifyAppRouterTransitionStart } from "../client/instrumentation-client-state.js";
1515
import { AppElementsWire } from "../server/app-elements.js";
16+
import { createExternalHistoryStatePreservingMetadata } from "../server/app-history-state.js";
1617
import {
1718
createRscRequestHeaders,
1819
createRscRequestUrl,
@@ -2010,7 +2011,12 @@ if (!isServer) {
20102011
unused: string,
20112012
url?: string | URL | null,
20122013
): void {
2013-
state.originalPushState.call(window.history, data, unused, url);
2014+
state.originalPushState.call(
2015+
window.history,
2016+
createExternalHistoryStatePreservingMetadata(data, window.history.state),
2017+
unused,
2018+
url,
2019+
);
20142020
if (state.suppressUrlNotifyCount === 0) {
20152021
commitClientNavigationState();
20162022
}
@@ -2021,7 +2027,12 @@ if (!isServer) {
20212027
unused: string,
20222028
url?: string | URL | null,
20232029
): void {
2024-
state.originalReplaceState.call(window.history, data, unused, url);
2030+
state.originalReplaceState.call(
2031+
window.history,
2032+
createExternalHistoryStatePreservingMetadata(data, window.history.state),
2033+
unused,
2034+
url,
2035+
);
20252036
if (state.suppressUrlNotifyCount === 0) {
20262037
commitClientNavigationState();
20272038
}

tests/shims.test.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,80 @@ describe("next/navigation shim", () => {
225225
}
226226
});
227227

228+
it("preserves App Router history metadata when external history calls provide caller state", async () => {
229+
// Matches Next.js' external History API wrapper behavior:
230+
// https://github.com/vercel/next.js/blob/canary/packages/next/src/client/components/app-router.tsx#L114-L127
231+
// Covered by Next.js shallow-routing tests for object, null, and undefined state:
232+
// https://github.com/vercel/next.js/blob/canary/test/e2e/app-dir/shallow-routing/shallow-routing.test.ts
233+
const previousWindow = (globalThis as any).window;
234+
const historyMetadataKey = "__vinext_previousNextUrl";
235+
const win = {
236+
location: {
237+
pathname: "/photo/1",
238+
search: "",
239+
hash: "",
240+
href: "http://localhost/photo/1",
241+
origin: "http://localhost",
242+
},
243+
history: {
244+
state: { [historyMetadataKey]: "/feed" } as unknown,
245+
pushState(data: unknown, _unused: string, url?: string | URL | null) {
246+
this.state = data;
247+
if (!url) return;
248+
const parsed = new URL(url, win.location.href);
249+
win.location.pathname = parsed.pathname;
250+
win.location.search = parsed.search;
251+
win.location.hash = parsed.hash;
252+
win.location.href = parsed.href;
253+
},
254+
replaceState(data: unknown, _unused: string, url?: string | URL | null) {
255+
this.state = data;
256+
if (!url) return;
257+
const parsed = new URL(url, win.location.href);
258+
win.location.pathname = parsed.pathname;
259+
win.location.search = parsed.search;
260+
win.location.hash = parsed.hash;
261+
win.location.href = parsed.href;
262+
},
263+
},
264+
addEventListener: vi.fn(),
265+
};
266+
(globalThis as any).window = win;
267+
268+
try {
269+
vi.resetModules();
270+
await import("../packages/vinext/src/shims/navigation.js");
271+
272+
win.history.pushState({ myData: { foo: "bar" } }, "", "/photo/1?filter=active");
273+
expect(win.history.state).toEqual({
274+
myData: { foo: "bar" },
275+
[historyMetadataKey]: "/feed",
276+
});
277+
278+
win.history.pushState(null, "", "/photo/1?filter=pending");
279+
expect(win.history.state).toEqual({
280+
[historyMetadataKey]: "/feed",
281+
});
282+
283+
win.history.replaceState(null, "", "/photo/1?filter=archived");
284+
expect(win.history.state).toEqual({
285+
[historyMetadataKey]: "/feed",
286+
});
287+
288+
win.history.replaceState(undefined, "", "/photo/1?filter=all");
289+
expect(win.history.state).toEqual({
290+
[historyMetadataKey]: "/feed",
291+
});
292+
} finally {
293+
vi.resetModules();
294+
if (previousWindow === undefined) {
295+
delete (globalThis as any).window;
296+
} else {
297+
(globalThis as any).window = previousWindow;
298+
}
299+
}
300+
});
301+
228302
it("exports redirect, notFound, permanentRedirect", async () => {
229303
const nav = await import("../packages/vinext/src/shims/navigation.js");
230304
expect(typeof nav.redirect).toBe("function");

0 commit comments

Comments
 (0)