Skip to content

anchorTo: 'end': tracked scrollOffset can go negative and never self-heals when content fits the viewport (phantom getDistanceFromEnd, wedged deferred corrections) #1229

Description

@stevan-borus

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:

  1. applyScrollAdjustment()this.scrollOffset += this.scrollAdjustments (measurement compensation)
  2. 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.

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