Skip to content

Element scroll restoration is reset to top by scrollToTopSelectors fallback when the scroll container is an element #7687

Description

@KurtGokhan

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

  1. On /, scroll the list down (the scroll container is the #main-content element with overflow-y: auto, not the window).
  2. Click any "Open detail N" link (a PUSH to /detail/$id).
  3. Click "Back to list" — a forward PUSH via <Link to="/">, not the browser back button.
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    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