Summary
Since virtual-core 3.17, every call to scrollToOffset, scrollToIndex, scrollBy, or scrollToEnd arms a rAF reconcile loop (scheduleScrollReconcile → reconcileScroll) that keeps re-asserting the estimate-derived target offset until the actual scroll offset converges (or ~5000ms elapse). scrollState and rafId are private; there is no public way to cancel or retarget the loop once armed.
This makes a common coarse-then-fine correction pattern impossible: after scrollToIndex(i, { align: 'start' }), an app that reads the true DOM rect on the next frame and issues a corrective delta will have that correction reverted on the loop's next tick — the loop's target was computed from stale estimated sizes, so it believes it hasn't converged.
On iOS specifically, any library-issued scroll write kills active momentum scrolling, so even a single re-assertion visibly jars the user.
Motivation / current workaround
We maintain a chat timeline (a Cinny fork) with dynamic row measurement and content-based estimates. When restoring a scroll anchor after prepending history, the natural flow is:
- Coarse:
virtualizer.scrollToIndex(anchorIndex, { align: 'start' }) to mount the region.
- Next frame: read the anchor element's
getBoundingClientRect(), compute the true pixel delta, scrollBy(delta).
Under 3.17 the reconcile loop reverts step 2 for up to 5 seconds. We had to bypass the library for these writes:
// Direct write, NOT virtualizer.scrollToOffset: since virtual-core 3.17 every
// scrollTo* call arms a rAF reconcile loop that keeps re-asserting its own
// (estimate-derived, so wrong) target offset until the actual offset converges
// — which reverts the DOM-rect delta correction on its next frame. The library
// offers no way to cancel or retarget that loop, so this path must bypass it.
scrollElement.scrollTo({ top: offset, behavior: 'instant' });
Bypassing works, but it means re-deriving the virtualizer's offset semantics by hand (getOffsetForIndex + scrollMargin conversions), which is fragile across library upgrades.
Minimal repro sketch
const virtualizer = useVirtualizer({
count: 1000,
getScrollElement: () => ref.current,
estimateSize: () => 50, // real rows are ~120px
});
virtualizer.scrollToIndex(500, { align: 'start' });
requestAnimationFrame(() => {
const row = ref.current!.querySelector('[data-index="500"]')!;
const delta =
row.getBoundingClientRect().top - ref.current!.getBoundingClientRect().top;
ref.current!.scrollBy({ top: delta, behavior: 'instant' });
// Correction lands; next frame reconcileScroll re-asserts the
// estimate-derived offset and row 500 is no longer aligned.
});
Proposed API
Preferred: a public cancel method.
cancelScroll(): void {
if (this.rafId != null) {
this.targetWindow?.cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.scrollState = null;
}
Alternatives:
- Per-call opt-out:
scrollToIndex(i, { align: 'start', reconcile: false }) (and equivalents).
- Retarget:
virtualizer.retargetScroll(offset) to replace the loop's target without re-arming.
Any of these would let us go back through the virtualizer instead of bypassing it.
Environment
@tanstack/virtual-core 3.17.3 (@tanstack/react-virtual)
- React 18, Vite, Vitest; reproduced on Chromium and iOS Safari
Happy to open a PR if an API shape is agreed.
Summary
Since virtual-core 3.17, every call to
scrollToOffset,scrollToIndex,scrollBy, orscrollToEndarms a rAF reconcile loop (scheduleScrollReconcile→reconcileScroll) that keeps re-asserting the estimate-derived target offset until the actual scroll offset converges (or ~5000ms elapse).scrollStateandrafIdare private; there is no public way to cancel or retarget the loop once armed.This makes a common coarse-then-fine correction pattern impossible: after
scrollToIndex(i, { align: 'start' }), an app that reads the true DOM rect on the next frame and issues a corrective delta will have that correction reverted on the loop's next tick — the loop's target was computed from stale estimated sizes, so it believes it hasn't converged.On iOS specifically, any library-issued scroll write kills active momentum scrolling, so even a single re-assertion visibly jars the user.
Motivation / current workaround
We maintain a chat timeline (a Cinny fork) with dynamic row measurement and content-based estimates. When restoring a scroll anchor after prepending history, the natural flow is:
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })to mount the region.getBoundingClientRect(), compute the true pixel delta,scrollBy(delta).Under 3.17 the reconcile loop reverts step 2 for up to 5 seconds. We had to bypass the library for these writes:
Bypassing works, but it means re-deriving the virtualizer's offset semantics by hand (
getOffsetForIndex+ scrollMargin conversions), which is fragile across library upgrades.Minimal repro sketch
Proposed API
Preferred: a public cancel method.
Alternatives:
scrollToIndex(i, { align: 'start', reconcile: false })(and equivalents).virtualizer.retargetScroll(offset)to replace the loop's target without re-arming.Any of these would let us go back through the virtualizer instead of bypassing it.
Environment
@tanstack/virtual-core3.17.3 (@tanstack/react-virtual)Happy to open a PR if an API shape is agreed.