Skip to content

Commit 1e3b908

Browse files
authored
fix(virtual-core): clamp tracked scrollOffset at 0 in end-anchor compensation (#1230)
1 parent e2cb096 commit 1e3b908

3 files changed

Lines changed: 125 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
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.

packages/virtual-core/src/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -651,7 +651,11 @@ export class Virtualizer<
651651
if (idx < count) {
652652
const anchorItem = newMeasurements[idx]
653653
if (anchorItem) {
654-
const newOffset = anchorItem.start + anchorOffset
654+
// Clamp to the reachable range's lower bound — anchorOffset may
655+
// have been derived from a transiently negative scrollOffset
656+
// (rubber-band), and a negative tracked offset never self-heals
657+
// when the element cannot scroll (#1229).
658+
const newOffset = Math.max(0, anchorItem.start + anchorOffset)
655659
if (newOffset !== this.scrollOffset) {
656660
anchorDelta = newOffset - this.scrollOffset
657661
this.scrollOffset = newOffset
@@ -703,6 +707,13 @@ export class Virtualizer<
703707
// `scrollAdjustments` to keep their sum invariant.
704708
if (this.scrollOffset !== null) {
705709
this.scrollOffset += this.scrollAdjustments
710+
// Clamp only the lower bound: a negative offset is unreachable, and
711+
// on an unscrollable element (content fits the viewport) no scroll
712+
// event ever fires to correct it, permanently skewing
713+
// getDistanceFromEnd() and wedging _flushIosDeferredIfReady (#1229).
714+
// Upper-bound overflow stays untouched — it is transiently
715+
// legitimate mid-prepend while the consumer's sizer catches up.
716+
if (this.scrollOffset < 0) this.scrollOffset = 0
706717
this.scrollAdjustments = 0
707718
}
708719
}

packages/virtual-core/tests/index.test.ts

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3155,3 +3155,111 @@ test('observeWindowOffset: reads scrollX when horizontal', () => {
31553155
listeners.get('scroll')!({} as Event)
31563156
expect(cb).toHaveBeenCalledWith(75, true)
31573157
})
3158+
3159+
// ─── #1229: negative tracked scrollOffset must not survive compensation ─────
3160+
// anchorTo: 'end' + element scrolling + items measuring smaller than their
3161+
// estimates + content shorter than the viewport. The end-anchor compensation
3162+
// applies a negative delta; the DOM clamps the scrollTop write to 0 and an
3163+
// unscrollable element never fires a scroll event, so an unclamped tracked
3164+
// offset would stay negative forever — phantom getDistanceFromEnd(), wedged
3165+
// _flushIosDeferredIfReady.
3166+
3167+
const makeUnscrollableElement = () => {
3168+
// Content fits the viewport: the browser clamps scrollHeight to
3169+
// clientHeight, so maxScrollOffset = 0 and no scroll event can fire.
3170+
const el = {
3171+
scrollTop: 0,
3172+
scrollLeft: 0,
3173+
scrollWidth: 400,
3174+
scrollHeight: 600,
3175+
clientWidth: 400,
3176+
clientHeight: 600,
3177+
scrollTo: ({ top }: { top: number }) => {
3178+
el.scrollTop = Math.max(0, Math.min(top, 0))
3179+
},
3180+
}
3181+
return el as unknown as HTMLDivElement
3182+
}
3183+
3184+
const unscrollableOptions = (
3185+
scrollElement: HTMLDivElement,
3186+
offsetCbRef: {
3187+
current: ((offset: number, isScrolling: boolean) => void) | null
3188+
},
3189+
) => ({
3190+
count: 5,
3191+
// Estimates larger than the real measured sizes
3192+
estimateSize: () => 100,
3193+
anchorTo: 'end' as const,
3194+
getScrollElement: () => scrollElement,
3195+
scrollToFn: (
3196+
offset: number,
3197+
{
3198+
adjustments = 0,
3199+
behavior,
3200+
}: { adjustments?: number; behavior?: ScrollBehavior },
3201+
instance: Virtualizer<HTMLDivElement, any>,
3202+
) => {
3203+
instance.scrollElement?.scrollTo?.({ top: offset + adjustments, behavior })
3204+
},
3205+
observeElementRect: (
3206+
_instance: unknown,
3207+
cb: (rect: { width: number; height: number }) => void,
3208+
) => {
3209+
cb({ width: 400, height: 600 })
3210+
return () => {}
3211+
},
3212+
observeElementOffset: (
3213+
_instance: unknown,
3214+
cb: (offset: number, isScrolling: boolean) => void,
3215+
) => {
3216+
offsetCbRef.current = cb
3217+
cb(0, false)
3218+
return () => {}
3219+
},
3220+
})
3221+
3222+
test('anchorTo end: shrink compensation clamps tracked scrollOffset at 0 when content fits the viewport (#1229)', () => {
3223+
const scrollElement = makeUnscrollableElement()
3224+
const offsetCbRef = { current: null as any }
3225+
const virtualizer = new Virtualizer(
3226+
unscrollableOptions(scrollElement, offsetCbRef),
3227+
)
3228+
3229+
virtualizer._willUpdate()
3230+
virtualizer.getVirtualItems()
3231+
3232+
// Real sizes come in smaller than the estimates (98 vs 100)
3233+
for (let i = 0; i < 5; i++) {
3234+
virtualizer.resizeItem(i, 98)
3235+
}
3236+
3237+
// The element is trivially at its end: 490px of content in a 600px
3238+
// viewport, pinned at scrollTop 0.
3239+
expect(scrollElement.scrollTop).toBe(0)
3240+
expect(virtualizer.scrollOffset).toBe(0)
3241+
expect(virtualizer.getDistanceFromEnd()).toBe(0)
3242+
expect(virtualizer.isAtEnd()).toBe(true)
3243+
})
3244+
3245+
test('anchorTo end: setOptions re-anchor clamps tracked scrollOffset at 0 (#1229)', () => {
3246+
const scrollElement = makeUnscrollableElement()
3247+
const offsetCbRef = { current: null as any }
3248+
const options = unscrollableOptions(scrollElement, offsetCbRef)
3249+
const virtualizer = new Virtualizer(options)
3250+
3251+
virtualizer._willUpdate()
3252+
virtualizer.getVirtualItems()
3253+
3254+
// Simulate a transiently negative offset reported by a real scroll event
3255+
// (elastic overscroll) landing right before an options update.
3256+
offsetCbRef.current!(-10, false)
3257+
expect(virtualizer.scrollOffset).toBe(-10)
3258+
3259+
// Trim the last item: edge keys change, triggering the end-anchor
3260+
// re-resolution in setOptions. The anchor item (index 0) still starts at
3261+
// 0, so the unclamped offset would be written back as -10.
3262+
virtualizer.setOptions({ ...options, count: 4 })
3263+
3264+
expect(virtualizer.scrollOffset).toBe(0)
3265+
})

0 commit comments

Comments
 (0)