history.scrollRestoration should never be changed from 'auto' — 'manual' regresses iOS Safari swipe-back and Chrome refresh (follow-up to #7815)
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 15.1k
- Forks
- 1.9k
- Avg merge
- 1d 20h
- Merged PRs (30d)
- 143
Description
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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/router-core/src/scroll-restoration.ts, especially setupScrollRestoration and the onRendered restore path, then read #7815's verified implementation. Confirm the manual assignment is removed and that push resets and traversal restores run in the mount commit before paint, while existing cache, snapshots, element tracking, and SSR behavior remain intact.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100