history.scrollRestoration should never be changed from 'auto' — 'manual' regresses iOS Safari swipe-back and Chrome refresh (follow-up to #7815)
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
onRenderedsubscriber), 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
scrollToclamps 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'sonRenderedrestore 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 resolvedLocation → onRendered, 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.
Source: TanStack/router