Skip to content

Replay same-document traversals that happen before hydration#95682

Merged
gaearon merged 2 commits into
canaryfrom
gaearon-fix-back-race
Jul 15, 2026
Merged

Replay same-document traversals that happen before hydration#95682
gaearon merged 2 commits into
canaryfrom
gaearon-fix-back-race

Conversation

@gaearon

@gaearon gaearon commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Fixes a race you can observe if you:

  1. Open page A
  2. Navigate to page B
  3. Cmd+Shift+R while on page B...
  4. ...and then navigate Back super fast

In that situation, the router gets confused and shows page B's content with page A's URL. From then on, going Back and Forward changes the URL (and tab title) but keeps showing B's content. See the new tests.

Why This Happens

Navigating from A to B client-side creates B's history entry via pushState. Reloading B replaces that document with B's fresh server-rendered HTML. Pressing Back at that point fires popstate to A, but nothing listens to it yet. Then JS loads, and we happily hydrate B's content onto B's HTML despite technically being on A route.

The Fix

Use Navigation API to detect that we’re on a different entry than we started. Once the router mounts, replay the missed traversal the same way the onPopState handler would have handled it.

(I originally tried to avoid using Navigation API but wasn’t sure how to detect this reliably.)

Test Plan

New tests are red before the fix.

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Stats from current PR

Warning

No stats were collected for Turbopack because its stats job did not complete (it failed, was cancelled, or timed out). The results below only cover the bundlers that finished.

🟢 2 improvements

Metric Canary PR Change Trend
node_modules Size 516 MB 515 MB 🟢 1.06 MB (0%) ▁▁▁▁▁
Webpack Build Time (cached) 26.951s 26.031s 🟢 920ms (-3%) ▁▁█▁▁
📊 All Metrics
📖 Metrics Glossary

Dev Server Metrics:

  • Listen = TCP port starts accepting connections
  • First Request = HTTP server returns successful response
  • Cold = Fresh build (no cache)
  • Warm = With cached build artifacts

Build Metrics:

  • Fresh = Clean build (no .next directory)
  • Cached = With existing .next directory

Change Thresholds:

  • Time: Changes < 50ms AND < 10%, OR < 2% are insignificant
  • Size: Changes < 1KB AND < 1% are insignificant
  • All other changes are flagged to catch regressions
📦 Dev Server (Webpack) (Legacy)

📦 Dev Server (Webpack)

Metric Canary PR Change Trend
Cold (Listen) 814ms 813ms ▂▂█▁▁
Cold (Ready in log) 812ms 790ms ▁▁█▁▁
Cold (First Request) 3.588s 3.482s ▁▁█▁▁
Warm (Listen) 814ms 813ms ▂▂█▁▂
Warm (Ready in log) 805ms 790ms ▁▁█▁▁
Warm (First Request) 3.546s 3.557s ▁▂█▁▁
📦 Production Builds (Webpack) (Legacy)

📦 Production Builds (Webpack)

Metric Canary PR Change Trend
Fresh Build 26.462s 26.268s ▁▁█▁▁
Cached Build 26.951s 26.031s 🟢 920ms (-3%) ▁▁█▁▁
node_modules Size 516 MB 515 MB 🟢 1.06 MB (0%) ▁▁▁▁▁
📦 Bundle Sizes

Bundle Sizes

📦 Webpack

Client

Main Bundles
Canary PR Change
2486.HASH.js gzip 169 B N/A -
3146-HASH.js gzip 64 kB N/A -
39fcf99b-HASH.js gzip 62.9 kB N/A -
8443-HASH.js gzip 4.68 kB N/A -
9431-HASH.js gzip 5.62 kB N/A -
framework-HASH.js gzip 59.8 kB 59.8 kB
main-app-HASH.js gzip 255 B 251 B 🟢 4 B (-2%)
main-HASH.js gzip 39.7 kB 40.1 kB 🔴 +411 B (+1%)
webpack-HASH.js gzip 1.68 kB 1.68 kB
6105-HASH.js gzip N/A 5.63 kB -
764.HASH.js gzip N/A 169 B -
8898-HASH.js gzip N/A 63.5 kB -
9597-HASH.js gzip N/A 4.65 kB -
e1ccab69-HASH.js gzip N/A 62.9 kB -
Total 239 kB 239 kB ✅ -57 B
Polyfills
Canary PR Change
polyfills-HASH.js gzip 39.4 kB 39.4 kB
Total 39.4 kB 39.4 kB
Pages
Canary PR Change
_app-HASH.js gzip 194 B 194 B
_error-HASH.js gzip 183 B 182 B
css-HASH.js gzip 335 B 335 B
dynamic-HASH.js gzip 1.8 kB 1.8 kB
edge-ssr-HASH.js gzip 255 B 254 B
head-HASH.js gzip 351 B 349 B
hooks-HASH.js gzip 384 B 384 B
image-HASH.js gzip 580 B 580 B
index-HASH.js gzip 259 B 259 B
link-HASH.js gzip 2.49 kB 2.49 kB
routerDirect..HASH.js gzip 319 B 319 B
script-HASH.js gzip 387 B 386 B
withRouter-HASH.js gzip 315 B 313 B
1afbb74e6ecf..834.css gzip 106 B 106 B
Total 7.96 kB 7.95 kB ✅ -8 B

