Skip to content

fix(router): honor history state url on popstate#2116

Draft
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/ignore-invalid-popstateevent
Draft

fix(router): honor history state url on popstate#2116
NathanDrake2406 wants to merge 8 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/ignore-invalid-popstateevent

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jun 17, 2026

Copy link
Copy Markdown
Contributor

What this changes

Pages Router popstate now tracks the previous full browser URL separately from the existing hash-stripped lastPathnameAndSearch tracker. The popstate hash-only check uses the shared isHashOnlyBrowserUrlChange(window.location.href, lastBrowserUrl, __basePath) helper, matching the push path and avoiding the hash-blind pathname + search comparison.

stateRouteUrl is still used as the masked route fetch target when state.url !== state.as; the masked-route-only guard is gone.

Why

lastPathnameAndSearch is intentionally hash-stripped and is still needed that way for the Safari replay filter and push-to-replace coercion. Using it for popstate hash-only detection made identical no-hash URLs look hash-only because browserUrl === lastPathnameAndSearch could be true without any hash delta.

That was visible in test/e2e/ignore-invalid-popstateevent: both popstate events can keep the address bar on the same static mask (as: "/static") while state.url changes to the dynamic route behind it. The old comparison short-circuited the second event as hash-only and skipped the route fetch. Next.js's onlyAHashChange does not treat an identical no-hash as as hash-only, so it falls through to the full navigation.

CI also caught the companion traversal case: browser back from /#hash to / removes the destination hash but is still hash-only in Next.js. The shared helper now treats same path/search with either a hash-bearing destination or a changed previous hash as hash-only, while still returning false for identical no-hash URLs.

Approach

  • Add lastBrowserUrl to Pages Router runtime state and initialize it from window.location.href.
  • Update it wherever lastPathnameAndSearch is updated after history writes or accepted popstate navigation.
  • Replace the popstate browserUrl === lastPathnameAndSearch hash-only check with isHashOnlyBrowserUrlChange(window.location.href, lastBrowserUrl, __basePath).
  • Keep lastPathnameAndSearch hash-stripped for the existing stale-event and push/replace comparisons.
  • Keep stateRouteUrl as the masked fetch target for runNavigateClient.
  • Deduplicate paired browser URL tracker writes behind updateBrowserUrlTrackers().

Validation

  • vp test run tests/pages-router-i18n-sticky-locale.test.ts
  • vp test run tests/shims.test.ts -t "isHashOnlyBrowserUrlChange|popstate|hash-only"
  • PLAYWRIGHT_PROJECT=pages-router pnpm run test:e2e -- tests/e2e/pages-router/router-events.spec.ts

I also checked server/prod-server.ts and server/dev-server.ts for equivalent Pages Router popstate/hash classification logic to sync; this path is client-side in the Pages Router shim, with no production server counterpart.

Pages Router popstate handling previously derived the route to load from window.location. That loses the route URL stored in Next-shaped history state when url and as differ, so a stale Safari replay is ignored correctly but the next identical popstate still reloads the visible static route instead of the dynamic page route.

Use state.as as the visible URL and state.url as the route fetch target for masked popstate entries, then force the HTML navigation path so data fetching is not keyed by the browser URL. Port the upstream Next.js ignore-invalid-popstateevent without-i18n coverage to lock in the stale-state behaviour.
@pkg-pr-new

pkg-pr-new Bot commented Jun 17, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2116
npm i https://pkg.pr.new/vinext@2116

commit: 80df5ed

NathanDrake2406 and others added 3 commits June 18, 2026 01:41
main's cloudflare#2076 ("enhance navigation with `as` mask") landed an independent
fix for the same masked-popstate bug this branch targets, threading a
`routeUrl` through runNavigateClient/navigateClient and passing raw
{url, as, options} to beforePopState (true Next.js parity). That
implementation supersedes this branch's `forceHtmlFetch` /
resolvePopStateNavigationTarget approach, so the router.ts conflict is
resolved in favour of main's design.

main still had a residual gap: a masked popstate whose visible URL is
unchanged (the exact without-i18n / with-i18n e2e scenario, where `as`
is the static mask on both entries) was classified as hash-only via
`browserUrl === lastPathnameAndSearch` and short-circuited before the
route fetch. Next.js's onlyAHashChange returns false for an identical
no-hash `as`, so it falls through to a full navigation. Hoist main's
stateRouteUrl computation above the hash-only check, derive isMaskedRoute
from it, and exclude masked entries from the hash-only fast path.

Keep this branch's ported regression tests (first stale event ignored,
second fetches state.url) and add the i18n and basePath companions the
review flagged as missing. beforePopState masked coverage already exists
on main via cloudflare#2076, so it is not duplicated here.
@github-actions

github-actions Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 80df5ed against base 42f8700.

0 improved · 4 regressed · 2 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) Next.js 185.6 KB 185.6 KB ⚫ 0.0%
Client bundle size (gzip) vinext 129.9 KB 129.9 KB ⚫ +0.0%
Dev server cold start Next.js 2.26 s 2.45 s 🔴 +8.3%
Dev server cold start vinext 2.44 s 2.69 s 🔴 +9.9%
Production build time Next.js 3.98 s 4.39 s 🔴 +10.3%
Production build time vinext 2.66 s 2.96 s 🔴 +11.5%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5%

The new i18n / basePath popstate tests typed their fetch mock and history
state loosely, so `vp check` flagged the `mock.calls[0]` index and the
state literals carried `any`. Type the fetch mock with its call signature,
and introduce `MaskedHistoryState` / `PopStateListener` aliases so the
ported popstate fixtures and the handler invocations are checked.
The masked-popstate tests construct `MaskedHistoryState` by hand and drive
the captured popstate handler in isolation. That proves the consumer reads
`state.url`, but nothing exercises the writer, so a future change to
`updateHistory`/`Router.push` that stopped emitting `url` distinct from `as`
would pass every existing test while breaking masked forward-navigation.

Add a producer→consumer integration test: a masked `Router.push(href, as)`
writes real history state via `updateHistory`, and that exact captured object
is replayed through popstate. It asserts the producer wrote `url !== as` and
the consumer fetched `state.url` (the route), not `state.as` (the address bar).

The test reproduces the PR's bug through the real write path: after the push
the router tracker `lastPathnameAndSearch` equals the visible URL, so the
masked entry lands on the identical-URL case that the `isMaskedRoute` guard
excludes from the hash-only fast path. Verified red on the pre-guard
`isHashOnly` (0 fetches), green with the guard.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant