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:
-
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.
-
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:
- Make a route tall enough to scroll, plus a second, shorter route.
- Scroll down on the tall route, click a
<Link> to the short route.
- 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:
packages/router-core/src/router.ts L2453–L2454 — load() sets isLoading = true and stores.location in the pending commit, while the old page is still on screen.
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.
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.
packages/react-router/src/Match.tsx L243 (OnRendered), L267–L286 — a layout effect keyed on resolvedLocation.state.__TSR_key finally emits onRendered.
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 resolvedLocation → onRendered. 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.
- Open the app on index page.
- Scroll down far.
- Press About Link
- Notice that About page renders scrolled down for 1 frame then snaps to the top.
Bug 2 - Scroll restoration flicker on backward navigation.
- Open the app index page, stay scrolled to the top.
- Click About Link.
- Scroll down far.
- Press cmd/ctrl + back to go back a page
- Notice how the index page paints scrolled down for 1 frame then snaps to the top
Bug 3 - iOS Swipe navigation
- Open the preview on an iPhone.
- Click about page
- Swipe to scroll back
- Notice no preview due to history.scrollRestoration not being set to "auto".
Bug 4 - Chrome scroll restoration flicker on hard navigate.
- Open preview on chrome
- Scroll down far
- Refresh the page (cmd/ctrl+R)
- Page renders scrolled top top for 1 frame then snaps to bottom.
- This is also fixed by history.scrollRestoration: "auto"
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:
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
scrollYto the shorter document at layout time), which proves the reset is post-paint.setupScrollRestorationunconditionally forceshistory.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 forcingmanualvisibly 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 withscrollRestoration: true:<Link>to the short route.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-router1.170.17/1.170.18,@tanstack/react-start1.168.27/1.168.28, React 19, Vite + Cloudflare plugin. Line references below aremainas of 2026-07-14.Root cause (problem 1) — the event chain is one commit too long
The reset is driven by
onRendered, butonRenderedcannot fire in the commit that mounts the new page. The chain:packages/router-core/src/router.tsL2453–L2454 —load()setsisLoading = trueandstores.locationin the pending commit, while the old page is still on screen.router.tsL2560 (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.packages/react-router/src/Transitioner.tsxL112–L128 — only afterisAnyPendingflips false does a layout effect emitonResolvedand setstores.resolvedLocation. BecausesetIsTransitioning(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.packages/react-router/src/Match.tsxL243 (OnRendered), L267–L286 — a layout effect keyed onresolvedLocation.state.__TSR_keyfinally emitsonRendered.packages/router-core/src/scroll-restoration.tsL239 — theonRenderedsubscriber performs the actualscrollTo.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 viauseRouterState; they change together in the L2560/L2569 batch), instead of waiting forresolvedLocation→onRendered. Verified working userland implementation: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 onlocation.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!isLoadinggate 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.tsL204 forceshistory.scrollRestoration = 'manual'.The browser's native restoration for same-document traversals and reloads is pre-paint. The router's replacement (the
onRenderedrestore, L239) is post-paint per problem 1. Somanualtrades a pre-paint restore for a post-paint one:manual, the page lands wherever it was and the router corrects it after paint — visible jump, mismatched gesture preview.manual, the page paints at top and snaps down after hydration.Our app runs with
history.scrollRestoration = 'auto'(re-set after the router forcesmanual), 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 — nativeautodoesn'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 leavinghistory.scrollRestorationalone (or expose an option). We could not find a scenario wheremanualproduces a better outcome thanauto; every flow we tested (push, back/forward, swipe-back, hard refresh) is equal or strictly worse undermanual.Related
<ScrollRestoration />does not restore the scroll positions before DOM paint #2601 was the same "restore lands post-paint" class for initial-load restoration, fixed with an inline pre-paint script — this issue is the client-side navigation analog.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.
Bug 2 - Scroll restoration flicker on backward navigation.
Bug 3 - iOS Swipe navigation
Bug 4 - Chrome scroll restoration flicker on hard navigate.