Skip to content

fix(app-router): replay Back/Forward missed before hydration - #2834

Open
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-2822-back-before-hydration
Open

fix(app-router): replay Back/Forward missed before hydration#2834
NathanDrake2406 wants to merge 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/fix-2822-back-before-hydration

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Closes #2822. Ports vercel/next.js#96252 (0fd2e51).

Problem

Navigate client-side (//about via Link), then reload. Chrome preserves the same-document association across the reload, so the entry pushed before the reload is still a same-document sibling. Pressing Back while the new document has committed but not hydrated is therefore an instant same-document traversal: the URL bar moves to /, popstate fires, and nobody is listening — vinext installs its listener at the end of bootstrapHydration(), after the awaits in main() and after the entry script itself has loaded.

Reproduced against tests/fixtures/app-basic before the fix (scripts stalled via Playwright routing to make the window deterministic):

Expected: "Welcome to App Router"
Received: "About"

The URL said /, the page rendered /about, and every later Back/Forward moved the URL bar without changing the content. Two mechanisms kept it wrong once hydration finished:

  1. writeBootstrapHistoryMetadata() / writeHydratedHistoryMetadata() replaced the traversed-to entry's own metadata (its traversal index, bfcache ids) with the activation entry's.
  2. The hydration cache publication seeded the visited-response cache with the reloaded /about payload keyed by window.location.href — which was already /. A traversal to / then resolved from that cache and rendered /about again.

Point 2 is vinext-specific: Next.js stores the router tree in the history entry and restores from it, while vinext refetches by URL, so the mis-keyed hydration payload had to be withheld too.

Fix

  • packages/vinext/src/server/app-browser-missed-traversal.ts (new): hasMissedInitialTraversal() compares navigation.activation.entry.key with navigation.currentEntry.key, gated on the live entry being one the App Router owns.
    Ownership needs more than a traversal index: vinext's patched history.pushState/replaceState install during module evaluation — before main()'s awaits — and createExternalHistoryStatePreservingMetadata() copies __vinext_historyIndex onto the caller's state, so a raw push inside that window mints a new Navigation API entry key carrying inherited vinext metadata. The patched writers now record an external-write flag on the shared client navigation state (hasObservedExternalHistoryWrite()), and any such write disqualifies the check. Without it, a shallow/third-party push was misread as a Back/Forward: the hydration payload was discarded and the push replayed as a traverse (verified: 3 RSC fetches for the pushed URL). vinext's own writes go through originalPushState/originalReplaceState and never set the flag. The activation entry is fixed for the document's lifetime and entry keys survive replaceState, so a mismatch before the listener exists is a missed traversal. No-ops where window.navigation is undefined (Firefox/Safari), matching Next.js.
  • AppBrowserHistoryController.markMissedInitialTraversal() makes both initial metadata writes no-op, leaving the traversed-to entry authoritative; the replayed traversal owns every later write.
  • bootstrapHydration() invalidates the hydration cache publication and skips initialPrefetchRouterState when a traversal was missed.
  • The popstate listener body is extracted into handleAppRouterTraversal(state) (and createPopstateRestoreHandler now takes a history state instead of an event) so the replay runs the exact same path as a real popstate. The replay re-checks detection after installing the listener, so a third-party history write in between leaves the traversal unhandled instead of replaying onto the wrong entry.

Verification

  • tests/e2e/app-router/back-before-hydration.spec.ts (new): fails on main with the output above; passes here. Three cases — the replay (including Forward/Back afterwards, and that hydration did not reload the document); a third-party pushState landing before the entry script runs; and a third-party pushState landing after the history patch is installed but before hydration, which is the inherited-metadata case. That third test stalls only the dev-overlay module main() awaits, so the entry graph has evaluated (patch installed) while hydration has not started, and asserts no RSC fetch is issued for the pushed URL. It fails with the external-write guard removed.
  • vp test run tests/shims.test.ts tests/app-browser-entry.test.ts tests/app-browser-history-controller.test.ts tests/link-status-registry.test.ts tests/pages-router-i18n-sticky-locale.test.ts — 1552 passed, including new coverage for the predicate (traversal / same entry / third-party entry / inherited index / no Navigation API), for the suppressed initial writes, and for the shim seam itself: a raw pushState preserves the traversal index, sets the external-write flag, and the predicate then declines the entry.
  • playwright --project=app-router navigation.spec.ts navigation-flows.spec.ts navigation-regressions.spec.ts scroll-restoration.spec.ts hydration.spec.ts — 33 passed.
  • playwright --project=app-router-bfcache — 7 passed.
  • Repo pre-commit gate (full vp check, staged unit/integration tests, knip) passed.

Caveats

  • Detection depends on the Navigation API, so Firefox and Safari keep the current behavior (traversal left unhandled), as upstream does.
  • Not ported: upstream's Suspense-boundary and cacheComponents variants of the scenario, and the search-param variant. The e2e here exercises the shared mechanism on the app-basic fixture rather than adding a dedicated fixture app.

A Back/Forward pressed while the browser entry is still loading (the
awaits in main(), or the script itself) is an instant same-document
traversal: Chrome keeps the same-document association across a reload, so
the URL bar moves and popstate fires with no listener attached. Hydration
then wrote the reloaded payload's metadata onto the traversed-to entry
and seeded the URL-keyed navigation cache under the traversed-to URL, so
the URL and the rendered content disagreed permanently.

Detect the missed traversal during bootstrap using the Navigation API —
the activation entry is fixed for the document's lifetime and entry keys
survive replaceState, so a key mismatch on a vinext-written entry means a
traversal went unobserved. When it happened, skip the bootstrap and
hydrated history writes, skip the hydration cache publication (the
payload belongs to the activation entry, not the live URL), and replay
the traversal through the popstate path once the listener is installed.

Ports vercel/next.js#96252.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

@pkg-pr-new

pkg-pr-new Bot commented Aug 7, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2834
npm i https://pkg.pr.new/create-vinext-app@2834
npm i https://pkg.pr.new/@vinext/types@2834
npm i https://pkg.pr.new/vinext@2834

commit: d3da49f

@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared d3da49f against base 884259a using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 134.7 KB 134.9 KB ⚫ +0.2%
Client entry size (gzip) vinext 122.2 KB 122.5 KB ⚫ +0.2%
Dev server cold start vinext 2.81 s 2.79 s ⚫ -1.0%
Production build time vinext 2.99 s 2.97 s ⚫ -0.6%
RSC entry closure size (gzip) vinext 113.8 KB 113.8 KB ⚫ +0.0%
Server bundle size (gzip) vinext 191.4 KB 191.4 KB ⚫ +0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

vinext's patched history.pushState/replaceState copy the current entry's
__vinext_historyIndex onto the caller's state, so a traversal index does
not prove the entry belongs to the App Router. The patch installs during
module evaluation — before main()'s awaits — so a raw pushState in that
window produced a new Navigation API entry key carrying inherited vinext
metadata, and the missed-traversal check read it as a Back/Forward: it
discarded the hydration payload and replayed the shallow push as a
traverse, fetching RSC for the pushed URL.

Record external history writes in the shared client navigation state and
disqualify the check when one is observed, leaving such entries adopted
and unhandled as intended.
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review August 7, 2026 13:38
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.

App Router client navigation: replay Back/Forward traversal missed before hydration (Navigation API detection)

1 participant