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
};
and dist/esm/utils.js, where the handle lives:
targetWindow.clearTimeout(timeoutId);
timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms);
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.
Reproduced against
@tanstack/virtual-core@3.17.7(latest at time of writing, published 2026-07-28).The bug
observeElementOffsetarms a debounce on everyscrollevent to resetisScrollingback tofalseafterisScrollingResetDelay(default 150 ms).Virtualizer.cleanup()removes the scroll listener, disconnects theResizeObserverand cancels itsrequestAnimationFrame— but it never clears that debounce timer.dist/esm/index.js,cleanup():and
dist/esm/utils.js, where the handle lives:timeoutIdis local to thedebounceclosure, 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:
useScrollendEventdefaults tofalse(dist/esm/index.js, the default options), so the debounce is used even in browsers that supportscrollend.supportsScrollendis"onscrollend" in window, and jsdom has noonscrollendat 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 runsmaybeNotify()→notify()→ the consumer'sonChange→ in React, auseReducerdispatch 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
observeOffsetreturn a disposer that clears the pending timeout (or hold the handle on the instance) and call it fromcleanup()alongside the existingcancelAnimationFrame.Workaround we are using
Wrapping
observeElementOffsetso 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.