Describe the bug
On iOS WebKit, the end-anchored virtualizer defers scroll compensation while it considers the scroller live (isScrolling / _iosTouching), accumulating deltas in _iosDeferredAdjustment and replaying them in _flushIosDeferredIfReady() once things settle. The deferred delta is computed against a snapshot of the world at accumulation time — but the flush replays it unconditionally, even when the position has since become correct by other means. The result is a visible jump to a wrong position ~150 ms after the list has settled (isScrollingResetDelay).
We hit two distinct manifestations of this in a WhatsApp-style chat (anchorTo: 'end', followOnAppend, dynamic media-heavy rows), both reproducible on iPhone (iOS 26, Safari + standalone PWA):
Manifestation A — an absolute scroll command does not invalidate the pending deferral
scrollToOffset() / scrollToIndex() / scrollToEnd() compute their target from current measurements. Any deferred delta pending at that moment is stale relative to that target — the command already accounts for the measurements the delta was compensating for. But the pending delta survives the command and replays on the next flush, shifting the list off the just-established position by exactly the stale amount.
Observed: programmatically restoring a saved reading position (repeated scrollToOffset calls while rows measure in) lands pixel-correct, then the flush fires ~150 ms later and shifts the list a constant +74 px — same delta every time, because the same rows produce the same measurement corrections.
Manifestation B — the flush replays a delta the browser's own clamp already absorbed
When the reader is pinned at the very bottom (scrollTop === scrollHeight - clientHeight) and a row above the viewport re-measures smaller while isScrolling is true (an image reporting its real height during momentum or right after programmatic scrolling), two things happen:
maxScrollOffset shrinks, and the browser clamps scrollTop onto the new bottom — which is already the correct end-anchored position (the tail stays glued).
- The library separately deferred a negative compensation delta for that same shrink.
The flush then replays the negative delta on top of the clamped position and lifts the view off the bottom. From an instrumented build of ours, a single room-open accumulated twelve deltas summing to exactly −101 while the list settled at the bottom, and the flush then wrote scrollTop 3543 → 3442 — the reader sees the newest message for a beat, then the list jumps up past it:
defer:resize +19 +19 −40 +19 +19 +19 +19 −189 −1 −3 +19 −1 (Σ = −101)
[list settles: scrollTop 3543 = max, gap 0]
flush: _scrollToOffset(3543, { adjustments: −101 }) → scrollTop 3442, 101 px above the bottom
The existing flush guard (if (cur < 0 || cur > max) return) doesn't catch this: after the clamp, cur === max, so the stale replay proceeds.
Why these are the same bug
Both are the deferral replaying a delta whose premise no longer holds: in A the target was re-derived from current measurements by an explicit command; in B the browser clamp already performed the correction. The deferral mechanism assumes nothing else moves the world between accumulation and flush — programmatic commands and the at-max clamp both violate that.
Fixes we're running in production (patch-package style, happy to PR)
-
Absolute commands invalidate the deferral — first statement of scrollToOffset and scrollToIndex (and therefore scrollToEnd):
this._iosDeferredAdjustment = 0;
An absolute target derives from current measurements; pending compensation is stale by definition.
-
The flush drops a negative delta at the end clamp — in _flushIosDeferredIfReady, after computing cur/max:
// At the end clamp an upward (negative) compensation has already been
// absorbed: the shrink reduced max and the browser clamped scrollTop onto
// the new bottom, which IS the correct end-anchored position.
if (this._iosDeferredAdjustment < 0 && cur >= max - 1) {
this._iosDeferredAdjustment = 0;
return;
}
(Positive deltas at max stay valid — content growth above doesn't clamp, so the compensation is still needed. Fix 2 is admittedly a targeted guard; recomputing the required adjustment from current state at flush time instead of replaying the stored delta would be the principled version.)
Both fixes are device-verified: the constant +74 restore drift and the −101 bottom jump are gone, with the flush-drop observable in our instrumented trace (flush:drop-at-end -101 3543 3543).
Same app and setup as #1229 (thanks for the fast fix in #1230!). These two are independent of the negative-offset clamps — #1230 doesn't change the replay behavior — but they live in the same deferral machinery, so it may make sense to look at them together before the next release.
Your minimal example
Hard to reproduce in a sandbox without a real iOS device (the deferral is gated on isIOSWebKit() and the timing window is isScrollingResetDelay); the recipe:
anchorTo: 'end' list with rows whose measured heights differ from estimates (images).
- iOS WebKit, reader at the bottom.
- Trigger row re-measurement within ~150 ms of programmatic scrolling (or during touch): late image loads do this naturally.
- Manifestation B: watch the list leave the bottom by the net measurement delta after the isScrolling reset. Manifestation A: run repeated absolute
scrollToOffset steering during the same window; the list drifts off the final target by the accumulated delta.
Platform
Describe the bug
On iOS WebKit, the end-anchored virtualizer defers scroll compensation while it considers the scroller live (
isScrolling/_iosTouching), accumulating deltas in_iosDeferredAdjustmentand replaying them in_flushIosDeferredIfReady()once things settle. The deferred delta is computed against a snapshot of the world at accumulation time — but the flush replays it unconditionally, even when the position has since become correct by other means. The result is a visible jump to a wrong position ~150 ms after the list has settled (isScrollingResetDelay).We hit two distinct manifestations of this in a WhatsApp-style chat (
anchorTo: 'end',followOnAppend, dynamic media-heavy rows), both reproducible on iPhone (iOS 26, Safari + standalone PWA):Manifestation A — an absolute scroll command does not invalidate the pending deferral
scrollToOffset()/scrollToIndex()/scrollToEnd()compute their target from current measurements. Any deferred delta pending at that moment is stale relative to that target — the command already accounts for the measurements the delta was compensating for. But the pending delta survives the command and replays on the next flush, shifting the list off the just-established position by exactly the stale amount.Observed: programmatically restoring a saved reading position (repeated
scrollToOffsetcalls while rows measure in) lands pixel-correct, then the flush fires ~150 ms later and shifts the list a constant +74 px — same delta every time, because the same rows produce the same measurement corrections.Manifestation B — the flush replays a delta the browser's own clamp already absorbed
When the reader is pinned at the very bottom (
scrollTop === scrollHeight - clientHeight) and a row above the viewport re-measures smaller whileisScrollingis true (an image reporting its real height during momentum or right after programmatic scrolling), two things happen:maxScrollOffsetshrinks, and the browser clampsscrollToponto the new bottom — which is already the correct end-anchored position (the tail stays glued).The flush then replays the negative delta on top of the clamped position and lifts the view off the bottom. From an instrumented build of ours, a single room-open accumulated twelve deltas summing to exactly −101 while the list settled at the bottom, and the flush then wrote
scrollTop 3543 → 3442— the reader sees the newest message for a beat, then the list jumps up past it:The existing flush guard (
if (cur < 0 || cur > max) return) doesn't catch this: after the clamp,cur === max, so the stale replay proceeds.Why these are the same bug
Both are the deferral replaying a delta whose premise no longer holds: in A the target was re-derived from current measurements by an explicit command; in B the browser clamp already performed the correction. The deferral mechanism assumes nothing else moves the world between accumulation and flush — programmatic commands and the at-max clamp both violate that.
Fixes we're running in production (patch-package style, happy to PR)
Absolute commands invalidate the deferral — first statement of
scrollToOffsetandscrollToIndex(and thereforescrollToEnd):An absolute target derives from current measurements; pending compensation is stale by definition.
The flush drops a negative delta at the end clamp — in
_flushIosDeferredIfReady, after computingcur/max:(Positive deltas at max stay valid — content growth above doesn't clamp, so the compensation is still needed. Fix 2 is admittedly a targeted guard; recomputing the required adjustment from current state at flush time instead of replaying the stored delta would be the principled version.)
Both fixes are device-verified: the constant +74 restore drift and the −101 bottom jump are gone, with the flush-drop observable in our instrumented trace (
flush:drop-at-end -101 3543 3543).Relation to #1229 / #1230
Same app and setup as #1229 (thanks for the fast fix in #1230!). These two are independent of the negative-offset clamps — #1230 doesn't change the replay behavior — but they live in the same deferral machinery, so it may make sense to look at them together before the next release.
Your minimal example
Hard to reproduce in a sandbox without a real iOS device (the deferral is gated on
isIOSWebKit()and the timing window isisScrollingResetDelay); the recipe:anchorTo: 'end'list with rows whose measured heights differ from estimates (images).scrollToOffsetsteering during the same window; the list drifts off the final target by the accumulated delta.Platform
@tanstack/virtual-core3.17.4 (also inspectedmain— the replay paths are unchanged after fix(virtual-core): clamp tracked scrollOffset at 0 in end-anchor compensation #1230)