diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts
index 2b39ebe0b..cb11b417b 100644
--- a/packages/vinext/src/shims/router.ts
+++ b/packages/vinext/src/shims/router.ts
@@ -414,6 +414,7 @@ type PagesRouterRuntimeState = {
cancelPendingRenderCommit: (() => void) | null;
beforePopStateCb?: BeforePopStateCallback;
lastPathnameAndSearch: string;
+ lastBrowserUrl: string;
isFirstPopStateEvent: boolean;
routerDidNavigate: boolean;
deprecatedEventBridgeInstalled: boolean;
@@ -441,6 +442,7 @@ function createPagesRouterRuntimeState(): PagesRouterRuntimeState {
cancelPendingRenderCommit: null,
lastPathnameAndSearch:
typeof window !== "undefined" ? window.location.pathname + window.location.search : "",
+ lastBrowserUrl: typeof window !== "undefined" ? window.location.href : "",
isFirstPopStateEvent: true,
routerDidNavigate: false,
deprecatedEventBridgeInstalled: false,
@@ -472,6 +474,12 @@ function getPagesRouterRuntimeState(): PagesRouterRuntimeState {
const routerRuntimeState = getPagesRouterRuntimeState();
const routerEvents = routerRuntimeState.events;
+function updateBrowserUrlTrackers(pathnameAndSearch?: string): void {
+ routerRuntimeState.lastPathnameAndSearch =
+ pathnameAndSearch ?? window.location.pathname + window.location.search;
+ routerRuntimeState.lastBrowserUrl = window.location.href;
+}
+
function getPagesRouterRuntimeComponents(): PagesRouterRuntimeComponents {
const existing = routerRuntimeState.components;
if (existing) return existing;
@@ -1272,7 +1280,7 @@ function getPathnameAndQuery(): {
function getCurrentHistoryAsPath(): string | null {
const state = window.history?.state;
- if (!isNextRouterState(state) || typeof state.as !== "string") return null;
+ if (!isNextRouterState(state)) return null;
try {
const browserUrl = new URL(window.location.href);
@@ -1696,7 +1704,7 @@ async function navigateClientData(
}
window.history.replaceState(window.history.state ?? {}, "", redirectedUrl);
- routerRuntimeState.lastPathnameAndSearch = window.location.pathname + window.location.search;
+ updateBrowserUrlTrackers();
await navigateClientHtml(redirectedUrl, redirectedUrl, controller, navId, assertStillCurrent);
return;
}
@@ -2018,7 +2026,7 @@ async function navigateClientHtml(
// Next.js's client Root callback without remounting the page tree.
if (pendingRedirectHistoryUrl) {
window.history.replaceState(window.history.state ?? {}, "", pendingRedirectHistoryUrl);
- routerRuntimeState.lastPathnameAndSearch = window.location.pathname + window.location.search;
+ updateBrowserUrlTrackers();
}
window.__NEXT_DATA__ = nextData;
applyVinextLocaleGlobals(window, nextData);
@@ -2111,8 +2119,7 @@ async function navigateClient(
scheduleHardNavigationAndThrow(redirectLocation, "Navigation redirected externally");
}
window.history.replaceState(window.history.state ?? {}, "", redirectedUrl);
- routerRuntimeState.lastPathnameAndSearch =
- window.location.pathname + window.location.search;
+ updateBrowserUrlTrackers();
browserUrl = redirectedUrl;
htmlFetchUrl = redirectedUrl;
} else if (middlewareEffect?.rewriteTarget) {
@@ -2301,7 +2308,7 @@ function updateHistory(
if (mode === "push") window.history.pushState(state, "", fullUrl);
else window.history.replaceState(state, "", fullUrl);
routerRuntimeState.currentHistoryKey = key;
- routerRuntimeState.lastPathnameAndSearch = window.location.pathname + window.location.search;
+ updateBrowserUrlTrackers();
routerRuntimeState.routerDidNavigate = true;
}
@@ -2819,10 +2826,10 @@ function PagesRouterProvider({ children }: { children: ReactNode }): ReactElemen
: content;
}
-// `routerRuntimeState.lastPathnameAndSearch` tracks pathname+search for
-// detecting hash-only back/forward in the popstate handler. It is updated after
-// every pushState/replaceState so popstate can compare the previous value with
-// the already-changed window.location.
+// `routerRuntimeState.lastPathnameAndSearch` tracks pathname+search for the
+// Safari-replay filter and push->replace coercion. It deliberately stays
+// hash-stripped. `lastBrowserUrl` tracks the full browser href separately so
+// popstate hash-only detection can compare against the previous hash-aware URL.
//
// `routerRuntimeState.isFirstPopStateEvent` mirrors Next.js's first-popstate
// Safari replay filter (packages/next/src/shared/lib/router/router.ts around
@@ -2906,15 +2913,37 @@ function handlePagesRouterPopState(e: PopStateEvent): void {
const currentLocale = window.__VINEXT_LOCALE__;
if (
state.options?.locale === currentLocale &&
- typeof state.as === "string" &&
withBasePath(state.as, __basePath) === routerRuntimeState.lastPathnameAndSearch
) {
return;
}
}
- // Detect hash-only back/forward: pathname+search unchanged, only hash differs.
- const isHashOnly = browserUrl === routerRuntimeState.lastPathnameAndSearch;
+ // When the restored history entry carries a `state.url` that differs from
+ // `state.as`, the entry was written by a ``
+ // navigation. Resolve the route URL (page module / data target) from
+ // `state.url`, not `state.as` (the address bar) — otherwise forward
+ // navigation to such an entry would re-render the masked page instead of the
+ // routed one. Mirrors Next.js's popstate handler around router.ts:971-995,
+ // which keys page resolution off `state.url` (`href`) rather than the browser
+ // URL. Defaults to `browserUrl` for non-masked entries, making the rest of
+ // the handler a no-op for ordinary back/forward navigation.
+ const stateRouteUrl =
+ isNextRouterState(state) && state.url !== state.as
+ ? normalizePathTrailingSlash(withBasePath(state.url, __basePath), __trailingSlash)
+ : browserUrl;
+
+ // Detect hash-only back/forward against the previous full browser URL. The
+ // pathname+search tracker is hash-stripped for other router comparisons, so
+ // using it here would misclassify identical no-hash URLs as hash-only. This
+ // mirrors Next.js's `onlyAHashChange`, which requires a real hash delta (or
+ // a hash-bearing destination for same-hash scrolling) and therefore lets
+ // masked same-URL popstates fetch `state.url`.
+ const isHashOnly = isHashOnlyBrowserUrlChange(
+ window.location.href,
+ routerRuntimeState.lastBrowserUrl,
+ __basePath,
+ );
const targetKey = getRouterStateKey(state);
let forcedScroll: ScrollPosition | undefined;
@@ -2959,13 +2988,13 @@ function handlePagesRouterPopState(e: PopStateEvent): void {
// Update trackers only after beforePopState confirms navigation proceeds.
// If beforePopState cancels, the app stays on the previous history entry,
- // so both must retain their previous values: `lastPathnameAndSearch` so the
- // next popstate compares against the correct baseline, and `currentHistoryKey`
- // so subsequent scroll bookkeeping keys off the entry the app is actually on.
+ // so the URL trackers must retain their previous values for the next
+ // popstate baseline, and `currentHistoryKey` must keep pointing at the entry
+ // the app is actually on for subsequent scroll bookkeeping.
if (targetKey !== undefined) {
routerRuntimeState.currentHistoryKey = targetKey;
}
- routerRuntimeState.lastPathnameAndSearch = browserUrl;
+ updateBrowserUrlTrackers(browserUrl);
if (isHashOnly) {
// Hash-only back/forward — no page fetch needed.
@@ -3017,25 +3046,6 @@ function handlePagesRouterPopState(e: PopStateEvent): void {
const scrollTarget = manualScrollRestoration
? (forcedScroll ?? readScrollPosition(state) ?? { x: 0, y: 0 })
: readScrollPosition(state);
- // When the restored history entry carries a `state.url` that differs
- // from `state.as`, the entry was written by a ``
- // navigation. Fetch the page module / data by `state.url` (the route),
- // not `state.as` (the address bar) — otherwise forward navigation to
- // such an entry would re-render the masked page instead of the routed
- // one. Mirrors Next.js's popstate handler around router.ts:971-995,
- // which keys page resolution off `state.url` (`href`) rather than the
- // browser URL.
- const stateRouteUrl = (() => {
- if (
- isNextRouterState(state) &&
- typeof state.url === "string" &&
- typeof state.as === "string" &&
- state.url !== state.as
- ) {
- return normalizePathTrailingSlash(withBasePath(state.url, __basePath), __trailingSlash);
- }
- return browserUrl;
- })();
const result = await runNavigateClient(
browserUrl,
fullAppUrl,
diff --git a/packages/vinext/src/shims/url-utils.ts b/packages/vinext/src/shims/url-utils.ts
index 321c270a4..c6a89a44d 100644
--- a/packages/vinext/src/shims/url-utils.ts
+++ b/packages/vinext/src/shims/url-utils.ts
@@ -216,7 +216,11 @@ export function isHashOnlyBrowserUrlChange(
// Keep this raw comparison distinct from the App Router planner's parsed
// search-param comparison until their separate navigation lifecycles are
// deliberately unified.
- return currentPathname === nextPathname && current.search === next.search && next.hash !== "";
+ return (
+ currentPathname === nextPathname &&
+ current.search === next.search &&
+ (next.hash !== "" || current.hash !== next.hash)
+ );
} catch {
return false;
}
diff --git a/tests/pages-router-i18n-sticky-locale.test.ts b/tests/pages-router-i18n-sticky-locale.test.ts
index 326152bce..0e72fb6c8 100644
--- a/tests/pages-router-i18n-sticky-locale.test.ts
+++ b/tests/pages-router-i18n-sticky-locale.test.ts
@@ -114,6 +114,221 @@ function buildNavHtml(
const PAGE_MODULE_URL = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx");
+// Router-shaped history state as Next.js writes it on push/replace
+// (`{ url, as, options, __N, key }`). `url` is the route, `as` the address bar;
+// a masked entry has `url !== as`.
+type MaskedHistoryState = {
+ url: string;
+ as: string;
+ options: { locale?: string; shallow?: boolean };
+ __N: true;
+ key: string;
+};
+
+// The popstate handler the runtime registers via `addEventListener`. The tests
+// only ever drive it with a `{ state }` payload, so this is the honest surface.
+type PopStateListener = (event: { state: unknown }) => void;
+
+// Ported from Next.js:
+// test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts
+// https://github.com/vercel/next.js/blob/canary/test/e2e/ignore-invalid-popstateevent/without-i18n.test.ts
+describe("Pages Router popstate stale-state filter (non-i18n parity)", () => {
+ async function installRuntime(win: ReturnType["win"]) {
+ const listeners = new Map();
+ win.addEventListener = vi.fn((type: string, handler: PopStateListener) => {
+ listeners.set(type, handler);
+ }) as any;
+ (globalThis as any).window = win;
+ vi.resetModules();
+ await import("../packages/vinext/src/shims/router.js");
+ const { installPagesRouterRuntime } =
+ await import("../packages/vinext/src/shims/pages-router-runtime.js");
+ installPagesRouterRuntime();
+ return listeners;
+ }
+
+ async function expectFirstStalePopstateIgnoredThenSecondUsesStateUrl({
+ initialPath,
+ state,
+ expectedFetchUrl,
+ }: {
+ initialPath: string;
+ state: MaskedHistoryState;
+ expectedFetchUrl: string;
+ }) {
+ const previousWindow = (globalThis as any).window;
+ const originalFetch = globalThis.fetch;
+ const originalCustomEvent = globalThis.CustomEvent;
+ const { win } = createNavWindow();
+ const initialUrl = new URL(initialPath, "http://localhost");
+ win.location.pathname = initialUrl.pathname;
+ win.location.search = initialUrl.search;
+ win.location.href = initialUrl.href;
+ win.__NEXT_DATA__ = {
+ ...win.__NEXT_DATA__,
+ page: "/static",
+ __vinext: { pageModuleUrl: "/@fs/pages/static.js" },
+ };
+
+ let routeChangeStartCount = 0;
+ const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(
+ async () => new Response(buildNavHtml("/[dynamic]", PAGE_MODULE_URL), { status: 200 }),
+ );
+ globalThis.fetch = fetchMock;
+ (globalThis as any).CustomEvent = class CustomEventMock {
+ constructor(public type: string) {}
+ } as any;
+
+ try {
+ const listeners = await installRuntime(win);
+ const popstateHandler = listeners.get("popstate");
+ expect(popstateHandler).toBeDefined();
+
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ routerModule.default.events.on("routeChangeStart", () => {
+ routeChangeStartCount += 1;
+ });
+
+ popstateHandler!({ state });
+ await new Promise((r) => setTimeout(r, 0));
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(routeChangeStartCount).toBe(0);
+ expect(win.__NEXT_DATA__.page).toBe("/static");
+
+ popstateHandler!({ state });
+ await vi.waitFor(() => expect(win.__NEXT_DATA__.page).toBe("/[dynamic]"));
+ expect(routeChangeStartCount).toBe(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]![0]).toBe(expectedFetchUrl);
+ } finally {
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ (globalThis as any).CustomEvent = originalCustomEvent;
+ }
+ }
+
+ it("ignores the first stale event without query params and processes the second", async () => {
+ await expectFirstStalePopstateIgnoredThenSecondUsesStateUrl({
+ initialPath: "/static",
+ state: {
+ url: "/[dynamic]?",
+ as: "/static",
+ options: {},
+ __N: true,
+ key: "",
+ },
+ // Next stores the route href verbatim (`/[dynamic]?` with a bare empty
+ // query); the handler threads `state.url` through unchanged, so the HTML
+ // fetch keeps the trailing `?`. It is harmless (empty search) and matches
+ // the value Next writes into history state.
+ expectedFetchUrl: "/[dynamic]?",
+ });
+ });
+
+ it("ignores the first stale event with query params and processes the second", async () => {
+ await expectFirstStalePopstateIgnoredThenSecondUsesStateUrl({
+ initialPath: "/static?param=1",
+ state: {
+ url: "/[dynamic]?param=1",
+ as: "/static?param=1",
+ options: {},
+ __N: true,
+ key: "",
+ },
+ expectedFetchUrl: "/[dynamic]?param=1",
+ });
+ });
+});
+
+// End-to-end producer→consumer regression. The hand-built-state tests above
+// isolate the popstate consumer; this one closes the loop through the real
+// state *writer*: a masked `Router.push(href, as)` produces history state via
+// `updateHistory`, and that exact captured object is replayed through popstate.
+// It guards against drift between the two sides — if `updateHistory` ever
+// stopped writing `url` distinct from `as`, or the popstate handler stopped
+// keying the fetch off `state.url`, this fails where the isolated tests would
+// not. It also reproduces the PR's bug through the real write path: after a
+// push the visible browser URL is unchanged and has no hash delta, so the
+// masked entry must not be swallowed by the hash-only fast path.
+describe("Pages Router masked popstate (producer→consumer integration)", () => {
+ it("replays a Router.push-produced masked entry and fetches state.url, not state.as", async () => {
+ const previousWindow = (globalThis as any).window;
+ const originalFetch = globalThis.fetch;
+ const originalCustomEvent = globalThis.CustomEvent;
+ const { win } = createNavWindow();
+ win.location.pathname = "/";
+ win.location.href = "http://localhost/";
+
+ const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(
+ async () => new Response(buildNavHtml("/dynamic-route", PAGE_MODULE_URL), { status: 200 }),
+ );
+ globalThis.fetch = fetchMock;
+ (globalThis as any).CustomEvent = class CustomEventMock {
+ constructor(public type: string) {}
+ } as any;
+
+ const listeners = new Map();
+ win.addEventListener = vi.fn((type: string, handler: PopStateListener) => {
+ listeners.set(type, handler);
+ }) as any;
+ (globalThis as any).window = win;
+
+ try {
+ vi.resetModules();
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ const Router = routerModule.default;
+ const { installPagesRouterRuntime } =
+ await import("../packages/vinext/src/shims/pages-router-runtime.js");
+ installPagesRouterRuntime();
+
+ const popstateHandler = listeners.get("popstate");
+ expect(popstateHandler).toBeDefined();
+
+ // Producer: masked navigation. `href` is the route, `as` the address bar.
+ // `updateHistory` writes `{ url: "/dynamic-route", as: "/static", … }`.
+ await Router.push("/dynamic-route", "/static");
+ await vi.waitFor(() => expect(win.__NEXT_DATA__.page).toBe("/dynamic-route"));
+
+ // Capture the state the producer actually wrote — no hand-built shape.
+ const produced = win.history.state as MaskedHistoryState;
+ expect(produced.__N).toBe(true);
+ expect(produced.url).toBe("/dynamic-route");
+ expect(produced.as).toBe("/static");
+ expect(win.location.pathname).toBe("/static");
+
+ // Consumer: replay that exact entry. The visible URL is unchanged from
+ // the push (`/static`) and carries no hash, so hash-only classification
+ // must fall through to a full fetch. A single popstate suffices because
+ // the push already set `routerDidNavigate`, disabling the replay filter.
+ fetchMock.mockClear();
+ win.__NEXT_DATA__ = {
+ ...win.__NEXT_DATA__,
+ page: "/static",
+ };
+
+ popstateHandler!({ state: produced });
+ await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ // The fetch targets the route behind the mask, not the address bar.
+ expect(fetchMock.mock.calls[0]![0]).toBe("/dynamic-route");
+ await vi.waitFor(() => expect(win.__NEXT_DATA__.page).toBe("/dynamic-route"));
+ } finally {
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ (globalThis as any).CustomEvent = originalCustomEvent;
+ }
+ });
+});
+
// Ported from Next.js test/e2e/ignore-invalid-popstateevent — Next.js writes
// `{ url, as, options, __N: true, key }` on every pushState/replaceState so
// the popstate handler can detect stale or non-Next events.
@@ -272,8 +487,8 @@ describe("Pages Router history state shape", () => {
// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/router.ts (around L935-942)
describe("Pages Router popstate stale-state filter (i18n parity)", () => {
async function installRuntime(win: ReturnType["win"]) {
- const listeners = new Map void>();
- win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
+ const listeners = new Map();
+ win.addEventListener = vi.fn((type: string, handler: PopStateListener) => {
listeners.set(type, handler);
}) as any;
(globalThis as any).window = win;
@@ -354,6 +569,94 @@ describe("Pages Router popstate stale-state filter (i18n parity)", () => {
}
});
+ it("processes the second masked popstate under a sticky locale and fetches state.url", async () => {
+ // With-i18n companion to the non-i18n parity block above. Mirrors
+ // test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts: a stale masked
+ // entry (`url` = route, `as` = address bar) carrying `options.locale`. The
+ // locale-aware stale filter must drop only the first replay; the second
+ // event must navigate and fetch the route (`state.url`), not the masked
+ // address bar (`state.as`). Guards the locale + masked-route interaction
+ // that the non-i18n test does not exercise (issue #1336 listed both).
+ const previousWindow = (globalThis as any).window;
+ const originalFetch = globalThis.fetch;
+ const originalCustomEvent = globalThis.CustomEvent;
+ const { win } = createNavWindow();
+ win.location.pathname = "/static";
+ win.location.href = "http://localhost/static";
+ win.__NEXT_DATA__ = {
+ ...win.__NEXT_DATA__,
+ page: "/static",
+ __vinext: { pageModuleUrl: "/@fs/pages/static.js" },
+ };
+ Object.assign(win, {
+ __VINEXT_LOCALE__: "sv",
+ __VINEXT_LOCALES__: ["en", "sv"],
+ __VINEXT_DEFAULT_LOCALE__: "en",
+ });
+
+ let routeChangeStartCount = 0;
+ const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(
+ async () =>
+ new Response(
+ buildNavHtml(
+ "/[dynamic]",
+ PAGE_MODULE_URL,
+ {},
+ { locale: "sv", locales: ["en", "sv"], defaultLocale: "en" },
+ ),
+ { status: 200 },
+ ),
+ );
+ globalThis.fetch = fetchMock;
+ (globalThis as any).CustomEvent = class CustomEventMock {
+ constructor(public type: string) {}
+ } as any;
+
+ const state: MaskedHistoryState = {
+ url: "/[dynamic]?",
+ as: "/static",
+ options: { locale: "sv" },
+ __N: true,
+ key: "",
+ };
+
+ try {
+ const listeners = await installRuntime(win);
+ const popstateHandler = listeners.get("popstate");
+ expect(popstateHandler).toBeDefined();
+
+ const routerModule = await import("../packages/vinext/src/shims/router.js");
+ routerModule.default.events.on("routeChangeStart", () => {
+ routeChangeStartCount += 1;
+ });
+
+ // 1st event: matching locale + as → Safari-replay, ignored.
+ popstateHandler!({ state });
+ await new Promise((r) => setTimeout(r, 0));
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(routeChangeStartCount).toBe(0);
+ expect(win.__NEXT_DATA__.page).toBe("/static");
+
+ // 2nd event: not ignored; masked → fetch the route, keeping the locale.
+ // `sv` is non-default, so the fetch URL is the route as-is (no `/sv`
+ // root rewrite, which only applies to default-locale root navigations).
+ popstateHandler!({ state });
+ await vi.waitFor(() => expect(win.__NEXT_DATA__.page).toBe("/[dynamic]"));
+ expect(routeChangeStartCount).toBe(1);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]![0]).toBe("/[dynamic]?");
+ } finally {
+ vi.resetModules();
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ (globalThis as any).CustomEvent = originalCustomEvent;
+ }
+ });
+
it("does not ignore a first popstate when the locale differs from the current locale", async () => {
// Parity with Next.js test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts
// "Don't ignore event with different locale".
@@ -472,6 +775,93 @@ describe("Pages Router popstate stale-state filter (i18n parity)", () => {
});
});
+// Ported from Next.js test/e2e/ignore-invalid-popstateevent — basePath variant.
+// A masked popstate under a configured basePath must fetch the route URL with
+// basePath applied exactly once. vinext stores app-relative history state
+// (no basePath), so the handler composes basePath back on via `withBasePath`;
+// this guards against a double-prefix regression on the masked path.
+describe("Pages Router popstate masked route under basePath", () => {
+ it("fetches the basePath-prefixed route URL for the second masked popstate", async () => {
+ const previousWindow = (globalThis as any).window;
+ const originalFetch = globalThis.fetch;
+ const originalCustomEvent = globalThis.CustomEvent;
+ const previousBasePath = process.env.__NEXT_ROUTER_BASEPATH;
+ // `__basePath` is read from this env var at module load, so set it before
+ // the reset-and-import below.
+ process.env.__NEXT_ROUTER_BASEPATH = "/base";
+
+ const { win } = createNavWindow();
+ // The browser shows the basePath-prefixed address bar; history state stays
+ // app-relative (`as: "/static"`, `url: "/[dynamic]?"`).
+ win.location.pathname = "/base/static";
+ win.location.href = "http://localhost/base/static";
+ win.__NEXT_DATA__ = {
+ ...win.__NEXT_DATA__,
+ page: "/static",
+ __vinext: { pageModuleUrl: "/@fs/pages/static.js" },
+ };
+
+ const fetchMock = vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>(
+ async () => new Response(buildNavHtml("/[dynamic]", PAGE_MODULE_URL), { status: 200 }),
+ );
+ globalThis.fetch = fetchMock;
+ (globalThis as any).CustomEvent = class CustomEventMock {
+ constructor(public type: string) {}
+ } as any;
+
+ const listeners = new Map();
+ win.addEventListener = vi.fn((type: string, handler: PopStateListener) => {
+ listeners.set(type, handler);
+ }) as any;
+ (globalThis as any).window = win;
+
+ const state: MaskedHistoryState = {
+ url: "/[dynamic]?",
+ as: "/static",
+ options: {},
+ __N: true,
+ key: "",
+ };
+
+ try {
+ vi.resetModules();
+ await import("../packages/vinext/src/shims/router.js");
+ const { installPagesRouterRuntime } =
+ await import("../packages/vinext/src/shims/pages-router-runtime.js");
+ installPagesRouterRuntime();
+
+ const popstateHandler = listeners.get("popstate");
+ expect(popstateHandler).toBeDefined();
+
+ // 1st event: `withBasePath(state.as)` === tracker ("/base/static") → ignored.
+ popstateHandler!({ state });
+ await new Promise((r) => setTimeout(r, 0));
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(win.__NEXT_DATA__.page).toBe("/static");
+
+ // 2nd event: masked → fetch the route with basePath applied once.
+ popstateHandler!({ state });
+ await vi.waitFor(() => expect(win.__NEXT_DATA__.page).toBe("/[dynamic]"));
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock.mock.calls[0]![0]).toBe("/base/[dynamic]?");
+ } finally {
+ vi.resetModules();
+ if (previousBasePath === undefined) {
+ delete process.env.__NEXT_ROUTER_BASEPATH;
+ } else {
+ process.env.__NEXT_ROUTER_BASEPATH = previousBasePath;
+ }
+ if (previousWindow === undefined) {
+ delete (globalThis as any).window;
+ } else {
+ (globalThis as any).window = previousWindow;
+ }
+ globalThis.fetch = originalFetch;
+ (globalThis as any).CustomEvent = originalCustomEvent;
+ }
+ });
+});
+
// Ported from Next.js test/e2e/i18n-preferred-locale-detection — clicking a
// Link with no `locale` prop must keep the active locale; on the server,
// Router.push must carry the active locale through state for the popstate
@@ -532,8 +922,8 @@ describe("Pages Router locale stickiness on programmatic navigation", () => {
// to close that gap.
describe("Pages Router initial-entry history state", () => {
async function installRuntime(win: ReturnType["win"]) {
- const listeners = new Map void>();
- win.addEventListener = vi.fn((type: string, handler: (event: any) => void) => {
+ const listeners = new Map();
+ win.addEventListener = vi.fn((type: string, handler: PopStateListener) => {
listeners.set(type, handler);
}) as any;
(globalThis as any).window = win;
diff --git a/tests/shims.test.ts b/tests/shims.test.ts
index 517b0802e..48feff058 100644
--- a/tests/shims.test.ts
+++ b/tests/shims.test.ts
@@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises";
import path from "node:path";
import { PAGES_FIXTURE_DIR } from "./helpers.js";
import { isExternalUrl, isHashOnlyChange } from "../packages/vinext/src/shims/router.js";
+import { isHashOnlyBrowserUrlChange } from "../packages/vinext/src/shims/url-utils.js";
import { extractVinextNextDataJson } from "../packages/vinext/src/client/vinext-next-data.js";
import { isValidModulePath } from "../packages/vinext/src/client/validate-module-path.js";
import vinext from "../packages/vinext/src/index.js";
@@ -14237,6 +14238,32 @@ describe("Pages Router router helpers", () => {
});
});
+ describe("isHashOnlyBrowserUrlChange", () => {
+ it("returns false for identical no-hash URLs", () => {
+ expect(isHashOnlyBrowserUrlChange("http://localhost/about", "http://localhost/about")).toBe(
+ false,
+ );
+ });
+
+ it("returns true when browser back removes a hash on the same path and search", () => {
+ expect(
+ isHashOnlyBrowserUrlChange(
+ "http://localhost/about?tab=1",
+ "http://localhost/about?tab=1#section",
+ ),
+ ).toBe(true);
+ });
+
+ it("returns true for same-hash anchor scrolling", () => {
+ expect(
+ isHashOnlyBrowserUrlChange(
+ "http://localhost/about#section",
+ "http://localhost/about#section",
+ ),
+ ).toBe(true);
+ });
+ });
+
describe("applyNavigationLocale", () => {
it("does not prefix absolute https:// URLs", async () => {
const { applyNavigationLocale } = await import("../packages/vinext/src/shims/router.js");
@@ -15879,6 +15906,7 @@ describe("Pages Router concurrent navigation", () => {
// snapshot stays in sessionStorage for a later non-hash popstate.
it("hash-only popstate scrolls to the hash anchor and leaves the entry snapshot for later", async () => {
const previousWindow = (globalThis as any).window;
+ const previousDocument = (globalThis as any).document;
const originalFetch = globalThis.fetch;
const originalScrollRestorationEnv = process.env.__NEXT_SCROLL_RESTORATION;
const listeners = new Map void>();
@@ -15906,6 +15934,10 @@ describe("Pages Router concurrent navigation", () => {
};
(globalThis as any).window = win;
+ (globalThis as any).document = {
+ getElementById: vi.fn(() => null),
+ getElementsByName: vi.fn(() => []),
+ };
globalThis.fetch = vi.fn();
process.env.__NEXT_SCROLL_RESTORATION = "true";
@@ -15928,9 +15960,11 @@ describe("Pages Router concurrent navigation", () => {
win.scrollX = 5;
win.scrollY = 500;
- // Hash-only push mints a new history entry ("#top" avoids the
- // document-dependent getElementById branch of scrollToHashTarget).
- await (win as any).next.router.push("/#top");
+ // Hash-only push mints a router-owned hash entry and, more importantly,
+ // records a real previous full browser URL for the later popstate
+ // comparison. Use a non-top hash so the popstate below has a real hash
+ // delta while still staying on the same pathname+search.
+ await (win as any).next.router.push("/#departed");
expect(sessionStore.get("__next_scroll_key-initial")).toBe(JSON.stringify({ x: 5, y: 500 }));
const pushedKey = pushState.mock.calls.at(-1)![0].key as string;
expect(typeof pushedKey).toBe("string");
@@ -15943,34 +15977,39 @@ describe("Pages Router concurrent navigation", () => {
hashEvents.push("hashChangeComplete"),
);
- // The live position on the hash entry, snapshotted on departure.
+ const hashKey = "key-hash";
+ sessionStore.set(`__next_scroll_${hashKey}`, JSON.stringify({ x: 12, y: 345 }));
+ // The live position on the departed hash entry, snapshotted on
+ // traversal away from `pushedKey`.
win.scrollX = 7;
win.scrollY = 70;
win.scrollTo.mockClear();
- // Back to the initial entry: same pathname+search, only the hash
+ // Forward to another hash entry: same pathname+search, only the hash
// differs, so this is a hash-only popstate.
- win.location.hash = "";
+ win.location.hash = "#top";
win.location.pathname = "/";
- win.location.href = "http://localhost/";
+ win.location.href = "http://localhost/#top";
popstateHandler!({
- state: { __N: true, url: "/", as: "/", options: {}, key: "key-initial" },
+ state: { __N: true, url: "/", as: "/", options: {}, key: hashKey },
});
// Hash-only: no page fetch, hash events fired in order.
expect(globalThis.fetch).not.toHaveBeenCalled();
expect(hashEvents).toEqual(["hashChangeStart", "hashChangeComplete"]);
- // Only the hash anchor is honored (empty hash scrolls to top); the
- // target entry's saved {x: 5, y: 500} is not applied (upstream parity).
+ // Only the hash anchor is honored; the target entry's saved {x: 12, y: 345}
+ // is not applied (upstream parity).
expect(win.scrollTo).toHaveBeenCalledWith(0, 0);
expect(win.scrollTo).not.toHaveBeenCalledWith(5, 500);
+ expect(win.scrollTo).not.toHaveBeenCalledWith(12, 345);
- // The departed hash entry's live position was snapshotted under its
- // key, and the unconsumed target snapshot survives for a later
- // non-hash popstate to this entry.
- expect(sessionStore.get(`__next_scroll_${pushedKey}`)).toBe(JSON.stringify({ x: 7, y: 70 }));
+ // The departed hash entry's live position was snapshotted under its key,
+ // and the unconsumed target snapshot survives for a later non-hash
+ // popstate to this entry.
expect(sessionStore.get("__next_scroll_key-initial")).toBe(JSON.stringify({ x: 5, y: 500 }));
+ expect(sessionStore.get(`__next_scroll_${pushedKey}`)).toBe(JSON.stringify({ x: 7, y: 70 }));
+ expect(sessionStore.get(`__next_scroll_${hashKey}`)).toBe(JSON.stringify({ x: 12, y: 345 }));
} finally {
vi.resetModules();
if (previousWindow === undefined) {
@@ -15978,6 +16017,11 @@ describe("Pages Router concurrent navigation", () => {
} else {
(globalThis as any).window = previousWindow;
}
+ if (previousDocument === undefined) {
+ delete (globalThis as any).document;
+ } else {
+ (globalThis as any).document = previousDocument;
+ }
globalThis.fetch = originalFetch;
if (originalScrollRestorationEnv === undefined) {
delete process.env.__NEXT_SCROLL_RESTORATION;