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/ios-index-scroll-deferral.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': patch
---

fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight `scrollToIndex` lands
10 changes: 9 additions & 1 deletion packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,11 @@ export class Virtualizer<
isIOSWebKit() &&
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
) {
this._iosDeferredAdjustment += delta
// An index scroll retargets from fresh measurements in reconcileScroll;
// replaying this delta after it lands would count it twice.
if (this.scrollState?.index == null) {
this._iosDeferredAdjustment += delta
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return false
} else {
const target = this.getScrollOffset() + this.scrollAdjustments + delta
Expand Down Expand Up @@ -1219,6 +1223,10 @@ export class Virtualizer<
return
}

// A resize since the last render leaves measurementsCache stale until
// something reads getMeasurements(); rebuild it (memoized) so an index
// target reflects it even when no render ran in between.
this.getMeasurements()
const targetOffset = this.getScrollStateTarget(this.scrollState)

// Index targets can still depend on cached sizes during the first rAF,
Expand Down
67 changes: 67 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2153,6 +2153,73 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad
})
})

test('iOS deferral: a resize during an in-flight scrollToIndex is not replayed after it lands', () => {
// reconcileScroll retargets the index scroll from fresh measurements, so a
// size correction deferred while it travels is already accounted for —
// replaying it on the settle flush would push the list past the target.
withFakeIOSUserAgent(() => {
const rafCallbacks: Array<FrameRequestCallback> = []
const mockWindow = {
requestAnimationFrame: (cb: FrameRequestCallback) =>
rafCallbacks.push(cb),
cancelAnimationFrame: () => {},
}
const el = {
scrollTop: 0,
scrollLeft: 0,
scrollHeight: 5000,
clientHeight: 200,
offsetHeight: 200,
ownerDocument: { defaultView: mockWindow },
}
let scrollCallback:
| ((offset: number, isScrolling: boolean) => void)
| null = null
const v = new Virtualizer({
count: 100,
estimateSize: () => 50,
getScrollElement: () => el as any,
scrollToFn: (offset, { adjustments = 0 }) => {
el.scrollTop = offset + adjustments
},
observeElementRect: (_inst, cb) => {
cb({ width: 400, height: 200 })
return () => {}
},
observeElementOffset: (_inst, cb) => {
scrollCallback = cb
cb(0, false)
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()
const flushFrames = () => {
for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) {
rafCallbacks.splice(0).forEach((cb) => cb(0))
scrollCallback!(el.scrollTop, true)
}
}

v.scrollToIndex(20, { align: 'start' })
scrollCallback!(el.scrollTop, true)
expect(el.scrollTop).toBe(1000)

// An item above the target measures 50px taller while the index scroll
// is still settling.
// No render runs in between (onChange is not set), so reconcile must
// pick the resize up itself.
v.resizeItem(10, 100)
flushFrames()
expect(el.scrollTop).toBe(1050)

// The scroll settles: the landing must stay on the item.
scrollCallback!(el.scrollTop, false)
expect(el.scrollTop).toBe(v.getOffsetForIndex(20, 'start')![0])
expect(el.scrollTop).toBe(1050)
})
})

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
Loading