Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions packages/vinext/src/shims/pages-router-runtime.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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);
}
213 changes: 200 additions & 13 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
normalizePathTrailingSlash,
toBrowserNavigationHref,
toSameOriginAppPath,
withBasePath,
} from "./url-utils.js";
import { stripBasePath } from "../utils/base-path.js";
import {
Expand All @@ -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";

/** basePath from next.config.js, injected by the plugin at build time */
const __basePath: string = process.env.__NEXT_ROUTER_BASEPATH ?? "";
Expand Down Expand Up @@ -265,13 +269,56 @@ 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 (from `window.__VINEXT_LOCALE__`) so a back-navigation
* popstate to this entry can recover its locale instead of falling back to
* the live window global — the locale 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 } = {};
if (window.__VINEXT_LOCALE__ !== undefined) options.locale = window.__VINEXT_LOCALE__;
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<string, unknown>)
: null;
const scroll = {
__vinext_scrollX: window.scrollX,
__vinext_scrollY: window.scrollY,
};
const base: Record<string, unknown> = existing ?? buildInitialRouterState();
window.history.replaceState({ ...base, ...scroll }, "");
}

/** Restore scroll position from history state */
Expand Down Expand Up @@ -807,6 +854,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"));
Expand All @@ -815,13 +868,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
Expand Down Expand Up @@ -893,11 +996,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 });
Expand All @@ -908,7 +1020,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;
Expand Down Expand Up @@ -996,10 +1108,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;

Expand Down Expand Up @@ -1028,6 +1209,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
Expand All @@ -1040,7 +1227,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 });
Expand Down
Loading