#7956·router

history.scrollRestoration should never be changed from 'auto' — 'manual' regresses iOS Safari swipe-back and Chrome refresh (follow-up to #7815)

Author: daveycodezCreated Aug 5, 2026Updated Sep 10, 2026

Which project does this relate to?

Start

Describe the proposal

Follow-up to #7815, which was closed as completed — but packages/router-core/src/scroll-restoration.ts on main still sets history.scrollRestoration = 'manual' (L204) whenever scrollRestoration: true.

Proposal: never change history.scrollRestoration from 'auto'. Remove the 'manual' assignment entirely.

Forcing 'manual' visibly regresses the two most-used browser engines:

  • iOS Safari (mobile): swipe-back's preview snapshot no longer matches the landing position — native pre-paint restoration is disabled and the router's restore runs post-paint (in the onRendered subscriber), so the page repaints at the wrong offset after the gesture completes.
  • Chrome (desktop): on hard refresh the page paints at top, then snaps down a frame or two later. The SSR inline restore script is one-shot and pre-paint, so with streamed SSR / lazy data / unsized images the document isn't tall enough yet and the scrollTo clamps toward 0; the real restore then lands post-paint. Native 'auto' doesn't have this failure mode — the browser keeps re-applying the target offset as the document grows.

Why 'manual' buys the router nothing

history.scrollRestoration governs only the window/viewport. Everything the router's scroll restoration feature adds on top is unaffected by the setting:

  • Per-element restore of scrollable divs works identically under 'auto' — the browser never restores recreated scroll containers on same-document navigation regardless of the flag, so the router's element machinery is the only thing handling them either way.
  • Window restore on same-document back/forward is better under 'auto': the browser applies the target entry's saved position at popstate time — pre-paint, usually correct, worst case clamped by transient document height — and the router's onRendered restore then acts as a post-paint corrector: a same-position no-op when native got it right, a fix when it clamped. Under 'manual', the pre-paint restore never happens and every traversal eats the post-paint snap.
  • Reload, bfcache, and cross-document traversal (including iOS swipe-back) are handled natively, pre-paint, correctly.

This is not theoretical: overriding the router's 'manual' back to 'auto' in userland — while leaving the router's own restore machinery fully active — has been running in production with no observed downside (this is the workaround documented in #7815). Native and router restoration compose; they are not exclusive.

What other frameworks do (verified against current source, 2026-08-04)

Framework history.scrollRestoration behavior
Next.js App Router never touches it — popstate restore is fully browser-native; the router only manages segment scroll-to-top on push
Next.js Pages Router 'manual' only behind off-by-default experimental.scrollRestoration
React Router / Remix 'manual' only while <ScrollRestoration/> is mounted, and reverts to 'auto' on unmount and inside its pagehide save handler so reloads/bfcache/swipe-back stay native
Nuxt vue-router sets 'manual', Nuxt immediately overrides to 'auto' and defers 'manual' until the first client-side navigation — a fresh load or reload never enters 'manual'
@solidjs/router 'manual' only behind the opt-in scrollRestoration prop
Astro <ClientRouter/> 'manual', but restores synchronously inside the popstate transition from history.state, not post-paint

No comparable router forces 'manual' for the lifetime of the tab the way TanStack does today, and the framework with the strongest scroll-restoration reputation (Next.js App Router) never touches the flag at all.

Scope of the change

Delete the history.scrollRestoration = 'manual' assignment in setupScrollRestoration. Everything else — the sessionStorage cache, the snapshots, the onRendered restore, the per-element tracking, the SSR inline script — stays as is and keeps working; it simply becomes the corrector/backstop layer instead of the sole restore path. Custom getScrollRestorationKey setups are unaffected: the router's restore runs after the browser's, so the custom-key position is always the one that ends up applied.

To fix the remaining post-paint snap properly (rather than relying on native restoration to mask it), the router's own scroll work should also move pre-paint: run the PUSH scroll-to-top and the traversal restore in a layout effect in the commit that mounts the new page (href changed && !isLoading), instead of waiting for resolvedLocationonRendered, which lands ≥1 commit later. That is problem 1 of #7815, with a verified working implementation in that issue. The two changes together are the complete fix: the browser owns pre-paint window restoration at document boundaries and traversals, and the router's push-reset, custom-key restores, and element restores all run pre-paint in the mount commit — no frame ever paints at the wrong offset, and no workaround (UA sniffing, userland 'auto' overrides, pagehide handoffs) is needed.