From 3f963ae1129a023bd26e39698e09cdd2c78fb3c4 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 17 Jul 2026 12:42:32 +0200 Subject: [PATCH] fix(virtual-core): clamp tracked scrollOffset at 0 in end-anchor compensation With anchorTo: 'end' and element scrolling, measurement compensation (applyScrollAdjustment) and the setOptions edge-key re-anchor wrote the tracked scrollOffset without clamping to the reachable range. When items measured smaller than their estimates and the content was shorter than the viewport, the offset went negative with no scroll event ever able to correct it (an unscrollable element emits none), permanently skewing getDistanceFromEnd()/isAtEnd() and wedging _flushIosDeferredIfReady so iOS deferred measurement corrections never flushed. Clamp only the lower bound: upper-bound overflow is transiently legitimate mid-prepend while the consumer's sizer catches up, but a negative element offset is invalid unconditionally. Fixes #1229 Co-Authored-By: Claude Fable 5 --- ...clamp-negative-end-anchor-scroll-offset.md | 5 + packages/virtual-core/src/index.ts | 13 ++- packages/virtual-core/tests/index.test.ts | 108 ++++++++++++++++++ 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 .changeset/clamp-negative-end-anchor-scroll-offset.md diff --git a/.changeset/clamp-negative-end-anchor-scroll-offset.md b/.changeset/clamp-negative-end-anchor-scroll-offset.md new file mode 100644 index 000000000..4375a94a0 --- /dev/null +++ b/.changeset/clamp-negative-end-anchor-scroll-offset.md @@ -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. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 60c271f9f..e9ae0086d 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -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 @@ -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 } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index f6cee7210..947dc6130 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -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, + ) => { + 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) +})