Replay same-document traversals that happen before hydration#95682
Conversation
Stats from current PRWarning 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
📊 All Metrics📖 Metrics GlossaryDev Server Metrics:
Build Metrics:
Change Thresholds:
📦 Dev Server (Webpack) (Legacy)📦 Dev Server (Webpack)
📦 Production Builds (Webpack) (Legacy)📦 Production Builds (Webpack)
📦 Bundle SizesBundle Sizes📦 WebpackClient Main Bundles
Polyfills
Pages
Server Edge SSR
Middleware
Build DetailsBuild Manifests
Build Cache
🔄 Shared (bundler-independent)Runtimes
📝 Changed Files (21 files)Files with changes:
View diffsapp-page-exp..ntime.dev.jsfailed to diffapp-page-exp..time.prod.jsfailed to diffapp-page-tur..ntime.dev.jsfailed to diffapp-page-tur..time.prod.jsfailed to diffapp-page-tur..ntime.dev.jsfailed to diffapp-page-tur..time.prod.jsfailed to diffapp-page.runtime.dev.jsfailed to diffapp-page.runtime.prod.jsfailed to diffapp-route-ex..ntime.dev.jsDiff too large to display app-route-ex..time.prod.jsDiff too large to display app-route-tu..ntime.dev.jsDiff too large to display app-route-tu..time.prod.jsDiff too large to display app-route-tu..ntime.dev.jsDiff too large to display app-route-tu..time.prod.jsDiff too large to display app-route.runtime.dev.jsDiff too large to display app-route.ru..time.prod.jsDiff too large to display server.runtime.prod.jsDiff too large to display use-cache-pr..ntime.dev.jsDiff too large to display use-cache-pr..ntime.dev.jsDiff too large to display use-cache-pr..ntime.dev.jsDiff too large to display use-cache-pr..ntime.dev.jsDiff too large to display 📎 Tarball URLCommit: a917a47 |
Tests PassedCommit: a917a47 |
05fe589 to
755457e
Compare
| return | ||
| } | ||
| didCheckForTraversalBeforeHydration = true | ||
| if (typeof window.navigation === 'undefined') { |
There was a problem hiding this comment.
Tried without depending on it first but couldn't figure out how to detect this reliably without regressing on other things
0f0ff9a to
e4bb9d1
Compare
238fb95 to
096f4cc
Compare
…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.
096f4cc to
6d54d09
Compare
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.
6d54d09 to
a917a47
Compare
…#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.
…-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.
Fixes a race you can observe if you:
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 firespopstateto 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
onPopStatehandler 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.