Skip to content
82 changes: 46 additions & 36 deletions packages/vinext/src/shims/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -414,6 +414,7 @@ type PagesRouterRuntimeState = {
cancelPendingRenderCommit: (() => void) | null;
beforePopStateCb?: BeforePopStateCallback;
lastPathnameAndSearch: string;
lastBrowserUrl: string;
isFirstPopStateEvent: boolean;
routerDidNavigate: boolean;
deprecatedEventBridgeInstalled: boolean;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<Link href="/route" as="/mask">`
// 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;

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 `<Link href="/route" as="/mask">`
// 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,
Expand Down
6 changes: 5 additions & 1 deletion packages/vinext/src/shims/url-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Loading
Loading