Skip to content

Public API to cancel or retarget the scroll reconcile loop armed by scrollTo*/scrollBy #1221

Description

@basnijholt

Summary

Since virtual-core 3.17, every call to scrollToOffset, scrollToIndex, scrollBy, or scrollToEnd arms a rAF reconcile loop (scheduleScrollReconcilereconcileScroll) 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:

  1. Coarse: virtualizer.scrollToIndex(anchorIndex, { align: 'start' }) to mount the region.
  2. 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:

  1. Per-call opt-out: scrollToIndex(i, { align: 'start', reconcile: false }) (and equivalents).
  2. 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.

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