fix(app-router): replay Back/Forward missed before hydration - #2834
Open
NathanDrake2406 wants to merge 3 commits into
Open
fix(app-router): replay Back/Forward missed before hydration#2834NathanDrake2406 wants to merge 3 commits into
NathanDrake2406 wants to merge 3 commits into
Conversation
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.
Contributor
Author
|
@codex review |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
commit: |
Contributor
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
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
marked this pull request as ready for review
August 7, 2026 13:38
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #2822. Ports vercel/next.js#96252 (
0fd2e51).Problem
Navigate client-side (
/→/aboutviaLink), 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/,popstatefires, and nobody is listening — vinext installs its listener at the end ofbootstrapHydration(), after theawaits inmain()and after the entry script itself has loaded.Reproduced against
tests/fixtures/app-basicbefore the fix (scripts stalled via Playwright routing to make the window deterministic):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:writeBootstrapHistoryMetadata()/writeHydratedHistoryMetadata()replaced the traversed-to entry's own metadata (its traversal index, bfcache ids) with the activation entry's./aboutpayload keyed bywindow.location.href— which was already/. A traversal to/then resolved from that cache and rendered/aboutagain.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()comparesnavigation.activation.entry.keywithnavigation.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/replaceStateinstall during module evaluation — beforemain()'s awaits — andcreateExternalHistoryStatePreservingMetadata()copies__vinext_historyIndexonto 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 throughoriginalPushState/originalReplaceStateand never set the flag. The activation entry is fixed for the document's lifetime and entry keys survivereplaceState, so a mismatch before the listener exists is a missed traversal. No-ops wherewindow.navigationis 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 skipsinitialPrefetchRouterStatewhen a traversal was missed.handleAppRouterTraversal(state)(andcreatePopstateRestoreHandlernow takes a history state instead of an event) so the replay runs the exact same path as a realpopstate. 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 onmainwith the output above; passes here. Three cases — the replay (including Forward/Back afterwards, and that hydration did not reload the document); a third-partypushStatelanding before the entry script runs; and a third-partypushStatelanding after the history patch is installed but before hydration, which is the inherited-metadata case. That third test stalls only the dev-overlay modulemain()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 rawpushStatepreserves 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.vp check, staged unit/integration tests, knip) passed.Caveats
cacheComponentsvariants of the scenario, and the search-param variant. The e2e here exercises the shared mechanism on theapp-basicfixture rather than adding a dedicated fixture app.