Skip to content

fix(virtual-core): cancel the isScrolling debounce on scroll-observer cleanup - #1256

Merged
piecyk merged 2 commits into
TanStack:mainfrom
solomonaustin:fix/cancel-isscrolling-debounce-on-cleanup
Aug 18, 2026
Merged

fix(virtual-core): cancel the isScrolling debounce on scroll-observer cleanup#1256
piecyk merged 2 commits into
TanStack:mainfrom
solomonaustin:fix/cancel-isscrolling-debounce-on-cleanup

Conversation

@solomonaustin

@solomonaustin solomonaustin commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Fixes #1255. The analysis in that issue is @Zinn-Digital-LTD's and it was accurate down to the line — they offered to send a patch, so if they'd rather own this, I'm glad to close in favour of theirs.

Problem

observeOffset arms a debounce on every scroll event to reset isScrolling back to false after isScrollingResetDelay (150 ms default). Its disposer removes the scroll/scrollend listeners but leaves that timer armed, and the handle is local to the debounce closure:

export const debounce = (targetWindow, fn, ms) => {
  let timeoutId: number          // <- unreachable from outside
  return function (this: any, ...args) {
    targetWindow.clearTimeout(timeoutId)
    timeoutId = targetWindow.setTimeout(() => fn.apply(this, args), ms)
  }
}

so a consumer cannot clear it either — the fix has to live in the library.

Unmount a virtualizer inside that window and one late (offset, false) callback still arrives, running maybeNotify() → the consumer's onChange → in React a useReducer dispatch into a tree that no longer exists. A browser swallows that; under vitest/jsdom it surfaces as an update-after-unmount and can fail a suite that is otherwise correct.

It's the default path rather than an edge case: useScrollendEvent defaults to false, and jsdom has no onscrollend, so every jsdom-based suite goes through the debounce.

Fix

debounce returns a function carrying cancel(), and the disposer already returned by observeOffset calls it alongside the listener removal. That disposer is pushed onto unsubs, which cleanup() drains, so element and window observers are both covered and no call site changes.

debounce is publicly exported, so this is additive — no existing signature changes.

Testing

Added observeElementOffset: cleanup drops the queued isScrolling reset, which scrolls, tears down inside the window, advances fake timers past the delay and asserts no further callback.

Verified it fails for the right reason without the source change — the orphaned timer fires and the callback lands once (Number of calls: 1) — and passes with it.

  • vitest run in virtual-core — 129/129 pass
  • tsc, eslint ./src, vite build all clean
  • Patch changeset included

Summary by CodeRabbit

  • Bug Fixes

    • Prevented delayed scroll-state callbacks from firing after virtualized elements are unmounted or observation is stopped.
    • Reset scrolling status and direction during cleanup, preventing stale scroll state when virtualization is disabled, reused, or its scroll element changes.
    • Improved cleanup during scroll observation teardown, reducing unexpected updates after removal.
  • Tests

    • Added regression coverage for cleanup, scroll-element changes, disabling, unmounting, and cancellation of pending callbacks.

… cleanup

`observeOffset` arms a debounce on every scroll event to reset `isScrolling`
back to `false` after `isScrollingResetDelay`. Its disposer removed the
listeners but left that timer running, and the handle was closure-local, so
no consumer could clear it either.

Unmounting a virtualizer within the delay window therefore still delivered one
`(offset, false)` callback, which runs through `maybeNotify()` into the
consumer's `onChange` — in React, a dispatch into a tree that no longer
exists. A browser swallows it; under jsdom it surfaces as an update after
unmount and can fail an otherwise correct suite.

`debounce` now exposes `cancel()` and the disposer calls it. The path is the
default one: `useScrollendEvent` is off by default, and jsdom has no
`onscrollend`, so every jsdom suite takes it.
@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 8f5febb3-2026-4a4c-9eba-3a565e9a8eec

📥 Commits

Reviewing files that changed from the base of the PR and between 186317f and 82eb0df.

📒 Files selected for processing (4)
  • .changeset/olive-pugs-repeat.md
  • packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
  • packages/virtual-core/src/index.ts
  • packages/virtual-core/tests/index.test.ts
💤 Files with no reviewable changes (1)
  • packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
🚧 Files skipped from review as they are similar to previous changes (2)
  • packages/virtual-core/src/index.ts
  • .changeset/olive-pugs-repeat.md

Included review availability: Your plan includes up to 8 reviews per rolling hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

This change adds cancellation to the debounce utility. Offset observer teardown cancels pending scroll resets. Virtualizer cleanup resets scrolling state. Tests cover element replacement, disabling, unmounting, and delayed callbacks.

Changes

Scroll reset teardown

Layer / File(s) Summary
Debounce cancellation contract
packages/virtual-core/src/utils.ts
The returned debounced function now exposes cancel(), which clears its pending timeout without invoking the wrapped function.
Observer cleanup and state reset
packages/virtual-core/src/index.ts, packages/virtual-core/tests/index.test.ts, .changeset/olive-pugs-repeat.md, packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts
Offset observer teardown cancels pending reset callbacks. Cleanup resets isScrolling and scrollDirection. Tests verify cleanup behavior and delayed callback cancellation. The changeset documents the patch. The end-to-end test removes the pre-toggle delay.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to 82eb0

The change cancels a queued scrolling reset during observer cleanup, preventing late callbacks after teardown; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the primary fix: canceling the isScrolling debounce during virtual-core scroll-observer cleanup.
Description check ✅ Passed The description explains the problem, implementation, testing, release impact, and included changeset, although it does not use the template headings.
Linked Issues check ✅ Passed The changes satisfy issue #1255 by canceling the debounce during cleanup and preventing late callbacks after virtualizer unmount.
Out of Scope Changes check ✅ Passed The changeset, cleanup state reset, regression tests, and Marko test adjustment support the linked issue and stated pull request objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

