Skip to content

Forward-navigation scroll reset runs one paint too late; forcing history.scrollRestoration = 'manual' breaks native restoration (iOS swipe-back, Chrome hard refresh) #7815

Description

@daveycodez

Which project does this relate to?

Start

Describe the bug

Forward-navigation scroll reset runs one paint too late; forcing history.scrollRestoration = 'manual' breaks native restoration (iOS swipe-back, Chrome hard refresh)

Summary

Two related problems in scroll restoration, both traced to source:

  1. The scroll-to-top on PUSH navigations fires one React commit (and one browser paint) after the commit that mounts the new page. While scrolled down, clicking a link paints the new page at the old scroll offset for one frame before snapping to top. Navigating from a long page to a short one paints the new page pinned to its bottom for a frame (the browser clamps scrollY to the shorter document at layout time), which proves the reset is post-paint.

  2. setupScrollRestoration unconditionally forces history.scrollRestoration = 'manual', which disables the browser's native (pre-paint) restoration. Native restoration is strictly better-timed than the router's post-paint restore, and forcing manual visibly regresses two flows: iOS Safari swipe-back (the gesture's preview snapshot no longer matches the landing position, and the restore lands post-paint) and Chrome hard refresh (page paints at top, then snaps down after hydration). Overriding it back to 'auto' in userland fixes both with no observed downside — the router's own restore just becomes a same-position no-op.

Reproduction

Any TanStack Start (or plain @tanstack/react-router) app with scrollRestoration: true:

  1. Make a route tall enough to scroll, plus a second, shorter route.
  2. Scroll down on the tall route, click a <Link> to the short route.
  3. Observe one frame of the short route rendered at the old scroll offset (bottom-pinned if the new page is shorter), then a snap to top.

For problem 2: scroll down, reload (Chrome) — page paints at top, then jumps down. On iOS Safari, swipe back — the landing doesn't match the gesture preview.

Tested on @tanstack/react-router 1.170.17/1.170.18, @tanstack/react-start 1.168.27/1.168.28, React 19, Vite + Cloudflare plugin. Line references below are main as of 2026-07-14.

Root cause (problem 1) — the event chain is one commit too long

The reset is driven by onRendered, but onRendered cannot fire in the commit that mounts the new page. The chain:

  1. packages/router-core/src/router.ts L2453–L2454load() sets isLoading = true and stores.location in the pending commit, while the old page is still on screen.
  2. router.ts L2560 (isLoading.set(false)) and L2569 (setMatches(pendingMatches)) — same batch: this is the commit that actually mounts the new page. This is where the scroll reset needs to happen (pre-paint), and nothing scroll-related runs here.
  3. packages/react-router/src/Transitioner.tsx L112–L128 — only after isAnyPending flips false does a layout effect emit onResolved and set stores.resolvedLocation. Because setIsTransitioning(false) is a transition-lane update, this can land in a later commit than step 2 — after the browser has painted the new page at the old offset.
  4. packages/react-router/src/Match.tsx L243 (OnRendered), L267–L286 — a layout effect keyed on resolvedLocation.state.__TSR_key finally emits onRendered.
  5. packages/router-core/src/scroll-restoration.ts L239 — the onRendered subscriber performs the actual scrollTo.

So the reset trails the DOM swap by ≥1 commit, and in practice by a paint. A layout effect in step 2's commit would run pre-paint and eliminate the flash entirely.

Suggested fix

Trigger the reset from the commit identified by href changed && !isLoading (both observable via useRouterState; they change together in the L2560/L2569 batch), instead of waiting for resolvedLocationonRendered. Verified working userland implementation:

function useScrollTopOnPush() {
  const router = useRouter()
  const href = useRouterState({ select: (s) => s.location.href })
  const isLoading = useRouterState({ select: (s) => s.isLoading })

  // history notifies subscribers synchronously inside push()/back(),
  // before React re-renders, so the ref is current in the effect below.
  const actionRef = useRef<string | undefined>(undefined)
  const settledHrefRef = useRef(href)

  useEffect(
    () => router.history.subscribe(({ action }) => {
      actionRef.current = action.type
    }),
    [router],
  )

  useLayoutEffect(() => {
    if (isLoading) return // pending commit: old page still on screen
    const hrefChanged = href !== settledHrefRef.current
    settledHrefRef.current = href
    if (hrefChanged && actionRef.current === 'PUSH') window.scrollTo(0, 0)
  }, [href, isLoading])
}

With this, the new page is scrolled to top before its first paint. No flash in either direction, including long → short.

Constraint for whoever implements this: the reset must never touch the old page. Scrolling at navigation start (e.g. in onBeforeLoad, or keying only on location.href — which updates in the pending commit, router.ts L2454) scrolls the outgoing page to top. That poisons iOS Safari's swipe-back preview snapshot and its saved scroll state for the history entry: swipe-back then lands at the top of the page. We hit this twice while developing the workaround; the !isLoading gate is what makes the effect fire in the new page's mount commit instead.

Root cause (problem 2) — history.scrollRestoration = 'manual'

packages/router-core/src/scroll-restoration.ts L204 forces history.scrollRestoration = 'manual'.

The browser's native restoration for same-document traversals and reloads is pre-paint. The router's replacement (the onRendered restore, L239) is post-paint per problem 1. So manual trades a pre-paint restore for a post-paint one:

  • iOS Safari swipe-back: natively, the entry's saved scroll is restored in sync with the gesture, so the preview snapshot matches the landing. With manual, the page lands wherever it was and the router corrects it after paint — visible jump, mismatched gesture preview.
  • Chrome hard refresh: natively restores the scroll offset before first paint. With manual, the page paints at top and snaps down after hydration.

Our app runs with history.scrollRestoration = 'auto' (re-set after the router forces manual), keeping the router's restoration active on top. Native handles BACK/FORWARD/reload pre-paint; the router's post-paint restore resolves to the same position (same cache) and is imperceptible. Element-level (non-window) restoration still works — native auto doesn't handle inner scroll containers, the router's cache does, so the two are complementary rather than conflicting.

Request: don't force manual — default to leaving history.scrollRestoration alone (or expose an option). We could not find a scenario where manual produces a better outcome than auto; every flow we tested (push, back/forward, swipe-back, hard refresh) is equal or strictly worse under manual.

Related

Complete minimal reproducer

https://stackblitz.com/edit/github-6c8dkjrz?file=src%2Froutes%2F__root.tsx

Steps to Reproduce the Bug

Bug 1 - Scroll to top flicker on forward navigation.

  1. Open the app on index page.
  2. Scroll down far.
  3. Press About Link
  4. Notice that About page renders scrolled down for 1 frame then snaps to the top.

Bug 2 - Scroll restoration flicker on backward navigation.

  1. Open the app index page, stay scrolled to the top.
  2. Click About Link.
  3. Scroll down far.
  4. Press cmd/ctrl + back to go back a page
  5. Notice how the index page paints scrolled down for 1 frame then snaps to the top

Bug 3 - iOS Swipe navigation

  1. Open the preview on an iPhone.
  2. Click about page
  3. Swipe to scroll back
  4. Notice no preview due to history.scrollRestoration not being set to "auto".

Bug 4 - Chrome scroll restoration flicker on hard navigate.

  1. Open preview on chrome
  2. Scroll down far
  3. Refresh the page (cmd/ctrl+R)
  4. Page renders scrolled top top for 1 frame then snaps to bottom.
  5. This is also fixed by history.scrollRestoration: "auto"

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions