Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/clamp-negative-end-anchor-scroll-offset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

Clamp the tracked `scrollOffset` at 0 when applying end-anchor measurement compensation and when re-anchoring in `setOptions`. Previously, with `anchorTo: 'end'` and content shorter than the viewport, items measuring smaller than their estimates drove the tracked offset negative with no scroll event to ever correct it — `getDistanceFromEnd()` reported a permanent phantom distance and iOS deferred measurement corrections stayed wedged forever.
13 changes: 12 additions & 1 deletion packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,11 @@ export class Virtualizer<
if (idx < count) {
const anchorItem = newMeasurements[idx]
if (anchorItem) {
const newOffset = anchorItem.start + anchorOffset
// Clamp to the reachable range's lower bound — anchorOffset may
// have been derived from a transiently negative scrollOffset
// (rubber-band), and a negative tracked offset never self-heals
// when the element cannot scroll (#1229).
const newOffset = Math.max(0, anchorItem.start + anchorOffset)
if (newOffset !== this.scrollOffset) {
anchorDelta = newOffset - this.scrollOffset
this.scrollOffset = newOffset
Expand Down Expand Up @@ -703,6 +707,13 @@ export class Virtualizer<
// `scrollAdjustments` to keep their sum invariant.
if (this.scrollOffset !== null) {
this.scrollOffset += this.scrollAdjustments
// Clamp only the lower bound: a negative offset is unreachable, and
// on an unscrollable element (content fits the viewport) no scroll
// event ever fires to correct it, permanently skewing
// getDistanceFromEnd() and wedging _flushIosDeferredIfReady (#1229).
// Upper-bound overflow stays untouched — it is transiently
// legitimate mid-prepend while the consumer's sizer catches up.
if (this.scrollOffset < 0) this.scrollOffset = 0
this.scrollAdjustments = 0
}
}
Expand Down
108 changes: 108 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3155,3 +3155,111 @@ test('observeWindowOffset: reads scrollX when horizontal', () => {
listeners.get('scroll')!({} as Event)
expect(cb).toHaveBeenCalledWith(75, true)
})

// ─── #1229: negative tracked scrollOffset must not survive compensation ─────
// anchorTo: 'end' + element scrolling + items measuring smaller than their
// estimates + content shorter than the viewport. The end-anchor compensation
// applies a negative delta; the DOM clamps the scrollTop write to 0 and an
// unscrollable element never fires a scroll event, so an unclamped tracked
// offset would stay negative forever — phantom getDistanceFromEnd(), wedged
// _flushIosDeferredIfReady.

const makeUnscrollableElement = () => {
// Content fits the viewport: the browser clamps scrollHeight to
// clientHeight, so maxScrollOffset = 0 and no scroll event can fire.
const el = {
scrollTop: 0,
scrollLeft: 0,
scrollWidth: 400,
scrollHeight: 600,
clientWidth: 400,
clientHeight: 600,
scrollTo: ({ top }: { top: number }) => {
el.scrollTop = Math.max(0, Math.min(top, 0))
},
}
return el as unknown as HTMLDivElement
}

const unscrollableOptions = (
scrollElement: HTMLDivElement,
offsetCbRef: {
current: ((offset: number, isScrolling: boolean) => void) | null
},
) => ({
count: 5,
// Estimates larger than the real measured sizes
estimateSize: () => 100,
anchorTo: 'end' as const,
getScrollElement: () => scrollElement,
scrollToFn: (
offset: number,
{
adjustments = 0,
behavior,
}: { adjustments?: number; behavior?: ScrollBehavior },
instance: Virtualizer<HTMLDivElement, any>,
) => {
instance.scrollElement?.scrollTo?.({ top: offset + adjustments, behavior })
},
observeElementRect: (
_instance: unknown,
cb: (rect: { width: number; height: number }) => void,
) => {
cb({ width: 400, height: 600 })
return () => {}
},
observeElementOffset: (
_instance: unknown,
cb: (offset: number, isScrolling: boolean) => void,
) => {
offsetCbRef.current = cb
cb(0, false)
return () => {}
},
})

test('anchorTo end: shrink compensation clamps tracked scrollOffset at 0 when content fits the viewport (#1229)', () => {
const scrollElement = makeUnscrollableElement()
const offsetCbRef = { current: null as any }
const virtualizer = new Virtualizer(
unscrollableOptions(scrollElement, offsetCbRef),
)

virtualizer._willUpdate()
virtualizer.getVirtualItems()

// Real sizes come in smaller than the estimates (98 vs 100)
for (let i = 0; i < 5; i++) {
virtualizer.resizeItem(i, 98)
}

// The element is trivially at its end: 490px of content in a 600px
// viewport, pinned at scrollTop 0.
expect(scrollElement.scrollTop).toBe(0)
expect(virtualizer.scrollOffset).toBe(0)
expect(virtualizer.getDistanceFromEnd()).toBe(0)
expect(virtualizer.isAtEnd()).toBe(true)
})

test('anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229)', () => {
const scrollElement = makeUnscrollableElement()
const offsetCbRef = { current: null as any }
const options = unscrollableOptions(scrollElement, offsetCbRef)
const virtualizer = new Virtualizer(options)

virtualizer._willUpdate()
virtualizer.getVirtualItems()

// Simulate a transiently negative offset reported by a real scroll event
// (elastic overscroll) landing right before an options update.
offsetCbRef.current!(-10, false)
expect(virtualizer.scrollOffset).toBe(-10)

// Trim the last item: edge keys change, triggering the end-anchor
// re-resolution in setOptions. The anchor item (index 0) still starts at
// 0, so the unclamped offset would be written back as -10.
virtualizer.setOptions({ ...options, count: 4 })

expect(virtualizer.scrollOffset).toBe(0)
})
Loading