diff --git a/packages/vinext/src/shims/pages-router-runtime.ts b/packages/vinext/src/shims/pages-router-runtime.ts index 24aec3a67..0dfec5df9 100644 --- a/packages/vinext/src/shims/pages-router-runtime.ts +++ b/packages/vinext/src/shims/pages-router-runtime.ts @@ -1,12 +1,22 @@ type PagesRouterPopStateHandler = (event: PopStateEvent) => void; let pagesRouterPopStateHandler: PagesRouterPopStateHandler | undefined; +let stampInitialHistoryStateFn: (() => void) | undefined; let pagesRouterRuntimeInstalled = false; export function setPagesRouterPopStateHandler(handler: PagesRouterPopStateHandler): void { pagesRouterPopStateHandler = handler; } +/** + * Register the function that stamps Next.js-shaped state onto the initial + * document entry (called once at install time). Router.ts registers this so + * the runtime module stays free of router internals. + */ +export function setStampInitialHistoryState(fn: () => void): void { + stampInitialHistoryStateFn = fn; +} + export function installPagesRouterRuntime(): void { if (typeof window === "undefined" || pagesRouterRuntimeInstalled) { return; @@ -17,5 +27,9 @@ export function installPagesRouterRuntime(): void { } pagesRouterRuntimeInstalled = true; + // Stamp the initial document entry with router-shaped state *before* the + // listener attaches so a back-navigation popstate carries the active locale + // and passes the foreign-state filter. + stampInitialHistoryStateFn?.(); window.addEventListener("popstate", pagesRouterPopStateHandler); } diff --git a/packages/vinext/src/shims/router.ts b/packages/vinext/src/shims/router.ts index ef199ba24..e8569202f 100644 --- a/packages/vinext/src/shims/router.ts +++ b/packages/vinext/src/shims/router.ts @@ -30,6 +30,7 @@ import { normalizePathTrailingSlash, toBrowserNavigationHref, toSameOriginAppPath, + withBasePath, } from "./url-utils.js"; import { stripBasePath } from "../utils/base-path.js"; import { @@ -46,7 +47,10 @@ import { } from "../utils/query.js"; import { matchRoutePattern, routePatternParts } from "../routing/route-pattern.js"; import { scrollToHashTarget } from "./hash-scroll.js"; -import { setPagesRouterPopStateHandler } from "./pages-router-runtime.js"; +import { + setPagesRouterPopStateHandler, + setStampInitialHistoryState, +} from "./pages-router-runtime.js"; import { assertSafeNavigationUrl } from "./url-safety.js"; import { getCurrentBrowserLocale } from "./client-locale.js"; @@ -275,13 +279,57 @@ export function isHashOnlyChange(href: string): boolean { return isHashOnlyBrowserUrlChange(href, window.location.href, __basePath); } -/** Save current scroll position into history state for back/forward restoration */ +/** + * Build router-shaped state for the initial document entry. Captures the + * active locale (URL-first via `getCurrentUrlLocale`, matching the path + * `resolveTransitionLocale` uses for pushes) so a back-navigation popstate + * to this entry can recover its locale instead of falling back to the live + * window global — which may have changed by the time the user navigates back. + */ +function buildInitialRouterState(): VinextHistoryState { + const appPath = stripBasePath(window.location.pathname, __basePath) + window.location.search; + const options: { locale?: string; shallow?: boolean } = {}; + const initialLocale = getCurrentUrlLocale(); + if (initialLocale !== undefined) options.locale = initialLocale; + return { + url: appPath, + as: appPath, + options, + __N: true, + key: createHistoryKey(), + }; +} + +/** + * Stamp the initial document entry with router-shaped state (only if no + * state is present). Called once at runtime install so the entry has a + * locale stamped before any push could overwrite the active locale global. + */ +function stampInitialHistoryState(): void { + if (window.history.state !== null && window.history.state !== undefined) return; + window.history.replaceState(buildInitialRouterState(), ""); +} + +setStampInitialHistoryState(stampInitialHistoryState); + +/** Save current scroll position into history state for back/forward restoration. + * + * Merging into the existing state preserves any router-owned fields (`__N`, + * `url`, `as`, `options`, `key`). If the install-time stamp didn't run + * (Router.push called before installPagesRouterRuntime), fall back to + * minting the same shape here so the entry isn't treated as foreign. + */ function saveScrollPosition(): void { - const state = window.history.state ?? {}; - window.history.replaceState( - { ...state, __vinext_scrollX: window.scrollX, __vinext_scrollY: window.scrollY }, - "", - ); + const existing = + typeof window.history.state === "object" && window.history.state !== null + ? (window.history.state as Record) + : null; + const scroll = { + __vinext_scrollX: window.scrollX, + __vinext_scrollY: window.scrollY, + }; + const base: Record = existing ?? buildInitialRouterState(); + window.history.replaceState({ ...base, ...scroll }, ""); } /** Restore scroll position from history state */ @@ -828,6 +876,12 @@ function extractHash(url: string): string { return i === -1 ? "" : url.slice(i); } +/** Return the URL with any trailing `#fragment` removed. */ +function stripHash(url: string): string { + const i = url.indexOf("#"); + return i === -1 ? url : url.slice(0, i); +} + /** Notify in-page listeners (e.g. useRouter hooks) that navigation occurred. */ function dispatchNavigateEvent(): void { window.dispatchEvent(new CustomEvent("vinext:navigate")); @@ -836,13 +890,63 @@ function dispatchNavigateEvent(): void { /** * Update history with the new URL and refresh the hash-only-detection tracker. * Centralises the `pushState`/`replaceState` branch so callers don't repeat it. + * + * Writes a Next.js-compatible state shape so popstate can detect non-router + * entries, ignore stale Safari-style replays, and recover the active locale + * across browser back/forward. Mirrors `Router.changeState` in + * .nextjs-ref/packages/next/src/shared/lib/router/router.ts (around L1916). + * + * @param mode push or replace + * @param fullUrl absolute URL committed to the browser (with basePath) + * @param navState router-level metadata (`url`, `as`, `options`) the popstate + * handler needs to honour stickiness — most importantly the active + * locale and the canonical app-relative `as` path. */ -function updateHistory(mode: "push" | "replace", url: string): void { - if (mode === "push") window.history.pushState({}, "", url); - else window.history.replaceState({}, "", url); +function updateHistory( + mode: "push" | "replace", + fullUrl: string, + navState?: { url?: string; as?: string; options?: { locale?: string; shallow?: boolean } }, +): void { + const previousState = + typeof window.history.state === "object" && window.history.state !== null + ? (window.history.state as { key?: string }) + : null; + const key = mode === "push" ? createHistoryKey() : (previousState?.key ?? createHistoryKey()); + const stateUrl = navState?.url ?? fullUrl; + const stateAs = navState?.as ?? fullUrl; + const options = navState?.options ?? {}; + const state: VinextHistoryState = { + url: stateUrl, + as: stateAs, + options, + __N: true, + key, + }; + if (mode === "push") window.history.pushState(state, "", fullUrl); + else window.history.replaceState(state, "", fullUrl); _lastPathnameAndSearch = window.location.pathname + window.location.search; } +/** + * Minimal Next.js-compatible history state shape. We deliberately keep this + * narrow: only the fields popstate and the i18n stickiness machinery read. + */ +type VinextHistoryState = { + url: string; + as: string; + options: { locale?: string; shallow?: boolean }; + __N: true; + key: string; +}; + +let _historyKeyCounter = 0; +function createHistoryKey(): string { + _historyKeyCounter += 1; + // Same intent as Next.js's createKey() — opaque, monotonic-ish, fine for + // identifying history entries client-side. + return `vinext_${_historyKeyCounter.toString(36)}_${Math.random().toString(36).slice(2, 9)}`; +} + /** * Throw the canonical "no router instance" error used when a Pages Router * navigation method (push/replace/back/reload/prefetch/beforePopState) is @@ -927,11 +1031,20 @@ async function performNavigation( const shallow = options?.shallow ?? false; const doScroll = options?.scroll !== false; + // History state metadata — surfaces the active locale to popstate and the + // Safari-replay filter. `as` is the canonical app-relative path (no + // basePath, no hash) so it can be compared against `_lastPathnameAndSearch` + // (which is `pathname + search` only) in the popstate handler. + const navStateOptions: { locale?: string; shallow: boolean } = { shallow }; + if (navigationLocale !== undefined) navStateOptions.locale = navigationLocale; + const resolvedNoHash = stripHash(resolved); + const navState = { url: resolvedNoHash, as: resolvedNoHash, options: navStateOptions }; + // Hash-only change — no page fetch needed if (isHashOnlyChange(full)) { const eventUrl = resolveHashUrl(full); routerEvents.emit("hashChangeStart", eventUrl, { shallow }); - updateHistory(mode, resolved.startsWith("#") ? resolved : full); + updateHistory(mode, resolved.startsWith("#") ? resolved : full, navState); if (doScroll) scrollToHashTarget(extractHash(resolved)); onStateUpdate?.(); routerEvents.emit("hashChangeComplete", eventUrl, { shallow }); @@ -942,7 +1055,7 @@ async function performNavigation( if (mode === "push") saveScrollPosition(); routerEvents.emit("routeChangeStart", resolved, { shallow }); routerEvents.emit("beforeHistoryChange", resolved, { shallow }); - updateHistory(mode, full); + updateHistory(mode, full, navState); if (!shallow) { const result = await runNavigateClient(full, resolved, htmlFetchUrl); if (result === "cancelled") return true; @@ -1030,10 +1143,79 @@ let _beforePopStateCb: BeforePopStateCallback | undefined; let _lastPathnameAndSearch = typeof window !== "undefined" ? window.location.pathname + window.location.search : ""; +// Tracks whether we have observed at least one popstate event in this +// document. Safari fires a synthetic popstate on tab reopen / restore which +// must be ignored when the carried state matches the page we're already on. +// +// Ported from Next.js: packages/next/src/shared/lib/router/router.ts +// (the `isFirstPopStateEvent` flag around the `onPopState` handler, ~L935). +let _isFirstPopStateEvent = true; + +function isNextRouterState(state: unknown): state is { + url?: string; + as?: string; + options?: { locale?: string; shallow?: boolean }; + __N: true; + key?: string; +} { + return ( + typeof state === "object" && + state !== null && + "__N" in state && + (state as { __N?: unknown }).__N === true + ); +} + function handlePagesRouterPopState(e: PopStateEvent): void { const browserUrl = window.location.pathname + window.location.search; const appUrl = stripBasePath(window.location.pathname, __basePath) + window.location.search; + const state = e.state as unknown; + const wasFirst = _isFirstPopStateEvent; + _isFirstPopStateEvent = false; + + // History entries written by third-party code (state without `__N: true`) + // are not owned by the router. Mirror Next.js's `if (!state.__N) return` + // early-exit so a non-router pushState doesn't trigger a spurious page + // fetch. + // + // The `null` state case (e.g. the initial document load, scroll-restoration + // popstate, or tests that fire popstate without state) keeps the legacy + // behaviour where we treat it as a back/forward navigation so existing + // popstate tests stay green. Only an *object* state without `__N` is + // treated as foreign. + if (state !== null && state !== undefined && !isNextRouterState(state)) { + return; + } + + // Safari-replay filter: the browser sometimes fires a synthetic popstate + // for the current entry on tab restore / BFCache. Ignore it when the + // entry's locale matches the active locale AND the entry's `as` matches + // the URL the router last actively navigated to. We compare against the + // router-internal tracker (`_lastPathnameAndSearch`, browser-shaped, with + // basePath) rather than the live `window.location` — after a real + // back/forward the browser URL has already changed but the router tracker + // still points at the entry we were on, so a genuine navigation is *not* + // misidentified as a replay. + // + // `state.as` is the canonical app-relative path (no basePath); compose the + // basePath back on for the comparison. + // + // Mirrors Next.js's: + // if (isFirstPopStateEvent && this.locale === state.options.locale + // && state.as === this.asPath) return + // .nextjs-ref/packages/next/src/shared/lib/router/router.ts (around L935). + if (wasFirst && isNextRouterState(state)) { + const currentLocale = window.__VINEXT_LOCALE__; + if ( + state.options?.locale === currentLocale && + typeof state.as === "string" && + withBasePath(state.as, __basePath) === _lastPathnameAndSearch + ) { + return; + } + } + // Detect hash-only back/forward: pathname+search unchanged, only hash differs. const isHashOnly = browserUrl === _lastPathnameAndSearch; @@ -1062,6 +1244,12 @@ function handlePagesRouterPopState(e: PopStateEvent): void { return; } + // If the restored history entry carries an explicit locale, honour it + // when computing the fetch URL so default-locale roots still go through + // their locale-qualified HTML endpoint (parity with the push path). + const stateLocale = isNextRouterState(state) ? state.options?.locale : undefined; + const effectiveLocale = stateLocale ?? window.__VINEXT_LOCALE__; + const fullAppUrl = appUrl + window.location.hash; routerEvents.emit("routeChangeStart", fullAppUrl, { shallow: false }); // Note: The browser has already updated window.location by the time popstate @@ -1074,7 +1262,7 @@ function handlePagesRouterPopState(e: PopStateEvent): void { const result = await runNavigateClient( browserUrl, fullAppUrl, - getPagesHtmlFetchUrl(browserUrl, window.__VINEXT_LOCALE__), + getPagesHtmlFetchUrl(browserUrl, effectiveLocale), ); if (result === "completed") { routerEvents.emit("routeChangeComplete", fullAppUrl, { shallow: false }); diff --git a/tests/pages-router-i18n-sticky-locale.test.ts b/tests/pages-router-i18n-sticky-locale.test.ts new file mode 100644 index 000000000..72d994f55 --- /dev/null +++ b/tests/pages-router-i18n-sticky-locale.test.ts @@ -0,0 +1,691 @@ +/** + * Pages Router i18n sticky-locale tests. + * + * Covers GitHub issue #1336 (item 2): "Locale detection re-runs on every + * request instead of being sticky." This file focuses on the client-side + * half — Link, Router.push/replace, history state shape, and popstate. + * + * Ported behaviours from Next.js: + * - test/e2e/i18n-preferred-locale-detection (locale carries through Link) + * - test/e2e/ignore-invalid-popstateevent (stale popstate ignored, locale + * in history state preserved across back/forward) + * + * The server-side companion (NEXT_LOCALE cookie write on detection) is + * already partially in place via `parseCookieLocaleFromHeader` honouring + * the cookie ahead of Accept-Language detection; setting the cookie on + * initial detection is intentionally out of scope to avoid overlapping + * with #1336 item 4 (default-locale prefix normalisation). + */ +import { describe, it, expect, vi } from "vite-plus/test"; +import path from "node:path"; +import { safeJsonStringify } from "../packages/vinext/src/server/html.js"; +import { buildPagesNextDataScript } from "../packages/vinext/src/server/pages-page-response.js"; + +function createNavWindow() { + const pushState = vi.fn(); + const replaceState = vi.fn(); + const render = vi.fn(); + + const win = { + location: { + pathname: "/", + search: "", + hash: "", + href: "http://localhost/", + hostname: "localhost", + assign: vi.fn(), + replace: vi.fn(), + reload: vi.fn(), + }, + history: { + state: null as unknown, + pushState: pushState as any, + replaceState: replaceState as any, + back: vi.fn(), + }, + dispatchEvent: vi.fn(), + scrollTo: vi.fn(), + scrollX: 0, + scrollY: 0, + addEventListener: vi.fn(), + __NEXT_DATA__: { + page: "/", + query: {}, + isFallback: false, + props: { pageProps: {} }, + __vinext: { pageModuleUrl: "/@fs/pages/index.js" }, + }, + __VINEXT_ROOT__: { render }, + __VINEXT_APP__: undefined, + __VINEXT_LOCALE__: undefined, + __VINEXT_LOCALES__: undefined, + __VINEXT_DEFAULT_LOCALE__: undefined, + }; + + pushState.mockImplementation((state: unknown, _title: string, url: string) => { + win.history.state = state; + try { + const parsed = new URL(url, "http://localhost"); + win.location.pathname = parsed.pathname; + win.location.search = parsed.search; + win.location.hash = parsed.hash; + win.location.href = parsed.href; + } catch { + win.location.pathname = url; + win.location.href = "http://localhost" + url; + } + }); + + replaceState.mockImplementation((state: unknown, _title: string, url?: string) => { + win.history.state = state; + if (!url) return; + try { + const parsed = new URL(url, "http://localhost"); + win.location.pathname = parsed.pathname; + win.location.search = parsed.search; + win.location.hash = parsed.hash; + win.location.href = parsed.href; + } catch { + win.location.pathname = url; + win.location.href = "http://localhost" + url; + } + }); + + return { win, pushState, replaceState, render }; +} + +function buildNavHtml( + page: string, + pageModuleUrl: string, + query: Record = {}, + i18n?: { locale: string; locales: string[]; defaultLocale: string }, +): string { + const nextDataScript = buildPagesNextDataScript({ + buildId: null, + i18n: i18n ?? {}, + pageProps: { page }, + params: query, + routePattern: page, + safeJsonStringify, + vinext: { pageModuleUrl }, + }); + return `${nextDataScript}`; +} + +const PAGE_MODULE_URL = path.resolve(import.meta.dirname, "fixtures/client-navigation-page.tsx"); + +// 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. +// https://github.com/vercel/next.js/blob/canary/packages/next/src/shared/lib/router/router.ts (around L1916) +describe("Pages Router history state shape", () => { + it("Router.push writes a Next-shaped history state including the active locale", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, pushState } = createNavWindow(); + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "id"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + (globalThis as any).window = win; + + globalThis.fetch = vi.fn( + async () => + new Response( + buildNavHtml( + "/about", + PAGE_MODULE_URL, + {}, + { locale: "en", locales: ["en", "id"], defaultLocale: "en" }, + ), + { status: 200 }, + ), + ); + + try { + vi.resetModules(); + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + await Router.push("/about"); + + // First arg of the latest pushState call is the state object. + expect(pushState).toHaveBeenCalled(); + const lastCall = pushState.mock.calls.at(-1)!; + const state = lastCall[0] as Record & { + options?: { locale?: string }; + }; + expect(state.__N).toBe(true); + expect(typeof state.url).toBe("string"); + expect(typeof state.as).toBe("string"); + expect(state.options?.locale).toBe("en"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("Router.push with an explicit locale stamps that locale into history state", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, pushState } = createNavWindow(); + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "fr"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + (globalThis as any).window = win; + + globalThis.fetch = vi.fn( + async () => + new Response( + buildNavHtml( + "/about", + PAGE_MODULE_URL, + {}, + { locale: "fr", locales: ["en", "fr"], defaultLocale: "en" }, + ), + { status: 200 }, + ), + ); + + try { + vi.resetModules(); + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + await Router.push("/about", undefined, { locale: "fr" }); + + const lastCall = pushState.mock.calls.at(-1)!; + const state = lastCall[0] as { options?: { locale?: string } }; + expect(state.options?.locale).toBe("fr"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); + + it("Router.push with locale: false records the default locale in history state", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, pushState } = createNavWindow(); + Object.assign(win, { + __VINEXT_LOCALE__: "fr", + __VINEXT_LOCALES__: ["en", "fr"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + (globalThis as any).window = win; + + globalThis.fetch = vi.fn( + async () => + new Response( + buildNavHtml( + "/about", + PAGE_MODULE_URL, + {}, + { locale: "en", locales: ["en", "fr"], defaultLocale: "en" }, + ), + { status: 200 }, + ), + ); + + try { + vi.resetModules(); + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + await Router.push("/about", undefined, { locale: false }); + + const lastCall = pushState.mock.calls.at(-1)!; + const state = lastCall[0] as { options?: { locale?: string } }; + // Next.js resolves locale: false to defaultLocale for transition tracking. + expect(state.options?.locale).toBe("en"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); +}); + +// Ported from Next.js test/e2e/ignore-invalid-popstateevent/with-i18n.test.ts +// and without-i18n.test.ts. +// Next.js drops the first popstate event whose state.options.locale equals the +// current locale AND state.as equals the current asPath (treating it as a +// browser-replay / Safari re-open), and processes the second identical event +// normally. +// 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) => { + 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; + } + + it("ignores the first popstate whose state matches the current locale and asPath", async () => { + // Mirrors Next.js's `isFirstPopStateEvent && locale === state.options.locale + // && state.as === asPath` early-exit. The most important assertion is that + // we do NOT trigger a page fetch on Safari-style replay events. + 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"; + Object.assign(win, { + __VINEXT_LOCALE__: "sv", + __VINEXT_LOCALES__: ["en", "sv"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + + let routeChangeStartCount = 0; + const fetchMock = vi.fn( + 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; + + 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; + }); + + // First popstate: same locale, same as path → must be ignored. + popstateHandler!({ + state: { + url: "/[dynamic]", + as: "/static", + options: { locale: "sv" }, + __N: true, + key: "", + }, + }); + await new Promise((r) => setTimeout(r, 0)); + expect(fetchMock).not.toHaveBeenCalled(); + expect(routeChangeStartCount).toBe(0); + } 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". + // We assert via the routeChangeStart event so we exercise the path even + // when the test fixture's window/location doesn't differ from `_last…`. + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const originalCustomEvent = globalThis.CustomEvent; + const { win } = createNavWindow(); + win.location.pathname = "/sv/static"; + win.location.href = "http://localhost/sv/static"; + Object.assign(win, { + __VINEXT_LOCALE__: "sv", + __VINEXT_LOCALES__: ["en", "sv"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + + let routeChangeStartCount = 0; + const fetchMock = vi.fn( + async () => + new Response( + buildNavHtml( + "/[dynamic]", + PAGE_MODULE_URL, + {}, + { locale: "en", locales: ["en", "sv"], defaultLocale: "en" }, + ), + { 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; + }); + + // Simulate that the URL changed (back/forward navigated to a different + // entry) so the hash-only fast-path doesn't swallow the event. + win.location.pathname = "/en/static"; + win.location.href = "http://localhost/en/static"; + + popstateHandler!({ + state: { + url: "/[dynamic]", + as: "/static", + options: { locale: "en" }, + __N: true, + key: "", + }, + }); + await new Promise((r) => setTimeout(r, 0)); + // Different locale → stale-filter must not skip; navigation proceeds. + expect(routeChangeStartCount).toBe(1); + } 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 popstate events without state.__N (non-Next-router history entries)", async () => { + 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"; + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "sv"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + + const fetchMock = vi.fn(async () => new Response("", { 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(); + + // History entry from non-Next code (third-party history.pushState). + // Mirrors Next.js's `if (!state.__N) return` early-exit. + popstateHandler!({ + state: { foreign: true }, + }); + await new Promise((r) => setTimeout(r, 0)); + expect(fetchMock).not.toHaveBeenCalled(); + } 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/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 +// machinery and downstream consumers. +describe("Pages Router locale stickiness on programmatic navigation", () => { + it("Router.push without an explicit locale preserves the current locale in history state", async () => { + const previousWindow = (globalThis as any).window; + const originalFetch = globalThis.fetch; + const { win, pushState } = createNavWindow(); + // Current URL is /id/* so `getCurrentUrlLocale` resolves to "id" (the + // URL is the source of truth post-#1442; setting __VINEXT_LOCALE__ alone + // is no longer enough — it must agree with the path). + win.location.pathname = "/id/start"; + win.location.href = "http://localhost/id/start"; + Object.assign(win, { + __VINEXT_LOCALE__: "id", + __VINEXT_LOCALES__: ["en", "id"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + (globalThis as any).window = win; + + globalThis.fetch = vi.fn( + async () => + new Response( + buildNavHtml( + "/about", + PAGE_MODULE_URL, + {}, + { locale: "id", locales: ["en", "id"], defaultLocale: "en" }, + ), + { status: 200 }, + ), + ); + + try { + vi.resetModules(); + const routerModule = await import("../packages/vinext/src/shims/router.js"); + const Router = routerModule.default; + + await Router.push("/about"); + + const lastCall = pushState.mock.calls.at(-1)!; + const state = lastCall[0] as { options?: { locale?: string } }; + expect(state.options?.locale).toBe("id"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + globalThis.fetch = originalFetch; + } + }); +}); + +// The initial document entry is created by the browser with `state: null`. If +// we don't stamp it with Next.js-shaped state on install, a later +// back-navigation popstate has no recorded locale and the handler falls back +// to `window.__VINEXT_LOCALE__` — which may have been changed by an +// intervening locale-switching push, fetching the wrong locale's HTML for the +// initial entry. installPagesRouterRuntime() runs replaceState once at boot +// 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) => { + 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; + } + + it("install stamps the initial document entry with the active locale", async () => { + const previousWindow = (globalThis as any).window; + const { win, replaceState } = createNavWindow(); + // Locale-prefixed URL so getCurrentUrlLocale resolves to "fr" from the + // path (post-#1442 URL-first locale inference). + win.location.pathname = "/fr/about"; + win.location.href = "http://localhost/fr/about"; + Object.assign(win, { + __VINEXT_LOCALE__: "fr", + __VINEXT_LOCALES__: ["en", "fr"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + + try { + await installRuntime(win); + + // First replaceState call is the install-time stamp. + expect(replaceState).toHaveBeenCalled(); + const firstCall = replaceState.mock.calls[0]!; + const state = firstCall[0] as { __N?: true; options?: { locale?: string }; as?: string }; + expect(state.__N).toBe(true); + expect(state.options?.locale).toBe("fr"); + expect(state.as).toBe("/fr/about"); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + } + }); + + it("install does not overwrite pre-existing history state", async () => { + const previousWindow = (globalThis as any).window; + const { win, replaceState } = createNavWindow(); + win.history.state = { foreign: true }; + Object.assign(win, { __VINEXT_LOCALE__: "fr" }); + + try { + await installRuntime(win); + expect(replaceState).not.toHaveBeenCalled(); + } finally { + vi.resetModules(); + if (previousWindow === undefined) { + delete (globalThis as any).window; + } else { + (globalThis as any).window = previousWindow; + } + } + }); + + it("back-nav to the initial entry uses the stamped locale, not the live window global", async () => { + // The regression this guards against: land on `/` under the default + // locale "en" (browser URL unprefixed), push to `/fr/about` with locale + // "fr" (flips window.__VINEXT_LOCALE__), hit back. Without an + // install-time stamp the popstate handler reads the live window global + // ("fr") and fetches `/fr` for the root entry — the wrong locale. + // Default-locale roots route through a locale-qualified HTML endpoint, + // so the wrong locale changes which HTML the page receives. + 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/"; + Object.assign(win, { + __VINEXT_LOCALE__: "en", + __VINEXT_LOCALES__: ["en", "fr"], + __VINEXT_DEFAULT_LOCALE__: "en", + }); + + const fetchCalls: string[] = []; + globalThis.fetch = vi.fn(async (input: any) => { + fetchCalls.push(typeof input === "string" ? input : input.url); + return new Response( + buildNavHtml( + "/", + PAGE_MODULE_URL, + {}, + { locale: "en", locales: ["en", "fr"], defaultLocale: "en" }, + ), + { status: 200 }, + ); + }) as any; + (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(); + + // Capture the install-time stamped state — this is what the browser + // hands back on a popstate to the initial entry. + const stampedState = win.history.state as { options?: { locale?: string }; as?: string }; + expect(stampedState.options?.locale).toBe("en"); + expect(stampedState.as).toBe("/"); + + // Simulate a forward push to /fr/about (locale "fr"). We just need to + // advance the popstate handler's internal trackers + // (`_lastPathnameAndSearch`, `_isFirstPopStateEvent`) past boot — in + // production this happens via Router.push, but driving the popstate + // handler directly is simpler than mocking the full push flow. + win.location.pathname = "/fr/about"; + win.location.href = "http://localhost/fr/about"; + popstateHandler!({ + state: { url: "/about", as: "/about", options: { locale: "fr" }, __N: true, key: "" }, + }); + await new Promise((r) => setTimeout(r, 0)); + fetchCalls.length = 0; + + // Now back: browser pathname returns to `/` and the popstate carries + // the *initial-entry* state we stamped. The live window global has + // flipped to "fr" from the prior forward nav. + Object.assign(win, { __VINEXT_LOCALE__: "fr" }); + win.location.pathname = "/"; + win.location.href = "http://localhost/"; + + popstateHandler!({ state: stampedState }); + await new Promise((r) => setTimeout(r, 0)); + + // The fetch must use the *stamped* locale ("en") → /en, not /fr. + expect(fetchCalls.length).toBeGreaterThan(0); + const backFetchUrl = fetchCalls[0]!; + expect(backFetchUrl).toBe("/en"); + } 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; + } + }); +}); diff --git a/tests/shims.test.ts b/tests/shims.test.ts index 1c5f0706b..d0d8738c0 100644 --- a/tests/shims.test.ts +++ b/tests/shims.test.ts @@ -11034,7 +11034,11 @@ describe("Pages Router router helpers", () => { { shallow: true }, ); - expect(pushState).toHaveBeenCalledWith({}, "", "/search?tag=a&tag=b&q=x"); + expect(pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true }), + "", + "/search?tag=a&tag=b&q=x", + ); } finally { if (previousWindow === undefined) { delete (globalThis as any).window; @@ -11090,7 +11094,7 @@ describe("Pages Router router helpers", () => { ); expect(pushState).toHaveBeenCalledWith( - {}, + expect.objectContaining({ __N: true }), "", "/search?page=2&draft=false&empty=&missing=&tag=a&tag=b", ); @@ -11523,7 +11527,13 @@ describe("Pages Router concurrent navigation", () => { const result = await Router.push(target, undefined, { shallow: true }); expect(result).toBe(true); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", expectedBrowserUrl); + // History state now follows Next.js shape ({ url, as, options, __N, key }); + // assert via partial match so test stays focused on URL normalization. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true }), + "", + expectedBrowserUrl, + ); } finally { if (previousTrailingSlash === undefined) { delete process.env.__VINEXT_TRAILING_SLASH; @@ -11848,7 +11858,14 @@ describe("Pages Router concurrent navigation", () => { expect(result).toBe(true); expect(fetch).toHaveBeenCalledWith("/en", expect.any(Object)); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/"); + // Next.js-shaped history state (`{ url, as, options, __N, key }`) is now + // written on every router push; assert via partial match so this test + // stays focused on the locale-qualified fetch / browser-URL contract. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true, options: expect.objectContaining({ locale: "en" }) }), + "", + "/", + ); expect(win.location.href).toBe("http://localhost/"); expect(win.__VINEXT_LOCALE__).toBe("en"); } finally { @@ -11908,7 +11925,13 @@ describe("Pages Router concurrent navigation", () => { expect(result).toBe(true); expect(fetch).toHaveBeenCalledWith("/en", expect.any(Object)); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/"); + // Next.js-shaped history state ({ url, as, options, __N, key }) is now + // written on every router push; assert via partial match. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true, options: expect.objectContaining({ locale: "en" }) }), + "", + "/", + ); expect(win.location.href).toBe("http://localhost/"); expect(win.__VINEXT_LOCALE__).toBe("en"); } finally { @@ -11966,7 +11989,12 @@ describe("Pages Router concurrent navigation", () => { expect(result).toBe(true); expect(fetch).toHaveBeenCalledWith("/id", expect.any(Object)); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/id"); + // Next.js-shaped history state — assert partially. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true, options: expect.objectContaining({ locale: "id" }) }), + "", + "/id", + ); expect(win.location.href).toBe("http://localhost/id"); expect(win.__VINEXT_LOCALE__).toBe("id"); } finally { @@ -12091,7 +12119,12 @@ describe("Pages Router concurrent navigation", () => { expect(result).toBe(true); expect(fetch).toHaveBeenCalledWith("/fr/about", expect.any(Object)); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", "/fr/about"); + // Next.js-shaped history state — assert partially. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true }), + "", + "/fr/about", + ); expect(win.location.href).toBe("http://localhost/fr/about"); expect(win.__VINEXT_LOCALE__).toBe("fr"); } finally { @@ -12158,7 +12191,12 @@ describe("Pages Router concurrent navigation", () => { expect(result).toBe(true); expect(fetch).not.toHaveBeenCalled(); - expect(win.history.pushState).toHaveBeenCalledWith({}, "", expectedBrowserUrl); + // Hash-only push now records Next.js-shaped history state too. + expect(win.history.pushState).toHaveBeenCalledWith( + expect.objectContaining({ __N: true }), + "", + expectedBrowserUrl, + ); expect(hashEvents).toEqual([`start:${expectedEventUrl}`, `complete:${expectedEventUrl}`]); expect(routeEvents).toEqual([]); } finally {