Server

Edge SSR
Canary PR Change
edge-ssr.js gzip 128 kB 127 kB
page.js gzip 282 kB 281 kB
Total 410 kB 408 kB ✅ -2.04 kB
Middleware
Canary PR Change
middleware-b..fest.js gzip 616 B 616 B
middleware-r..fest.js gzip 156 B 155 B
middleware.js gzip 45.2 kB 44.9 kB
edge-runtime..pack.js gzip 842 B 842 B
Total 46.8 kB 46.5 kB ✅ -269 B
Build Details
Build Manifests
Canary PR Change
_buildManifest.js gzip 719 B 720 B
Total 719 B 720 B ⚠️ +1 B
Build Cache
Canary PR Change
0.pack gzip 4.79 MB 4.77 MB 🟢 20.1 kB (0%)
index.pack gzip 119 kB 119 kB
index.pack.old gzip 118 kB 118 kB
Total 5.03 MB 5.01 MB ✅ -18.6 kB

🔄 Shared (bundler-independent)

Runtimes
Canary PR Change
app-page-exp...dev.js gzip 363 kB 362 kB
app-page-exp..prod.js gzip 201 kB 200 kB
app-page-tur...dev.js gzip 363 kB 362 kB
app-page-tur..prod.js gzip 201 kB 200 kB
app-page-tur...dev.js gzip 359 kB 358 kB
app-page-tur..prod.js gzip 199 kB 198 kB
app-page.run...dev.js gzip 360 kB 359 kB
app-page.run..prod.js gzip 199 kB 198 kB
app-route-ex...dev.js gzip 81.3 kB 79.1 kB 🟢 2.23 kB (-3%)
app-route-ex..prod.js gzip 55.3 kB 53.6 kB 🟢 1.7 kB (-3%)
app-route-tu...dev.js gzip 81.4 kB 79.1 kB 🟢 2.23 kB (-3%)
app-route-tu..prod.js gzip 55.4 kB 53.7 kB 🟢 1.7 kB (-3%)
app-route-tu...dev.js gzip 81 kB 78.7 kB 🟢 2.22 kB (-3%)
app-route-tu..prod.js gzip 55.1 kB 53.4 kB 🟢 1.69 kB (-3%)
app-route.ru...dev.js gzip 80.9 kB 78.7 kB 🟢 2.22 kB (-3%)
app-route.ru..prod.js gzip 55.1 kB 53.4 kB 🟢 1.69 kB (-3%)
dist_client_...dev.js gzip 324 B 324 B
dist_client_...dev.js gzip 326 B 326 B
dist_client_...dev.js gzip 318 B 318 B
dist_client_...dev.js gzip 317 B 317 B
pages-api-tu...dev.js gzip 45.2 kB 45.2 kB
pages-api-tu..prod.js gzip 34 kB 34 kB
pages-api.ru...dev.js gzip 45.2 kB 45.2 kB
pages-api.ru..prod.js gzip 33.9 kB 33.9 kB
pages-turbo....dev.js gzip 54.7 kB 54.7 kB
pages-turbo...prod.js gzip 39.6 kB 39.6 kB
pages.runtim...dev.js gzip 54.7 kB 54.7 kB
pages.runtim..prod.js gzip 39.6 kB 39.6 kB
server.runti..prod.js gzip 65.3 kB 65.1 kB
use-cache-pr...dev.js gzip 71.4 kB 71.3 kB
use-cache-pr...dev.js gzip 71.4 kB 71.4 kB
use-cache-pr...dev.js gzip 69.7 kB 69.7 kB
use-cache-pr...dev.js gzip 69.7 kB 69.7 kB
Total 3.49 MB 3.46 MB ✅ -24.4 kB
📝 Changed Files (21 files)

Files with changes:

  • app-page-exp..ntime.dev.js
  • app-page-exp..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page-tur..ntime.dev.js
  • app-page-tur..time.prod.js
  • app-page.runtime.dev.js
  • app-page.runtime.prod.js
  • app-route-ex..ntime.dev.js
  • app-route-ex..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route-tu..ntime.dev.js
  • app-route-tu..time.prod.js
  • app-route.runtime.dev.js
  • app-route.ru..time.prod.js
  • server.runtime.prod.js
  • use-cache-pr..ntime.dev.js
  • use-cache-pr..ntime.dev.js
  • use-cache-pr..ntime.dev.js
  • ... and 1 more
View diffs
app-page-exp..ntime.dev.js
failed to diff
app-page-exp..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page-tur..ntime.dev.js
failed to diff
app-page-tur..time.prod.js
failed to diff
app-page.runtime.dev.js
failed to diff
app-page.runtime.prod.js
failed to diff
app-route-ex..ntime.dev.js

