Skip to content

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

Description

@gkurt

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.

Activity

  1. barry166 commented on Jun 28, 2026

    @barry166

    The copy-forward block above already skips elements that are in scrollToTopSelectors when shouldResetScroll is true. The restore block probably needs the same idea: track which element selectors were restored, then exclude those elements from the final scrollToTopSelectors fallback. That would keep the normal window top reset while not clobbering an element restore in the same render pass.

  2. LevDomasnih commented on Jun 28, 2026

    @LevDomasnih

    I opened a PR with a focused fix here: #7710

  3. gkurt commented on Jun 28, 2026

    @gkurt
    ContributorAuthor

    There is another PR here: #7704

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions