-
-
Notifications
You must be signed in to change notification settings - Fork 1.6k
Description
Which project does this relate to?
Router
Describe the bug
We ran into a tricky scroll bug in our TanStack Start app. When users navigate between pages via SPA navigation, the destination page sometimes doesn't scroll to top. Instead it gets stuck partway down, even though resetScroll: true is set.
After a lot of debugging, we traced it to a race condition in the onScroll handler inside setupScrollRestoration (scroll-restoration.ts).
The handler reads router.state.location inside the throttled callback, which runs up to 100ms after the actual scroll event fired. During that 100ms window, router.state.location may have already changed to the new route if the user navigated. So the old page's scroll position gets saved under the new page's cache key in sessionStorage.
When restoreScroll() runs on the new page, it finds a cached entry with a non-zero scrollY (belonging to the old page) and restores to that position instead of scrolling to top.
Here's the relevant code:
// scroll-restoration.ts, setupScrollRestoration()
const onScroll = (event: Event) => {
// ...
const restoreKey = getKey(router.stores.location.state) // ← read at EXECUTION time, not event time
scrollRestorationCache.set((state) => {
state[restoreKey].window = { scrollY: window.scrollY }
// ...
})
}The trailing throttle captures args from the first call but restoreKey is not part of those args. It's read from router.state.location when the throttle finally executes.
Suggested Fix
We applied two changes to setupScrollRestoration() in scroll-restoration.ts (well, the compiled scroll-restoration.js). Here is the full diff of our working patch:
Fix 1: Capture restoreKey at scroll event time, not at throttle execution time
We split the original onScroll handler into two parts. The raw scroll event listener resolves the elementSelector and captures the restoreKey immediately. Then it passes both as arguments to a throttled saveScrollPosition function. Since the throttle preserves args from the first call in each 100ms window, the key always reflects the route that was active when the scroll actually happened.
- const onScroll = (event) => {
- if (ignoreScroll || !router.isScrollRestoring) {
- return;
- }
- let elementSelector = "";
- if (event.target === document || event.target === window) {
- elementSelector = "window";
- } else {
- const attrId = event.target.getAttribute(
- "data-scroll-restoration-id"
- );
- if (attrId) {
- elementSelector = `[data-scroll-restoration-id="${attrId}"]`;
- } else {
- elementSelector = getCssSelector(event.target);
- }
- }
- const restoreKey = getKey(router.state.location);
+ const saveScrollPosition = (restoreKey, elementSelector) => {
scrollRestorationCache.set((state) => {
const keyEntry = state[restoreKey] ||= {};
const elementEntry = keyEntry[elementSelector] ||= {};
@@ (unchanged scroll position save logic)
return state;
});
};
+ const throttledSave = throttle(saveScrollPosition, 100);
if (typeof document !== "undefined") {
- document.addEventListener("scroll", throttle(onScroll, 100), true);
+ document.addEventListener("scroll", (event) => {
+ if (ignoreScroll || !router.isScrollRestoring) {
+ return;
+ }
+ let elementSelector = "";
+ if (event.target === document || event.target === window) {
+ elementSelector = "window";
+ } else {
+ const attrId = event.target.getAttribute(
+ "data-scroll-restoration-id"
+ );
+ if (attrId) {
+ elementSelector = `[data-scroll-restoration-id="${attrId}"]`;
+ } else {
+ elementSelector = getCssSelector(event.target);
+ }
+ }
+ // Capture the restore key at scroll event time, not when the throttled
+ // handler executes. The throttle preserves args from the first call in
+ // each window, preventing stale keys from post-navigation state changes.
+ const restoreKey = getKey(router.state.location);
+ throttledSave(restoreKey, elementSelector);
+ }, true);
}Fix 2: Clear stale cache entries before restoreScroll() in onRendered
This handles the second vector: browser-generated scroll events during DOM transitions that fire after router.state.location already changed. We clear any cached entries for the new route's key right before restoreScroll() reads from sessionStorage. We use the scrollRestorationCache.set() API which handles both the in-memory state and sessionStorage atomically.
router.subscribe("onRendered", (event) => {
const cacheKey = getKey(event.toLocation);
// ...existing resetNextScroll and scrollRestoration checks...
+ // Clear stale scroll entries for the new key before restoring.
+ // During DOM transitions the browser fires scroll events after
+ // router.state.location already changed, polluting the new key's cache.
+ if (router.resetNextScroll && scrollRestorationCache) {
+ scrollRestorationCache.set((state) => {
+ delete state[cacheKey];
+ return state;
+ });
+ }
restoreScroll({
storageKey,
key: cacheKey,This way restoreScroll finds no cached entries for a new forward navigation and falls through to scrollTo(0, 0) as expected.
Your Example Website or App
I can share in private on Discord (#Bluebeel) if needed. The website is not yet live.
Steps to Reproduce the Bug or Issue
- Create a TanStack Router app with
scrollRestoration: true - Have a home page with enough content to scroll to 6000px or more (multiple sections, sliders, etc.)
- Have a destination page that is much shorter (3000-4000px)
- Scroll down on the home page using the mouse wheel (this generates continuous scroll events)
- Immediately click a
<Link>or callrouter.navigate({ resetScroll: true }) - The destination page is not scrolled to top. It's stuck at
newDocHeight - viewportHeight
Why it's intermittent: The bug only shows up when the user's last scroll event falls within the 100ms throttle window before navigation. If the user scrolls, pauses for more than 100ms, then clicks, the throttle has already fired and saved under the correct key. But fast scroll-then-click hits the race condition.
Proof from sessionStorage (tsr-scroll-restoration-v1_3):
We dumped the full sessionStorage key from a real browsing session where we navigated to several games and could reproduce the bug. Here's what the polluted entries look like. These keys belong to game pages, but they contain scroll positions from the home page:
"zwayg": { "window": { "scrollX": 0, "scrollY": 2975 }, ... }
"gfcvo": { "window": { "scrollX": 0, "scrollY": 2710 }, ... }
"g6rvv": { "window": { "scrollX": 0, "scrollY": 2253 }, ... }
"vr4bd": { "window": { "scrollX": 0, "scrollY": 2710 }, ... }For comparison, the home page keys have the correct (high) scroll positions:
"tv3zv": { "window": { "scrollX": 0, "scrollY": 6634 }, ... }
"gbcme": { "window": { "scrollX": 0, "scrollY": 6611 }, ... }
"mfqzi": { "window": { "scrollX": 0, "scrollY": 6660 }, ... }
"7b5b6": { "window": { "scrollX": 0, "scrollY": 6735 }, ... }The game page keys shouldn't have a window.scrollY of 2710 or 2975. Those are clamped home page scroll positions that were saved under the wrong key because of the throttle race.
We also confirmed this with a Playwright test. Here's the clearest example:
TSR key: v0u6x (game page)
Pre-click: scrollY = 8961 (on home page)
Post-navigation: scrollY = 2691 (stuck on game page)
Cache for v0u6x: { "window": { "scrollX": 0, "scrollY": 8961 } }
scrollY: 8961 is the home page position saved under the game page key v0u6x. restoreScroll() finds it and restores to 8961. The browser clamps it to 3771 - 1080 = 2691. The user sees the footer instead of the game.
After applying our fix, the same test shows clean data:
TSR key: 2heq9 (game page)
Pre-click: scrollY = 6083 (on home page)
Post-navigation: scrollY = 0
Cache for 2heq9: { "window": { "scrollX": 0, "scrollY": 0 } }
No more pollution. The game page key has scrollY: 0 and the page scrolls to top every time.
Expected behavior
When navigating with resetScroll: true, the destination page should always scroll to top (scrollY: 0), no matter how recently the user was scrolling on the previous page.
Screenshots or Videos
No response
Platform
- Router Version: 1.166.7
- OS: macOS (also reproduced on Windows/Linux via Playwright)
- Browser: Chrome 134, also reproducible in Firefox and Safari
- Bundler: Vite 8
Additional context
No response