Repository navigation
Element scroll restoration is reset to top by scrollToTopSelectors fallback when the scroll container is an element #7687
Description
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-contentelement withoverflow-y: auto, not the window). - Click any "Open detail N" link (a
PUSHto/detail/$id). - Click "Back to list" — a forward
PUSHvia<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.
Activity
barry166 commentedon Jun 28, 2026
The copy-forward block above already skips elements that are in
scrollToTopSelectorswhenshouldResetScrollis true. The restore block probably needs the same idea: track which element selectors were restored, then exclude those elements from the finalscrollToTopSelectorsfallback. That would keep the normal window top reset while not clobbering an element restore in the same render pass.LevDomasnih commentedon Jun 28, 2026
I opened a PR with a focused fix here: #7710
gkurt commentedon Jun 28, 2026
There is another PR here: #7704