Diff too large to display

app-route-ex..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route-tu..ntime.dev.js

Diff too large to display

app-route-tu..time.prod.js

Diff too large to display

app-route.runtime.dev.js

Diff too large to display

app-route.ru..time.prod.js

Diff too large to display

server.runtime.prod.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

use-cache-pr..ntime.dev.js

Diff too large to display

📎 Tarball URL
https://vercel-packages.vercel.app/next/commits/a917a4752ad420ff5568945bc09da7cad0fa36ad/next

Commit: a917a47

@github-actions

github-actions Bot commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

Tests Passed

Commit: a917a47

@gaearon
gaearon force-pushed the gaearon-fix-back-race branch from 05fe589 to 755457e Compare July 10, 2026 15:59
return
}
didCheckForTraversalBeforeHydration = true
if (typeof window.navigation === 'undefined') {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tried without depending on it first but couldn't figure out how to detect this reliably without regressing on other things

Comment thread test/e2e/app-dir/back-before-hydration/app/search/page.tsx Outdated
@gaearon
gaearon force-pushed the gaearon-fix-back-race branch 4 times, most recently from 0f0ff9a to e4bb9d1 Compare July 11, 2026 11:35
@gaearon
gaearon force-pushed the gaearon-fix-back-race branch 3 times, most recently from 238fb95 to 096f4cc Compare July 15, 2026 15:33
…tent

Session history entries created by pushState remain same-document
siblings of a reloaded document. If the user presses Back while the
reloaded page is still loading (easy to hit with a hard reload), the
browser performs an instant same-document traversal: the URL changes
and popstate fires, but no router is attached yet, so the content
does not change. When hydration then completes, the router assumes
the current URL matches its payload and stamps the payload tree onto
the traversed entry via replaceState. From then on the URL bar and
the rendered content permanently disagree, and subsequent traversals
update the URL without ever changing the content.

Covers a path-only traversal and one that differs only in search
params, plus pre-hydration history changes that are NOT missed
traversals and must keep behaving as before: a third-party pushState,
in-page anchor jumps (on a fresh load and between a reload and
hydration), a pushState followed by back, and a traversal onto a
third-party entry. The tests make the race deterministic by stalling
static scripts so the reloaded document commits but cannot hydrate
until released.
@gaearon
gaearon force-pushed the gaearon-fix-back-race branch from 096f4cc to 6d54d09 Compare July 15, 2026 16:05
Session history entries created by pushState remain same-document
siblings of a reloaded document, so a Back/Forward press while the
reloaded page is still loading is an instant same-document traversal
whose popstate fires before the router's code has run. The router then
hydrated assuming the current entry matched its payload, stamped the
payload tree onto the traversed entry, and the URL bar and content
permanently disagreed.

Since the popstate itself is unobservable, detect the traversal at
boot via the Navigation API: the current entry no longer matches the
entry the document was activated on (entry keys are stable across
replaceState but not across traversals). The check also requires the
current entry to be app-router-owned (history.state.__NA): a foreign
entry means an early third-party pushState or an anchor jump from a
stateless entry, where there is nothing to replay and replaying would
spuriously reload. When a missed traversal is detected, the router's
first history write is skipped (one-shot, decided together with the
detection) so the traversed entry's state stays intact, and once the
popstate listener is attached the missed popstate is replayed against
the live history state through the same handler. In browsers without
the Navigation API the traversal remains unhandled, as before.
@gaearon
gaearon force-pushed the gaearon-fix-back-race branch from 6d54d09 to a917a47 Compare July 15, 2026 16:25
@gaearon
gaearon merged commit 2ce362f into canary Jul 15, 2026
136 of 139 checks passed
@gaearon
gaearon deleted the gaearon-fix-back-race branch July 15, 2026 17:35
gaearon added a commit that referenced this pull request Jul 16, 2026
…#95853)

Reverts #95682

This breaks CC (#95848) due to
PPR codepath doing something different. Although Claude says
da4d63d
can work around it, I think it's safest to revert this given that I
don't know what the right full fix is.

I also think we need more test coverage because the navigation test
suite is off in CC mode.

I'll send a follow-up with all the failing tests we know of (from #95682
and new) so we can fix this for good after a closer look.
gaearon added a commit that referenced this pull request Jul 16, 2026
…-hydration tests

Every scenario now runs against two fixture shapes: pages with no
Suspense boundary above them, and pages below a Suspense-wrapped
layout. These exercise different recovery codepaths: without a
boundary, a suspended traversal holds the transition until its data is
ready; with one, it can commit the boundary's fallback and then
depends on React retrying when the data resolves. Under
cacheComponents the boundary variant regressed into a permanently
blank page (#95848) while the boundary-less variant recovered.

On top of the revert, the 'reconciles ...' tests fail in both variants
(URL/content desync); the 'other history changes' tests describe
pre-#95682 behavior and should pass.
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.

2 participants