Which project does this relate to?
Router
Describe the bug
When the scroll container is a DOM element (not window) and that same element is listed in scrollToTopSelectors, scroll restoration restores the element's scrollTop and then, a few lines later in the same onRendered handler, the scroll-to-top fallback resets it to 0. Net result: element scroll restoration never takes effect on a navigation that resets scroll (a forward PUSH).
The cause is in the onRendered subscriber in packages/router-core/src/scroll-restoration.ts. The scroll-to-top fallback is gated only on whether the window was restored (windowRestored); a successful element restore does not suppress it:
let windowRestored = false;
if (shouldResetScroll) {
const elementEntries = scroll.restoring ? scrollRestorationCache[cacheKey] : undefined;
if (elementEntries) for (const elementSelector in elementEntries) {
const { scrollX, scrollY } = elementEntries[elementSelector];
if (elementSelector === windowScrollTarget) {
if (skipWindowRestore) continue;
scrollTo({ top: scrollY, left: scrollX, behavior });
windowRestored = true; // only set for the window target
} else {
const element = getElement(elementSelector);
if (element) {
element.scrollLeft = scrollX;
element.scrollTop = scrollY; // element IS restored here...
}
}
}
if (!windowRestored && !hash) { // ...but windowRestored is still false
const scrollOptions = { top: 0, left: 0, behavior };
scrollTo(scrollOptions);
if (scrollToTopSelectors) {
scrollToTopElements ??= getScrollToTopElements(scrollToTopSelectors);
for (const element of scrollToTopElements)
element.scrollTo(scrollOptions); // ...and the restored element is reset to top
}
}
}
Because the restored target is an element (e.g. #main-content) rather than window, windowRestored stays false, so the fallback runs and scrolls every scrollToTopSelectors element — including the one just restored — back to (0, 0).
Verified counter-case: removing #main-content from scrollToTopSelectors makes the element scroll restore correctly (to 4000 in the reproducer). With it present, the same flow ends at 0. This confirms the element restore happens and is then specifically clobbered by the scroll-to-top fallback.
Complete minimal reproducer
https://stackblitz.com/edit/tanstack-router-scroll-restoration-content-bug?file=src%2Fmain.tsx
Steps to Reproduce the Bug
- On
/, scroll the list down (the scroll container is the #main-content element with overflow-y: auto, not the window).
- Click any "Open detail N" link (a
PUSH to /detail/$id).
- Click "Back to list" — a forward
PUSH via <Link to="/">, not the browser back button.
- The list resets to the top instead of restoring the previous scroll position.
The router is configured with scrollRestoration: true, scrollToTopSelectors: ['#main-content'], and a path-scoped getScrollRestorationKey so the forward PUSH back to / reuses the cached scroll for /:
getScrollRestorationKey: (location) =>
location.pathname === '/' ? location.pathname : location.state.__TSR_key || location.href,
Expected behavior
On the forward navigation back to /, #main-content is restored to its previously saved scroll position (the cache contains an entry for key /). A successful element restore should suppress the scrollToTopSelectors reset for that element, the same way a window restore suppresses it via windowRestored.
Platform
- Router / Start Version: @tanstack/react-router 1.170.16 (router-core 1.171.13)
- OS: macOS
- Browser: Chrome
- Browser Version: 149
- Bundler: vite
- Bundler Version: 5.4
Additional context
Suggested fix: track which targets were restored from cache (not just the window) and skip those in the scroll-to-top fallback — e.g. collect restored selectors/elements during the restore loop and, in the fallback, only scrollTo(0, 0) the scrollToTopSelectors elements that were not restored.
Secondary observation (lower priority): getScrollRestorationSelector keys non-window elements by an nth-child DOM path unless the element carries a data-scroll-restoration-id attribute — it does not consider the element's id. With portals (modals/drawers), conditionally-rendered nodes, or View Transitions pseudo-elements, sibling indices can shift between save and restore, so the cached selector may fail to resolve. Honoring id, or documenting that data-scroll-restoration-id is required for stable element restoration, would make this more robust.
Which project does this relate to?
Router
Describe the bug
When the scroll container is a DOM element (not
window) and that same element is listed inscrollToTopSelectors, scroll restoration restores the element'sscrollTopand then, a few lines later in the sameonRenderedhandler, the scroll-to-top fallback resets it to0. Net result: element scroll restoration never takes effect on a navigation that resets scroll (a forwardPUSH).The cause is in the
onRenderedsubscriber inpackages/router-core/src/scroll-restoration.ts. The scroll-to-top fallback is gated only on whether the window was restored (windowRestored); a successful element restore does not suppress it:Because the restored target is an element (e.g.
#main-content) rather thanwindow,windowRestoredstaysfalse, so the fallback runs and scrolls everyscrollToTopSelectorselement — including the one just restored — back to(0, 0).Verified counter-case: removing
#main-contentfromscrollToTopSelectorsmakes the element scroll restore correctly (to4000in the reproducer). With it present, the same flow ends at0. This confirms the element restore happens and is then specifically clobbered by the scroll-to-top fallback.Complete minimal reproducer
https://stackblitz.com/edit/tanstack-router-scroll-restoration-content-bug?file=src%2Fmain.tsx
Steps to Reproduce the Bug
/, scroll the list down (the scroll container is the#main-contentelement withoverflow-y: auto, not the window).PUSHto/detail/$id).PUSHvia<Link to="/">, not the browser back button.The router is configured with
scrollRestoration: true,scrollToTopSelectors: ['#main-content'], and a path-scopedgetScrollRestorationKeyso the forwardPUSHback to/reuses the cached scroll for/:Expected behavior
On the forward navigation back to
/,#main-contentis restored to its previously saved scroll position (the cache contains an entry for key/). A successful element restore should suppress thescrollToTopSelectorsreset for that element, the same way a window restore suppresses it viawindowRestored.Platform
Additional context
Suggested fix: track which targets were restored from cache (not just the window) and skip those in the scroll-to-top fallback — e.g. collect restored selectors/elements during the restore loop and, in the fallback, only
scrollTo(0, 0)thescrollToTopSelectorselements that were not restored.Secondary observation (lower priority):
getScrollRestorationSelectorkeys non-window elements by annth-childDOM path unless the element carries adata-scroll-restoration-idattribute — it does not consider the element'sid. With portals (modals/drawers), conditionally-rendered nodes, or View Transitions pseudo-elements, sibling indices can shift between save and restore, so the cached selector may fail to resolve. Honoringid, or documenting thatdata-scroll-restoration-idis required for stable element restoration, would make this more robust.