packages/virtual-core/tests/index.test.ts

Parsing error: "parserOptions.project" has been provided for @typescript-eslint/parser.
The file was not found in any of the provided project(s): packages/virtual-core/tests/index.test.ts


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@piecyk piecyk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Diagnosis is accurate and the fix is the right shape — cancel() in the shared observeOffset disposer covers both observers, needs no call-site changes, and is additive on a public util so patch is correct. Verified locally: virtual-core 129/129, react-virtual 7/7, tsc clean, and the new test does fail without the source change.

One thing to fix first: that debounce is the only writer of isScrolling = false — it's assigned in just one place, the scroll callback in _willUpdate().

That's harmless on unmount, but cleanup() also runs when the scroll element changes and when enabled goes false, and there the instance stays alive. Scroll → disable → wait past isScrollingResetDelay → re-enable now leaves isScrolling stuck true, where before the PR the zombie timer reset it to false. A stuck flag skips sync measurement in measureElement, strands iOS deferred adjustments in _flushIosDeferredIfReady, and leaves scrollDirection stale.

Could you add this to cleanup(), next to the existing scrollState = null, and cover the swap path in a test as well as unmount?

this.isScrolling = false
this.scrollDirection = null

Also: packages/marko-virtual/e2e/app/e2e/option-gates.spec.ts has a waitForTimeout(250) with a note to remove it once a cancellable debounce lands. Worth dropping here — that's exactly the disable-within-150ms case above, so it doubles as verification.

Minor: the check rollup only shows CodeRabbit and Socket, so the test workflow doesn't look like it has run yet.

Cancelling the debounce removed the only writer of `isScrolling = false`.
That is fine on unmount, but `cleanup()` also runs when the scroll element
changes and when `enabled` goes false, and there the instance stays alive:
scroll, disable, wait past `isScrollingResetDelay`, re-enable, and the flag
was left stuck on. A stuck flag skips sync measurement, strands the iOS
deferred adjustment and leaves `scrollDirection` stale.

Reset both flags in `cleanup()` next to the other per-element state.

Also drop the `waitForTimeout(250)` in the marko option-gates e2e. It was
there to sidestep this exact zombie timer and is annotated to be removed once
a cancellable debounce lands, so it now doubles as coverage.
@solomonaustin

Copy link
Copy Markdown
Contributor Author

Thanks — that's a real gap, and you're right about the mechanism. I went and checked rather than take it on trust: isScrolling only has two writers, the field initialiser and the scroll callback in _willUpdate(), so cancelling the debounce did remove the only runtime path back to false. And since cleanup() is reached from _willUpdate on both the element swap and the enabled: false path, the instance survives and the flag sticks. _flushIosDeferredIfReady bails early on isScrolling and the measure paths gate on it too, so the knock-on effects are as you described.

Added both resets in cleanup() next to scrollState = null, with a short note. The iOS block right underneath already documents the same hazard — an unsub clearing the only pending reset of a flag — so it seemed right to keep them together.

Three tests, one per way into cleanup(): element swapped, element removed, unmount. Each drives the instance mid-scroll through the offset callback and then asserts both flags. All three fail without the source change.

I also dropped the waitForTimeout(250) in the marko option-gates gate. One correction there though: I checked whether it doubles as verification like you suggested, and it doesn't. I built the packages and ran the spec — 11/11 — then reverted the cleanup() change, rebuilt, and ran it again. Still 11/11, gate 2 included. So it isn't sensitive to this bug either way. Worth removing as a stale workaround, and it saves 250ms, but the unit tests are the actual guard here.

Changeset now mentions the flag reset as well, since that's user-visible too.

virtual-core 132/132, react-virtual 7/7, marko e2e 11/11, tsc and eslint clean.

On the checks — you're right they haven't run. I can't trigger the workflow from a fork, so I think it needs an approval from your side.

@solomonaustin
solomonaustin requested a review from piecyk August 18, 2026 09:04
@nx-cloud

nx-cloud Bot commented Aug 18, 2026

Copy link
Copy Markdown

View your CI Pipeline Execution ↗ for commit 82eb0df

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ✅ Succeeded 4m 28s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 20s View ↗

☁️ Nx Cloud last updated this comment at 2026-08-18 14:26:35 UTC

@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown
More templates

@tanstack/angular-virtual

npm i https://pkg.pr.new/@tanstack/angular-virtual@1256

@tanstack/lit-virtual

npm i https://pkg.pr.new/@tanstack/lit-virtual@1256

@tanstack/marko-virtual

npm i https://pkg.pr.new/@tanstack/marko-virtual@1256

@tanstack/react-virtual

npm i https://pkg.pr.new/@tanstack/react-virtual@1256

@tanstack/solid-virtual

npm i https://pkg.pr.new/@tanstack/solid-virtual@1256

@tanstack/svelte-virtual

npm i https://pkg.pr.new/@tanstack/svelte-virtual@1256

@tanstack/virtual-core

npm i https://pkg.pr.new/@tanstack/virtual-core@1256

@tanstack/vue-virtual

npm i https://pkg.pr.new/@tanstack/vue-virtual@1256

commit: 82eb0df

@piecyk
piecyk merged commit a0a411e into TanStack:main Aug 18, 2026
10 checks passed
@github-actions github-actions Bot mentioned this pull request Aug 18, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Virtualizer.cleanup() does not clear the isScrolling debounce timer, so an unmounted virtualizer still fires one onChange

2 participants