Skip to content

Scroll restoration listeners are never removed, so every created router stays in memory #8775

Open

Description

@piecyk

Which project does this relate to?

Router

Describe the bug

When scrollRestoration is on, setupScrollRestoration (packages/router-core/src/scroll-restoration.ts) runs once per router, from the RouterCore constructor. It adds three global listeners:

  • document.addEventListener('scroll', …, true)
  • addEventListener('pagehide', …)
  • addEventListener('pageshow', …)

It keeps no reference to them, and the router has no method to remove them. Each listener closes over the router, so the router (its stores, matches, and the elements in trackedScrollTargets) cannot be garbage collected.

With one router for the life of the page this is fine. It is a leak when an app creates routers more than once, for example:

  • an app that renders some views in their own router instance and creates one per view (our case: each page in a stack of sliding panels has its own router, so the page under a panel stays mounted)
  • micro-frontends that mount and unmount a router
  • tests and HMR that create a new router each time

In our app, after opening and closing 20 panels, getEventListeners(document).scroll.length goes from 1 to 21. Each pagehide listener also writes the scroll cache to sessionStorage, so N routers write it N times on unload.

Steps to reproduce

import { createMemoryHistory, createRootRoute, createRouter } from '@tanstack/react-router'

for (let i = 0; i < 20; i++) {
  createRouter({
    routeTree: createRootRoute(),
    history: createMemoryHistory(),
    scrollRestoration: true,
  })
}

// Chrome DevTools console:
getEventListeners(document).scroll.length // 20
getEventListeners(window).pagehide.length // 20

Expected behavior

There is a way to release a router that is no longer used: its global listeners are removed and it can be garbage collected.

Proposed fix

  • setupScrollRestoration keeps the listeners (and the router subscriptions it adds), and returns a cleanup function.
  • RouterCore stores that cleanup and exposes it as a public method, for example router.dispose(). It is idempotent and does not touch history.scrollRestoration if another router still uses scroll restoration.
  • RouterProvider does not call it automatically, because the app creates the router outside React and may render it again.

I can open a PR with this and tests. Is dispose() an acceptable name and shape, or do you prefer something else (for example destroy(), or a cleanup returned from router.mount())?

Your environment

  • @tanstack/react-router 1.170.41 (also present on main, see scroll-restoration.ts)
  • Chrome 155, macOS

Related: #8024 (bare addEventListener for pagehide), #7956 (history.scrollRestoration mode).

Activity

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