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) +})