From aa5d9caa96eb30666c2742a7016c01a18fba36f0 Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 08:24:14 +0200 Subject: [PATCH 1/2] fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight scrollToIndex lands --- .changeset/ios-index-scroll-deferral.md | 5 ++ packages/virtual-core/src/index.ts | 6 +- packages/virtual-core/tests/index.test.ts | 67 +++++++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 .changeset/ios-index-scroll-deferral.md diff --git a/.changeset/ios-index-scroll-deferral.md b/.changeset/ios-index-scroll-deferral.md new file mode 100644 index 00000000..311c334c --- /dev/null +++ b/.changeset/ios-index-scroll-deferral.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): don't replay iOS-deferred size corrections after an in-flight `scrollToIndex` lands diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 8a2896f1..659c9777 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -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 + } return false } else { const target = this.getScrollOffset() + this.scrollAdjustments + delta diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index e2511385..a570498a 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -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 = [] + 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. + v.resizeItem(10, 100) + // The notify re-renders, rebuilding the measurements. + v['getMeasurements']() + 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 From d2d8e9edcf2bfe8a7788f1644c2f30b8b00efbe6 Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 08:31:29 +0200 Subject: [PATCH 2/2] fix(virtual-core): rebuild measurements before reconciling an index scroll A resize that no render has read yet left measurementsCache stale, so reconcileScroll could retire an index scroll at the old target now that the iOS-deferred delta is no longer replayed. --- packages/virtual-core/src/index.ts | 4 ++++ packages/virtual-core/tests/index.test.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 659c9777..38989f0f 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1223,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, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index a570498a..0dc7db3f 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2207,9 +2207,9 @@ test('iOS deferral: a resize during an in-flight scrollToIndex is not replayed a // 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) - // The notify re-renders, rebuilding the measurements. - v['getMeasurements']() flushFrames() expect(el.scrollTop).toBe(1050)