Version: @tanstack/virtual-core 3.17.4 (latest; via @tanstack/react-virtual 3.14.6)
Summary
In anchorTo: 'end' mode with element scrolling, the virtualizer's tracked scrollOffset is written without clamping to the element's reachable scroll range in two places:
applyScrollAdjustment() — this.scrollOffset += this.scrollAdjustments (measurement compensation)
- the
setOptions() edge-key re-anchor block — this.scrollOffset = anchorItem.start + anchorOffset
When items measure smaller than their estimates (estimates are estimates), the end-anchor compensation applies a negative delta so visible content stays pinned to the bottom. If the list's content is shorter than the viewport, the element is physically pinned at scrollTop = 0, so the tracked offset goes negative (we observed a stable -10) and there is no scroll event that can ever correct it — an unscrollable element emits none.
Two things then stay broken for the lifetime of the instance:
getDistanceFromEnd() returns max(getMaxScrollOffset() - scrollOffset, 0) = a constant phantom (e.g. 10) even though the element is trivially at its end. Anything polling isAtEnd() or steering scrollToEnd() toward convergence spins forever — scrollToEnd writes a clamped target the element is already at, so nothing changes.
_flushIosDeferredIfReady() early-returns while getScrollOffset() < 0, so on iOS WebKit every deferred measurement correction accrued during a touch stays queued forever — items remain at their estimated positions (visible as large gaps between rows).
Repro conditions (minimal)
- element scroller,
anchorTo: 'end'
estimateSize returns values larger than the real measured sizes
- total content height < scroller viewport height
- mount, let
measureElement run → tracked offset goes negative; getDistanceFromEnd() never reaches 0
Not iOS-specific: we reproduce deterministically in desktop Chromium (vitest browser mode) as well as on iOS WebKit, where the wedged deferred-flush additionally freezes item positions.
Expected
A tracked element-scroll offset outside [0, maxScrollOffset] (other than transient rubber-band values reported by real scroll events) is unrepresentable; compensation writes should clamp at least the lower bound.
Suggested fix (running in production as a pnpm patch)
if (this.scrollOffset !== null) {
this.scrollOffset += this.scrollAdjustments;
+ if (this.scrollOffset < 0) this.scrollOffset = 0;
this.scrollAdjustments = 0;
}
and in the setOptions() re-anchor block:
- const newOffset = anchorItem.start + anchorOffset;
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset);
Only the lower bound is clamped deliberately: upper-bound overflow is transiently legitimate mid-prepend (the DOM sizer grows a frame later), but a negative element offset is invalid unconditionally.
Related but distinct: #1218 also traces to applyScrollAdjustment, but concerns the policy of compensating for a visible growing item; this issue is about the compensation violating the reachable-range invariant and permanently wedging when the element cannot scroll.
Version: @tanstack/virtual-core 3.17.4 (latest; via @tanstack/react-virtual 3.14.6)
Summary
In
anchorTo: 'end'mode with element scrolling, the virtualizer's trackedscrollOffsetis written without clamping to the element's reachable scroll range in two places:applyScrollAdjustment()—this.scrollOffset += this.scrollAdjustments(measurement compensation)setOptions()edge-key re-anchor block —this.scrollOffset = anchorItem.start + anchorOffsetWhen items measure smaller than their estimates (estimates are estimates), the end-anchor compensation applies a negative delta so visible content stays pinned to the bottom. If the list's content is shorter than the viewport, the element is physically pinned at
scrollTop = 0, so the tracked offset goes negative (we observed a stable-10) and there is no scroll event that can ever correct it — an unscrollable element emits none.Two things then stay broken for the lifetime of the instance:
getDistanceFromEnd()returnsmax(getMaxScrollOffset() - scrollOffset, 0)= a constant phantom (e.g.10) even though the element is trivially at its end. Anything pollingisAtEnd()or steeringscrollToEnd()toward convergence spins forever —scrollToEndwrites a clamped target the element is already at, so nothing changes._flushIosDeferredIfReady()early-returns whilegetScrollOffset() < 0, so on iOS WebKit every deferred measurement correction accrued during a touch stays queued forever — items remain at their estimated positions (visible as large gaps between rows).Repro conditions (minimal)
anchorTo: 'end'estimateSizereturns values larger than the real measured sizesmeasureElementrun → tracked offset goes negative;getDistanceFromEnd()never reaches 0Not iOS-specific: we reproduce deterministically in desktop Chromium (vitest browser mode) as well as on iOS WebKit, where the wedged deferred-flush additionally freezes item positions.
Expected
A tracked element-scroll offset outside
[0, maxScrollOffset](other than transient rubber-band values reported by real scroll events) is unrepresentable; compensation writes should clamp at least the lower bound.Suggested fix (running in production as a pnpm patch)
if (this.scrollOffset !== null) { this.scrollOffset += this.scrollAdjustments; + if (this.scrollOffset < 0) this.scrollOffset = 0; this.scrollAdjustments = 0; }and in the
setOptions()re-anchor block:Only the lower bound is clamped deliberately: upper-bound overflow is transiently legitimate mid-prepend (the DOM sizer grows a frame later), but a negative element offset is invalid unconditionally.
Related but distinct: #1218 also traces to
applyScrollAdjustment, but concerns the policy of compensating for a visible growing item; this issue is about the compensation violating the reachable-range invariant and permanently wedging when the element cannot scroll.