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
8 changes: 8 additions & 0 deletions .changeset/ios-deferral-stale-replay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/virtual-core': patch
---

Stop iOS-deferred scroll adjustments from replaying stale deltas after the position is already correct (#1233). On iOS WebKit the end-anchored virtualizer defers scroll compensation while the scroller is live and replays it once settled, but two cases replayed a delta whose premise no longer held:

- Absolute scroll commands (`scrollToOffset` / `scrollToIndex` / `scrollToEnd`) derive their target from current measurements, so a pending deferred delta is already stale — it now invalidates the deferral instead of letting it replay and shift the list off the just-established position. Relative commands (`scrollBy`) keep the deferral.
- At the end clamp with `anchorTo: 'end'`, a row above the viewport re-measuring smaller lets the browser clamp `scrollTop` onto the new bottom (already the correct position); the flush now drops the stale negative compensation instead of replaying it and lifting the view off the bottom. Positive deltas still replay, since content growth above does not clamp.
20 changes: 20 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -977,6 +977,14 @@ export class Virtualizer<
const cur = this.getScrollOffset()
const max = this.getMaxScrollOffset()
if (cur < 0 || cur > max) return
// At the end clamp the browser already absorbed a shrink above the
// viewport (it clamped scrollTop onto the new bottom), so replaying our
// deferred negative delta would lift the view off the bottom — drop it.
// Positive deltas still replay: growth above doesn't clamp. (#1233)
if (this._iosDeferredAdjustment < 0 && cur >= max - 1) {
this._iosDeferredAdjustment = 0
return
}
const delta = this._iosDeferredAdjustment
this._iosDeferredAdjustment = 0
// Roll the deferred delta into the running accumulator so any resize
Expand Down Expand Up @@ -1730,6 +1738,14 @@ export class Virtualizer<
toOffset: number,
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
// An absolute scroll command derives its target from current
// measurements, so any iOS-deferred compensation still pending is stale by
// definition — the command already accounts for the measurements the delta
// was compensating for. Drop it so _flushIosDeferredIfReady doesn't replay
// it onto the just-established position (relative commands like scrollBy
// intentionally keep the deferral, since they build on the current offset).
this._iosDeferredAdjustment = 0

const offset = this.getOffsetForAlignment(toOffset, align)

const now = this.now()
Expand All @@ -1754,6 +1770,10 @@ export class Virtualizer<
behavior = 'auto',
}: ScrollToIndexOptions = {},
) => {
// See scrollToOffset: an absolute target invalidates any pending
// iOS-deferred compensation.
this._iosDeferredAdjustment = 0

index = Math.max(0, Math.min(index, this.options.count - 1))

const offsetInfo = this.getOffsetForIndex(index, initialAlign)
Expand Down
152 changes: 152 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1534,6 +1534,61 @@ test('iOS deferral: multiple resizes during scroll accumulate and flush as one',
})
})

test('iOS deferral: an absolute scroll command invalidates a pending deferred adjustment', () => {
// Regression (#1233 manifestation A): scrollToOffset/scrollToIndex derive
// their target from CURRENT measurements, so any deferred compensation still
// pending is stale — replaying it on the next flush shifts the list off the
// just-established target by the accumulated delta. The absolute commands
// must drop the deferral.
withFakeIOSUserAgent(() => {
const scrollToFn = vi.fn()
let scrollCallback:
| ((offset: number, isScrolling: boolean) => void)
| null = null
const v = new Virtualizer({
count: 10,
estimateSize: () => 50,
getScrollElement: () =>
({
scrollTop: 200,
scrollLeft: 0,
scrollHeight: 500,
clientHeight: 200,
offsetHeight: 200,
}) as any,
scrollToFn,
observeElementRect: () => {},
observeElementOffset: (_inst, cb) => {
scrollCallback = cb
cb(200, true)
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()
scrollToFn.mockClear()

// Accumulate a deferred adjustment during scroll.
v.resizeItem(0, 100)
expect(v['_iosDeferredAdjustment']).toBe(50)

// An absolute command should clear the stale deferral.
v.scrollToOffset(300)
expect(v['_iosDeferredAdjustment']).toBe(0)

// scrollToIndex clears it too (still scrolling, so the resize defers).
v.resizeItem(1, 100)
expect(v['_iosDeferredAdjustment']).toBe(50)
v.scrollToIndex(5)
expect(v['_iosDeferredAdjustment']).toBe(0)
scrollToFn.mockClear()

// Settling must not replay any (now dropped) delta.
scrollCallback!(300, false)
expect(v['_iosDeferredAdjustment']).toBe(0)
})
})

test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-back resizes stay consistent', () => {
// Regression: the deferred flush used to write `adjustments: delta`
// directly without updating `this.scrollAdjustments`. If a second resize
Expand Down Expand Up @@ -1588,6 +1643,103 @@ test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-ba
})
})

