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 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.
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.tsonmainstill setshistory.scrollRestoration = 'manual'(L204) wheneverscrollRestoration: true.Proposal: never change
history.scrollRestorationfrom'auto'. Remove the'manual'assignment entirely.Forcing
'manual'visibly regresses the two most-used browser engines:onRenderedsubscriber), so the page repaints at the wrong offset after the gesture completes.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 nothinghistory.scrollRestorationgoverns only the window/viewport. Everything the router's scroll restoration feature adds on top is unaffected by the setting:'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.'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.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)
history.scrollRestorationbehavior'manual'only behind off-by-defaultexperimental.scrollRestoration'manual'only while<ScrollRestoration/>is mounted, and reverts to'auto'on unmount and inside itspagehidesave handler so reloads/bfcache/swipe-back stay native'manual', Nuxt immediately overrides to'auto'and defers'manual'until the first client-side navigation — a fresh load or reload never enters'manual''manual'only behind the opt-inscrollRestorationprop<ClientRouter/>'manual', but restores synchronously inside the popstate transition fromhistory.state, not post-paintNo 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 insetupScrollRestoration. Everything else — the sessionStorage cache, the snapshots, theonRenderedrestore, 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. CustomgetScrollRestorationKeysetups 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 forresolvedLocation→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,pagehidehandoffs) is needed.