You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Virtualizer.cleanup() does not clear the isScrolling debounce timer, so an unmounted virtualizer still fires one onChange #1255
Reproduced against @tanstack/virtual-core@3.17.7 (latest at time of writing, published 2026-07-28).
The bug
observeElementOffset arms a debounce on every scroll event to reset isScrolling back to false after isScrollingResetDelay (default 150 ms). Virtualizer.cleanup() removes the scroll listener, disconnects the ResizeObserver and cancels its requestAnimationFrame — but it never clears that debounce timer.
dist/esm/index.js, cleanup():
this.cleanup=()=>{this.unsubs.filter(Boolean).forEach((d)=>d());this.unsubs=[];this.observer.disconnect();if(this.rafId!=null&&this.targetWindow){this.targetWindow.cancelAnimationFrame(this.rafId);this.rafId=null;}// … nothing clears the observeOffset debounce};
timeoutId is local to the debounce closure, so no consumer can clear it — the fix has to be inside the library rather than something a caller can do.
Why it is reachable by default
This is the default path, not an edge case:
useScrollendEvent defaults to false (dist/esm/index.js, the default options), so the debounce is used even in browsers that support scrollend.
supportsScrollend is "onscrollend" in window, and jsdom has no onscrollend at all, so every jsdom-based test suite takes this path unconditionally.
Effect
Unmount a virtualized list inside the 150 ms window after a scroll and one late (offset, false) callback still arrives. That runs maybeNotify() → notify() → the consumer's onChange → in React, a useReducer dispatch into a tree that no longer exists.
In a browser that dispatch is a silent no-op. Under vitest/jsdom it is not — it surfaces as an update-after-unmount and can fail a test suite that is otherwise correct.
Suggested fix
Have observeOffset return a disposer that clears the pending timeout (or hold the handle on the instance) and call it from cleanup() alongside the existing cancelAnimationFrame.
Workaround we are using
Wrapping observeElementOffset so that unsubscribing marks the subscription dead; upstream's orphaned timer still fires but finds nothing to call. It works, but it is a wrapper around a timer we cannot reach, which is why this is filed rather than fixed locally.
Happy to open a PR if the shape above is the one you would want.
Thanks @Zinn-Digital-LTD, confirmed and reproduced against main, and the suggested fix is the right one. One thing to add: that debounce is the only writer of isScrolling = false (it's assigned in just one place, the scroll callback in _willUpdate()). Looks like we alredy we PR #1256 for this.
Activity
piecyk commentedon Aug 18, 2026
Thanks @Zinn-Digital-LTD, confirmed and reproduced against main, and the suggested fix is the right one. One thing to add: that debounce is the only writer of isScrolling = false (it's assigned in just one place, the scroll callback in _willUpdate()). Looks like we alredy we PR #1256 for this.