test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', () => {
// Regression (#1233 manifestation B): with anchorTo: 'end' and the reader
// pinned at the bottom, a row above the viewport re-measuring *smaller*
// during isScrolling shrinks maxScrollOffset; the browser clamps scrollTop
// onto the new bottom, which is already the correct end-anchored position.
// The library also deferred a negative compensation for that same shrink —
// replaying it on the settled, already-correct position lifts the view off
// the bottom. The flush must drop the negative delta at the end clamp.
withFakeIOSUserAgent(() => {
const scrollToFn = vi.fn()
let scrollCallback:
| ((offset: number, isScrolling: boolean) => void)
| null = null
const v = new Virtualizer({
count: 10,
estimateSize: () => 50,
anchorTo: 'end',
getScrollElement: () =>
({
scrollTop: 300, // pinned at the bottom: scrollHeight - clientHeight
scrollLeft: 0,
scrollHeight: 500,
clientHeight: 200,
offsetHeight: 200,
}) as any,
scrollToFn,
observeElementRect: () => {},
observeElementOffset: (_inst, cb) => {
scrollCallback = cb
cb(300, true) // at the bottom, scrolling
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()
scrollToFn.mockClear()

// A row above the viewport re-measures smaller while at the end.
v.resizeItem(0, 30) // 50 → 30: total shrinks by 20
expect(scrollToFn).not.toHaveBeenCalled()
expect(v['_iosDeferredAdjustment']).toBe(-20)

// Settle. The browser already clamped scrollTop onto the new bottom
// (cur === max), so the deferred negative delta is stale and must not
// replay.
scrollCallback!(300, false)
expect(v['_iosDeferredAdjustment']).toBe(0)
expect(scrollToFn).not.toHaveBeenCalled()
})
})

test('iOS deferral: a positive delta at the end clamp still replays (growth above does not clamp)', () => {
// Complement to manifestation B: content GROWING above the viewport does
// not clamp (the browser cannot shrink to fit growth), and the consumer's
// DOM sizer may not have grown yet, so a positive deferred delta must still
// flush — the end-clamp drop is negative-only.
withFakeIOSUserAgent(() => {
const scrollToFn = vi.fn()
let scrollCallback:
| ((offset: number, isScrolling: boolean) => void)
| null = null
const v = new Virtualizer({
count: 10,
estimateSize: () => 50,
anchorTo: 'end',
getScrollElement: () =>
({
scrollTop: 300,
scrollLeft: 0,
scrollHeight: 500,
clientHeight: 200,
offsetHeight: 200,
}) as any,
scrollToFn,
observeElementRect: () => {},
observeElementOffset: (_inst, cb) => {
scrollCallback = cb
cb(300, true)
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()
scrollToFn.mockClear()

// A row above the viewport re-measures larger while at the end.
v.resizeItem(0, 70) // 50 → 70: total grows by 20
expect(scrollToFn).not.toHaveBeenCalled()
expect(v['_iosDeferredAdjustment']).toBe(20)

// Settle. Growth doesn't clamp, so the positive delta must replay.
scrollCallback!(300, false)
expect(v['_iosDeferredAdjustment']).toBe(0)
expect(scrollToFn).toHaveBeenCalledTimes(1)
})
})

// ─── Phase 1: touch event distinction ────────────────────────────────────────

// Mock EventTarget that records listeners so tests can dispatch events
Expand Down
Loading