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;