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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions