Skip to content

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

Description

@daveycodez

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions