From 0aea1bd44a15fc90cb427923c956740bb8ba465f Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Sun, 11 Oct 2026 16:03:01 +0200 Subject: [PATCH 1/3] fix(virtual-core): leave resizes measured before a scroll command's read-back to reconcile After a scrollToIndex/scrollToEnd/reconcile write, `scrollOffset` keeps the old position until the scroll event reads the write back, and the rows on screen are still the ones rendered for it. A resize in that window was compensated against the old position: scrolling up with scrollToIndex could jump back to the old position plus the delta, and reconcile (target unchanged) never rewrote it until the 5s safety valve. Track the offset of the last non-smooth write until its read-back. While a scroll state is active and `scrollOffset` doesn't reflect that write yet, skip resize compensation: reconcileScroll retargets from fresh measurements on the next frame. Compensation writes and the prepend anchor sync update `scrollOffset` eagerly, so they are unaffected. scrollBy builds on the written offset, so a scrollBy right after a scroll command adds to its target. Classifying those resizes against the written offset instead was tried and regressed the chat examples: during scrollToEnd the rows at the old position were compensated, the end target kept moving, and a user scroll could no longer interrupt the never-landed pin. Adds tests/ios-differential.test.ts, which runs scripted event sequences on an iOS and a non-iOS virtualizer and checks they settle at the same offset without needing the safety valve (#1307). Co-Authored-By: Claude Opus 5.5 --- .changeset/last-written-scroll-offset.md | 5 + packages/virtual-core/src/index.ts | 42 +- packages/virtual-core/tests/index.test.ts | 184 ++++++- .../tests/ios-differential.test.ts | 510 ++++++++++++++++++ 4 files changed, 720 insertions(+), 21 deletions(-) create mode 100644 .changeset/last-written-scroll-offset.md create mode 100644 packages/virtual-core/tests/ios-differential.test.ts diff --git a/.changeset/last-written-scroll-offset.md b/.changeset/last-written-scroll-offset.md new file mode 100644 index 000000000..535bcee45 --- /dev/null +++ b/.changeset/last-written-scroll-offset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): leave resizes measured before a scroll command's write is read back to reconcile, so an upward `scrollToIndex` no longer jumps back and stalls until the safety valve, and build `scrollBy` on a just-written offset diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 38989f0f0..1613a9be1 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -497,6 +497,11 @@ export class Virtualizer< // value when the diff is < 1.5 px, distinguishing it from a real user // scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases. private _intendedScrollOffset: number | null = null + // Where our last non-smooth write put the element, until its scroll event + // arrives. Until then `scrollOffset` still holds the old position, and the + // rows on screen are the ones rendered for it. A smooth write leaves it + // null: the element is somewhere between the two offsets. + private _lastWrittenOffset: number | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps // such a write because the consumer's sizer has not grown yet: an @@ -888,6 +893,7 @@ export class Virtualizer< this._iosTouching = false this._iosJustTouchEnded = false this._clampedAdjustment = null + this._lastWrittenOffset = null this.scrollElement = null this.targetWindow = null } @@ -973,6 +979,7 @@ export class Virtualizer< this.scrollState = null } this._intendedScrollOffset = null + this._lastWrittenOffset = null // A pending clamped compensation write (#1258) survives only its // own read-back, which the browser reports at the scroll max we @@ -1835,13 +1842,31 @@ export class Virtualizer< this.itemSizeCache.set(key, size) this.itemSizeCacheVersion++ - let adjustedSync = false - if (wasAtEnd) { - adjustedSync = this.applyScrollAdjustment( - this.getTotalSize() - prevTotalSize, + // A scroll command (or reconcile) wrote a new position that neither a + // scroll event nor an eager update has reflected in `scrollOffset` yet. + // The rows being measured were rendered for the old position, so + // "above the viewport" means nothing against either offset; while the + // scroll state lives, reconcileScroll retargets from fresh measurements + // next frame. Compensation writes and the prepend anchor sync update + // `scrollOffset` eagerly, and the iOS flush writes exactly + // `scrollOffset + scrollAdjustments`, so none of them count. + const awaitingCommandReadBack = + this.scrollState !== null && + this._lastWrittenOffset !== null && + !approxEqual( + this._lastWrittenOffset, + this.getScrollOffset() + this.scrollAdjustments, ) - } else if (shouldAdjustScroll) { - adjustedSync = this.applyScrollAdjustment(delta) + + let adjustedSync = false + if (!awaitingCommandReadBack) { + if (wasAtEnd) { + adjustedSync = this.applyScrollAdjustment( + this.getTotalSize() - prevTotalSize, + ) + } else if (shouldAdjustScroll) { + adjustedSync = this.applyScrollAdjustment(delta) + } } // When we just moved `scrollTop` to compensate for an above-viewport @@ -2080,7 +2105,8 @@ export class Virtualizer< delta: number, { behavior = 'auto' }: ScrollToOffsetOptions = {}, ) => { - const offset = this.getScrollOffset() + delta + // Build on a position we just wrote even before its scroll event arrives. + const offset = (this._lastWrittenOffset ?? this.getScrollOffset()) + delta const now = this.now() this.scrollState = { @@ -2227,6 +2253,8 @@ export class Virtualizer< // Record the intended logical scroll target so the next scroll event // can reconcile against subpixel rounding by the browser. this._intendedScrollOffset = offset + (adjustments ?? 0) + this._lastWrittenOffset = + behavior === 'smooth' ? null : this._intendedScrollOffset this.options.scrollToFn(offset, { behavior, adjustments }, this) } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 46012e3c8..73a2a284b 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -8,6 +8,7 @@ import { observeWindowOffset, windowScroll, } from '../src/index' +import type { VirtualizerOptions } from '../src/index' test('should export the Virtualizer class', () => { expect(Virtualizer).toBeDefined() @@ -2537,20 +2538,18 @@ test.fails( }, ) -test.fails( - 'scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', - () => { - // Not iOS-specific. scrollOffset still holds the old position until the - // scroll event, so row 38 (below the target) reads as above the viewport. - const h = makeDeferralHarness({ ios: false, start: 2000 }) - h.v.scrollToIndex(10, { align: 'start' }) - expect(h.el.scrollTop).toBe(500) - h.v.resizeItem(38, 100) - h.scroll(true) - h.frame() - expect(h.el.scrollTop).toBe(500) - }, -) +test('scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll', () => { + // Not iOS-specific. scrollOffset still holds the old position until the + // scroll event, so row 38 (below the target) reads as above the viewport; + // resizes before the read-back are left to reconcile. + const h = makeDeferralHarness({ ios: false, start: 2000 }) + h.v.scrollToIndex(10, { align: 'start' }) + expect(h.el.scrollTop).toBe(500) + h.v.resizeItem(38, 100) + h.scroll(true) + h.frame() + expect(h.el.scrollTop).toBe(500) +}) test.fails( 'scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves', @@ -5425,3 +5424,160 @@ test('ResizeObserver batch: a sync re-render does not drop queued entries (#1036 expect(virtualizer.getMeasurements()[5]!.size).toBe(140) expect(notifies).toEqual([true]) }) + +// 100 rows estimated at 50px in a 200px viewport. scrollToFn writes the +// element and clamps like a browser; scroll events are delivered by hand, so a +// test can resize between a programmatic write and its read-back. +function setupHandDrivenScroll( + extra: Partial> = {}, +) { + 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 = Math.max( + 0, + Math.min(offset + adjustments, el.scrollHeight - el.clientHeight), + ) + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: 200 }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(0, false) + return () => {} + }, + ...extra, + }) + v._willUpdate() + v['getMeasurements']() + const scroll = (offset: number, isScrolling: boolean) => + scrollCallback!(offset, isScrolling) + const settle = () => { + for (let i = 0; i < 10 && rafCallbacks.length > 0; i++) { + rafCallbacks.splice(0).forEach((cb) => cb(0)) + scroll(el.scrollTop, true) + } + scroll(el.scrollTop, false) + } + return { v, el, scroll, settle } +} + +test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { + // Scrolling up from 2000 to row 20 (1000), row 30 grows before the scroll + // event reports 1000. Compensating it against the stale 2000 used to write + // 2050, and reconcile (target unchanged) never rewrote it until the 5s + // safety valve. + const { v, el, scroll, settle } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToIndex(20, { align: 'start' }) + expect(el.scrollTop).toBe(1000) + v.resizeItem(30, 100) + expect(el.scrollTop).toBe(1000) + settle() + + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) + +test('a resize above the target before the read-back is picked up by reconcile', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + v.scrollToIndex(20, { align: 'start' }) + v.resizeItem(10, 100) + // No compensation write before the read-back... + expect(el.scrollTop).toBe(1000) + settle() + // ...reconcile retargets to the row's new start. + expect(el.scrollTop).toBe(1050) + expect(v.scrollState).toBeNull() +}) + +test('rows at the old position measured during scrollToEnd do not push it off the end', () => { + // scrollToEnd writes 4800, but until the read-back the rendered rows are + // the ones at the top. They measure 20px smaller each; compensating them + // against the written offset would walk the view up from the end while + // the end target (the DOM max, sizer not re-rendered) stays put. + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToEnd() + expect(el.scrollTop).toBe(4800) + for (let i = 0; i < 5; i++) v.resizeItem(i, 30) + expect(el.scrollTop).toBe(4800) + settle() + + expect(el.scrollTop).toBe(4800) + expect(v.scrollState).toBeNull() +}) + +test('scrollBy right after a scrollToIndex builds on the written offset', () => { + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start' }) + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(1100) +}) + +test('scrollBy after the user scrolled builds on the user position, not an old write', () => { + const { v, el, scroll, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start' }) + scroll(el.scrollTop, false) // read-back of 1000 + el.scrollTop = 1300 + scroll(1300, false) // the user moved on + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(1400) +}) + +test('scrollBy during a smooth scroll builds on the current position', () => { + // The element is still travelling, so the destination isn't where it is. + const { v, el, settle } = setupHandDrivenScroll() + + v.scrollToIndex(20, { align: 'start', behavior: 'smooth' }) + v.scrollBy(100) + settle() + + expect(el.scrollTop).toBe(100) +}) + +test('an anchorTo end list scrolled up by scrollToIndex is not pinned back by a resize before the read-back', () => { + // At the end, scrollToIndex(20) writes 1000. The last row grows before the + // read-back; against the stale offset the list still looks pinned, and the + // end-anchoring compensation used to write 4850 over the index scroll. + const { v, el, scroll, settle } = setupHandDrivenScroll({ anchorTo: 'end' }) + el.scrollTop = 4800 + scroll(4800, false) + + v.scrollToIndex(20, { align: 'start' }) + v.resizeItem(99, 100) + settle() + + expect(el.scrollTop).toBe(1000) + expect(v.scrollState).toBeNull() +}) diff --git a/packages/virtual-core/tests/ios-differential.test.ts b/packages/virtual-core/tests/ios-differential.test.ts new file mode 100644 index 000000000..b985c6d44 --- /dev/null +++ b/packages/virtual-core/tests/ios-differential.test.ts @@ -0,0 +1,510 @@ +import { describe, expect, test } from 'vitest' +import { Virtualizer, _resetIOSDetectionForTests } from '../src/index' +import type { ScrollAlignment, VirtualizerOptions } from '../src/index' + +// Differential check for the iOS scroll deferral (#1307). +// +// On iOS WebKit a scrollTop write cancels momentum, so size corrections made +// while the scroller is live are deferred and written once it settles. On +// every other platform the same correction is written immediately. Deferral +// should change only *when* a correction lands, never *how much*: after the +// same sequence of events, both platforms must settle at the same scrollTop. +// +// Each case runs on two fresh virtualizers, one with an iOS user agent, and +// compares the settled scrollTop. + +type Step = + | { t: 'at'; top: number } // the element starts here, at rest + | { t: 'index'; i: number; align?: ScrollAlignment } + | { t: 'end' } + | { t: 'by'; d: number } + | { t: 'cancel' } + | { t: 'resize'; i: number; size: number } + | { t: 'prepend'; n: number } + | { t: 'event' } // read-back of the current position, still scrolling + | { t: 'idle' } // a scroll event that reports the scroller at rest + | { t: 'drag'; d: number } // native scroll by the user + | { t: 'touchstart' } + | { t: 'touchend' } + | { t: 'frame' } + | { t: 'valve' } // jump past the reconcile safety valve, then a frame + +type Options = Partial> + +const IOS_UA = 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X)' +const VIEWPORT = 200 + +function run(ios: boolean, c: Case) { + if (ios) { + Object.defineProperty(navigator, 'userAgent', { + value: IOS_UA, + configurable: true, + }) + } + _resetIOSDetectionForTests() + try { + return runSteps(c.steps, c.options ?? {}, c.setup) + } finally { + delete (navigator as any).userAgent + _resetIOSDetectionForTests() + } +} + +function runSteps( + steps: Array, + options: Options, + setup?: (v: Virtualizer) => void, +) { + const rafs: Array = [] + const timers = new Map void>() + let timerId = 0 + let clock = 0 + const listeners: Record void>> = {} + const win = { + requestAnimationFrame: (cb: FrameRequestCallback) => rafs.push(cb), + cancelAnimationFrame: () => {}, + setTimeout: (fn: () => void) => { + timers.set(++timerId, fn) + return timerId + }, + clearTimeout: (id: number) => timers.delete(id), + performance: { now: () => clock }, + } + const el = { + scrollTop: 0, + scrollLeft: 0, + scrollHeight: 0, + clientHeight: VIEWPORT, + offsetHeight: VIEWPORT, + ownerDocument: { defaultView: win }, + addEventListener: (type: string, fn: () => void) => { + ;(listeners[type] ??= []).push(fn) + }, + removeEventListener: () => {}, + } + const max = () => el.scrollHeight - el.clientHeight + let ids = Array.from({ length: options.count ?? 100 }, (_, i) => i) + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + let dragClamped = false + + const v = new Virtualizer({ + count: ids.length, + estimateSize: () => 50, + getItemKey: (i) => ids[i]!, + getScrollElement: () => el as any, + scrollToFn: (offset, { adjustments = 0 }) => { + el.scrollTop = Math.max(0, Math.min(offset + adjustments, max())) + }, + observeElementRect: (_inst, cb) => { + cb({ width: 400, height: VIEWPORT }) + return () => {} + }, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.scrollTop, false) + return () => {} + }, + ...options, + }) + setup?.(v) + + // What a framework adapter does on every render: rebuild measurements, + // run the update hook, and size the scroll container to the total. + const render = () => { + v['getMeasurements']() + v._willUpdate() + el.scrollHeight = v.getTotalSize() + (v.options.scrollMargin ?? 0) + } + const scroll = (isScrolling: boolean) => + scrollCallback!(el.scrollTop, isScrolling) + const frame = () => rafs.splice(0).forEach((cb) => cb(clock)) + const fire = (type: string) => listeners[type]?.forEach((fn) => fn()) + + render() + + for (const step of steps) { + switch (step.t) { + case 'at': + el.scrollTop = step.top + scroll(false) + break + case 'index': + v.scrollToIndex(step.i, { align: step.align ?? 'start' }) + break + case 'end': + v.scrollToEnd() + break + case 'by': + v.scrollBy(step.d) + break + case 'cancel': + v.cancelScroll() + break + case 'resize': + v.resizeItem(step.i, step.size) + render() + break + case 'prepend': + ids = [ + ...Array.from({ length: step.n }, (_, i) => i - step.n - ids.length), + ...ids, + ] + v.setOptions({ ...v.options, count: ids.length }) + render() + break + case 'event': + scroll(true) + break + case 'idle': + scroll(false) + break + case 'drag': { + const next = el.scrollTop + step.d + el.scrollTop = Math.max(0, Math.min(next, max())) + if (el.scrollTop !== next) dragClamped = true + scroll(true) + break + } + case 'touchstart': + fire('touchstart') + break + case 'touchend': + fire('touchend') + break + case 'frame': + frame() + break + case 'valve': + clock += 6000 + frame() + break + } + } + + // Settle: let reconcile finish while the scroller still reports motion, + // report rest, let the touch grace timer expire, and finish reconcile at + // rest. A reconcile still running then is stuck off its target; push it + // through the safety valve so the positions compare, but report it. + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + scroll(true) + } + scroll(false) + timers.forEach((fn) => fn()) + timers.clear() + for (let i = 0; i < 20 && rafs.length > 0; i++) { + frame() + scroll(false) + } + const stuck = v.scrollState !== null + if (stuck) { + clock += 6000 + frame() + scroll(false) + } + + return { top: el.scrollTop, dragClamped, stuck } +} + +type Case = { + name: string + steps: Array + options?: Options + setup?: (v: Virtualizer) => void +} + +const at1000 = (): Step => ({ t: 'at', top: 1000 }) +const landedOn20 = (align: ScrollAlignment = 'start'): Array => [ + { t: 'index', i: 20, align }, + { t: 'event' }, + { t: 'frame' }, // reached the target, still waiting for stable frames +] + +const keyedEnd: Options = { anchorTo: 'end' } + +const cases: Array = [ + { + name: 'A: resize above the target during an index scroll, lands (#1304)', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + ], + }, + { + name: 'B: resize after the last reconcile write, gesture interrupts', + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'C: scroll up, row between target and old viewport grows before the read-back, gesture', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 30, size: 100 }, + { t: 'event' }, + { t: 'frame' }, + { t: 'drag', d: -50 }, + ], + }, + { + name: 'D: scroll up, row between target and old viewport grows before the read-back, lands', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 30, size: 100 }, + ], + }, + { + name: 'D2: scroll up, row above the target grows before the read-back, lands', + steps: [ + { t: 'at', top: 2000 }, + { t: 'index', i: 20 }, + { t: 'resize', i: 10, size: 100 }, + ], + }, + { + name: 'E: user scroll, row above grows, user flings past it', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -700 }, + ], + }, + { + name: 'F: align end, a visible row grows, gesture', + steps: [ + ...landedOn20('end'), + { t: 'resize', i: 19, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'G: align end, a row above grows, gesture', + steps: [ + ...landedOn20('end'), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'H: align center, a row above grows, gesture', + steps: [ + ...landedOn20('center'), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'I: lanes 2 measured, the target changes lane, gesture', + options: { + lanes: 2, + laneAssignmentMode: 'measured', + estimateSize: (i) => (i === 19 ? 80 : 50), + }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 18, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'J: scrollPaddingStart, a visible row above the target grows, gesture', + options: { scrollPaddingStart: 100 }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 19, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'K: scrollMargin, target near the end, a row above grows, gesture', + options: { scrollMargin: 300 }, + steps: [ + { t: 'index', i: 93 }, + { t: 'event' }, + { t: 'frame' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'L: custom predicate opting out, gesture', + setup: (v) => { + v.shouldAdjustScrollPositionOnItemSizeChange = () => false + }, + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'M: cancelScroll after the scroll settled', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'idle' }, + { t: 'cancel' }, + ], + }, + { + name: 'M2: cancelScroll while still scrolling', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'cancel' }, + ], + }, + { + name: 'N: safety valve after the scroll settled', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'idle' }, + { t: 'valve' }, + ], + }, + { + name: 'N2: safety valve while still scrolling', + steps: [ + { t: 'index', i: 20 }, + { t: 'event' }, + { t: 'resize', i: 10, size: 100 }, + { t: 'valve' }, + ], + }, + { + name: 'O: prepend during an index scroll, lands', + options: keyedEnd, + steps: [...landedOn20(), { t: 'prepend', n: 10 }], + }, + { + name: 'O2: prepend during an index scroll, gesture', + options: keyedEnd, + steps: [...landedOn20(), { t: 'prepend', n: 10 }, { t: 'drag', d: -100 }], + }, + { + name: 'O3: prepend and a resize during an index scroll, gesture', + options: keyedEnd, + steps: [ + ...landedOn20(), + { t: 'prepend', n: 10 }, + { t: 'resize', i: 5, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'P: user scroll, a row above grows, then a row at the new fold is measured', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'resize', i: 19, size: 120 }, + ], + }, + { + name: 'T: scrollBy right after scrollToIndex', + steps: [ + { t: 'index', i: 20 }, + { t: 'by', d: 100 }, + ], + }, + { + name: 'U: touch drag, a row above grows mid-touch, release', + steps: [ + at1000(), + { t: 'touchstart' }, + { t: 'drag', d: -30 }, + { t: 'resize', i: 10, size: 100 }, + { t: 'drag', d: -30 }, + { t: 'touchend' }, + ], + }, + { + name: 'X: scrollToEnd with anchorTo end, the last row grows, gesture', + options: keyedEnd, + steps: [ + { t: 'end' }, + { t: 'event' }, + { t: 'frame' }, + { t: 'resize', i: 99, size: 100 }, + { t: 'drag', d: -100 }, + ], + }, + { + name: 'X2: scrollToEnd, rows at the old position measured before the read-back', + steps: [ + { t: 'end' }, + { t: 'resize', i: 0, size: 30 }, + { t: 'resize', i: 1, size: 30 }, + { t: 'resize', i: 2, size: 30 }, + ], + }, + { + name: 'Y: user scroll, a row above shrinks', + steps: [ + { t: 'drag', d: 1000 }, + { t: 'resize', i: 10, size: 20 }, + ], + }, + { + name: 'Z: two resizes after the last reconcile write, gesture', + steps: [ + ...landedOn20(), + { t: 'resize', i: 10, size: 100 }, + { t: 'resize', i: 11, size: 70 }, + { t: 'drag', d: -100 }, + ], + }, +] + +// Cases where iOS still diverges, by id. All but P drop a correction deferred +// while an index scroll runs (#1305) instead of applying it the way non-iOS +// does; P misses the pending deferral when classifying the next resize. +// #1307 fixes them and must remove them from this list: the test asserts +// that each one still diverges, so a fix can't go unnoticed. +const KNOWN_DIVERGENT = new Set([ + 'B', + 'G', + 'H', + 'I', + 'K', + 'M', + 'M2', + 'N', + 'N2', + 'O3', + 'P', + 'Z', +]) + +describe('iOS deferral settles where non-iOS does', () => { + for (const c of cases) { + test(c.name, () => { + const plain = run(false, c) + const ios = run(true, c) + const result = JSON.stringify({ plain: plain.top, ios: ios.top }) + // A drag the clamp cut short is not additive with a later correction, + // so the two platforms legitimately differ; keep cases clear of it. + expect(plain.dragClamped || ios.dragClamped, result).toBe(false) + // Both platforms share a reconcile; a bug there matches on both sides, + // so check it separately: settling never needs the safety valve. + expect({ plain: plain.stuck, ios: ios.stuck }).toEqual({ + plain: false, + ios: false, + }) + if (KNOWN_DIVERGENT.has(c.name.split(':')[0]!)) { + expect(Math.abs(plain.top - ios.top), result).toBeGreaterThan(1) + } else { + expect(ios.top, result).toBe(plain.top) + } + }) + } +}) From a1271e165967e3401b132b9687e59361d5324ddf Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 11:24:02 +0200 Subject: [PATCH 2/3] fix(virtual-core): clamp scrollBy and drop the written offset with the scroll state A scrollBy past either edge recorded an offset the browser clamped back to where the element already was. No scroll event fired, so the next scrollBy built on the unreachable offset and one call was swallowed per extra push. Clamp the target, and clear the written offset wherever the scroll state is dropped (safety valve, settle, cancelScroll) so a write the browser ignored does not outlive its command. Also correct the resizeItem comment: only index and end targets are retargeted by reconcile; an offset target lands where it was written. Co-Authored-By: Claude Opus 5.5 --- packages/virtual-core/src/index.ts | 23 ++++++-- packages/virtual-core/tests/index.test.ts | 70 ++++++++++++++++++++++- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 1613a9be1..c0cadc79c 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -500,7 +500,8 @@ export class Virtualizer< // Where our last non-smooth write put the element, until its scroll event // arrives. Until then `scrollOffset` still holds the old position, and the // rows on screen are the ones rendered for it. A smooth write leaves it - // null: the element is somewhere between the two offsets. + // null: the element is somewhere between the two offsets. Cleared with the + // scroll state too, since a write the browser ignored never reads back. private _lastWrittenOffset: number | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps @@ -1201,6 +1202,7 @@ export class Virtualizer< private scheduleScrollReconcile() { if (!this.targetWindow) { this.scrollState = null + this._lastWrittenOffset = null return } if (this.rafId != null) return @@ -1227,6 +1229,7 @@ export class Virtualizer< const MAX_RECONCILE_MS = 5000 if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) { this.scrollState = null + this._lastWrittenOffset = null return } @@ -1266,6 +1269,7 @@ export class Virtualizer< }) } this.scrollState = null + this._lastWrittenOffset = null return } } else { @@ -1845,9 +1849,12 @@ export class Virtualizer< // A scroll command (or reconcile) wrote a new position that neither a // scroll event nor an eager update has reflected in `scrollOffset` yet. // The rows being measured were rendered for the old position, so - // "above the viewport" means nothing against either offset; while the - // scroll state lives, reconcileScroll retargets from fresh measurements - // next frame. Compensation writes and the prepend anchor sync update + // "above the viewport" means nothing against either offset. An index + // or end target is retargeted from fresh measurements by + // reconcileScroll next frame; an offset target (scrollToOffset, + // scrollBy) lands where it was written and the delta is not applied, + // since compensating against the stale offset would overwrite the + // command. Compensation writes and the prepend anchor sync update // `scrollOffset` eagerly, and the iOS flush writes exactly // `scrollOffset + scrollAdjustments`, so none of them count. const awaitingCommandReadBack = @@ -2106,7 +2113,12 @@ export class Virtualizer< { behavior = 'auto' }: ScrollToOffsetOptions = {}, ) => { // Build on a position we just wrote even before its scroll event arrives. - const offset = (this._lastWrittenOffset ?? this.getScrollOffset()) + delta + // Clamp: a write past either edge that leaves the element where it was + // fires no scroll event, so nothing would clear an unreachable offset. + const offset = this.getOffsetForAlignment( + (this._lastWrittenOffset ?? this.getScrollOffset()) + delta, + 'start', + ) const now = this.now() this.scrollState = { @@ -2164,6 +2176,7 @@ export class Virtualizer< this.rafId = null } this.scrollState = null + this._lastWrittenOffset = null } getTotalSize = () => { diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 73a2a284b..7fdcad521 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -5446,11 +5446,14 @@ function setupHandDrivenScroll( } let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = null + // Like iOS during a touch: drop programmatic writes on the floor. + const browser = { ignoreWrites: false } const v = new Virtualizer({ count: 100, estimateSize: () => 50, getScrollElement: () => el as any, scrollToFn: (offset, { adjustments = 0 }) => { + if (browser.ignoreWrites) return el.scrollTop = Math.max( 0, Math.min(offset + adjustments, el.scrollHeight - el.clientHeight), @@ -5478,7 +5481,14 @@ function setupHandDrivenScroll( } scroll(el.scrollTop, false) } - return { v, el, scroll, settle } + // Runs frames without scroll events, as a browser does when a write leaves + // the element where it was. + const frames = (n = 10) => { + for (let i = 0; i < n && rafCallbacks.length > 0; i++) { + rafCallbacks.splice(0).forEach((cb) => cb(0)) + } + } + return { v, el, scroll, settle, frames, browser } } test('a resize before the read-back of a scrollToIndex write is left to reconcile', () => { @@ -5581,3 +5591,61 @@ test('an anchorTo end list scrolled up by scrollToIndex is not pinned back by a expect(el.scrollTop).toBe(1000) expect(v.scrollState).toBeNull() }) + +test('scrollBy past the end does not leave the next scrollBy building on an unreachable offset', () => { + // A write the browser clamps to where the element already is fires no + // scroll event, so nothing reads it back. + const { v, el, scroll, frames } = setupHandDrivenScroll() + el.scrollTop = 4800 + scroll(4800, false) + + v.scrollBy(100) + frames() + expect(el.scrollTop).toBe(4800) + v.scrollBy(-100) + + expect(el.scrollTop).toBe(4700) +}) + +test('scrollBy past the start does not leave the next scrollBy building on an unreachable offset', () => { + const { v, el, frames } = setupHandDrivenScroll() + + v.scrollBy(-100) + frames() + expect(el.scrollTop).toBe(0) + v.scrollBy(100) + + expect(el.scrollTop).toBe(100) +}) + +test('scrollBy after the safety valve dropped an ignored write builds on the real position', () => { + const { v, el, scroll, frames, browser } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + let now = 0 + v['now'] = () => now + + browser.ignoreWrites = true + v.scrollToIndex(20, { align: 'start' }) + now = 6000 + frames() + expect(v.scrollState).toBeNull() + browser.ignoreWrites = false + v.scrollBy(100) + + expect(el.scrollTop).toBe(2100) +}) + +test('scrollBy after cancelScroll dropped an ignored write builds on the real position', () => { + const { v, el, scroll, browser } = setupHandDrivenScroll() + el.scrollTop = 2000 + scroll(2000, false) + + browser.ignoreWrites = true + v.scrollToIndex(20, { align: 'start' }) + v.cancelScroll() + browser.ignoreWrites = false + v.scrollBy(100) + + expect(el.scrollTop).toBe(2100) +}) From 053792274a7296709382e700b5d5a5006bd57396 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Mon, 12 Oct 2026 11:55:58 +0200 Subject: [PATCH 3/3] refactor(virtual-core): keep the written offset on the scroll state The written offset only matters while a scroll command is in flight, which the resizeItem guard already requires. Storing it on ScrollState drops it with the state (settle, safety valve, cancelScroll, a replaced command, cleanup) instead of resetting a separate field at each of those sites. Co-Authored-By: Claude Opus 5.5 --- packages/virtual-core/src/index.ts | 34 ++++++++++++++---------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index c0cadc79c..e42eff8f2 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -406,6 +406,14 @@ type ScrollState = { stableFrames: number // Keep arrival across measurement-driven retargets so external scrolls can cancel. hasReachedTarget: boolean + + // Where our last non-smooth write for this command put the element, until + // its scroll event arrives. Until then `scrollOffset` still holds the old + // position, and the rows on screen are the ones rendered for it. A smooth + // write leaves it null: the element is somewhere between the two offsets. + // It goes with the scroll state, so a write the browser ignored (no read-back) + // does not outlive its command. + writtenOffset?: number | null } type PendingScrollAnchor = [ @@ -497,12 +505,6 @@ export class Virtualizer< // value when the diff is < 1.5 px, distinguishing it from a real user // scroll. The +0.5 over Math.abs lets us also absorb the +1 / -1 cases. private _intendedScrollOffset: number | null = null - // Where our last non-smooth write put the element, until its scroll event - // arrives. Until then `scrollOffset` still holds the old position, and the - // rows on screen are the ones rendered for it. A smooth write leaves it - // null: the element is somewhere between the two offsets. Cleared with the - // scroll state too, since a write the browser ignored never reads back. - private _lastWrittenOffset: number | null = null // A compensation write from `applyScrollAdjustment` whose target exceeded // the element's scroll max at the moment of the write. The browser clamps // such a write because the consumer's sizer has not grown yet: an @@ -894,7 +896,6 @@ export class Virtualizer< this._iosTouching = false this._iosJustTouchEnded = false this._clampedAdjustment = null - this._lastWrittenOffset = null this.scrollElement = null this.targetWindow = null } @@ -980,7 +981,7 @@ export class Virtualizer< this.scrollState = null } this._intendedScrollOffset = null - this._lastWrittenOffset = null + if (this.scrollState) this.scrollState.writtenOffset = null // A pending clamped compensation write (#1258) survives only its // own read-back, which the browser reports at the scroll max we @@ -1202,7 +1203,6 @@ export class Virtualizer< private scheduleScrollReconcile() { if (!this.targetWindow) { this.scrollState = null - this._lastWrittenOffset = null return } if (this.rafId != null) return @@ -1229,7 +1229,6 @@ export class Virtualizer< const MAX_RECONCILE_MS = 5000 if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) { this.scrollState = null - this._lastWrittenOffset = null return } @@ -1269,7 +1268,6 @@ export class Virtualizer< }) } this.scrollState = null - this._lastWrittenOffset = null return } } else { @@ -1858,10 +1856,9 @@ export class Virtualizer< // `scrollOffset` eagerly, and the iOS flush writes exactly // `scrollOffset + scrollAdjustments`, so none of them count. const awaitingCommandReadBack = - this.scrollState !== null && - this._lastWrittenOffset !== null && + this.scrollState?.writtenOffset != null && !approxEqual( - this._lastWrittenOffset, + this.scrollState.writtenOffset, this.getScrollOffset() + this.scrollAdjustments, ) @@ -2116,7 +2113,7 @@ export class Virtualizer< // Clamp: a write past either edge that leaves the element where it was // fires no scroll event, so nothing would clear an unreachable offset. const offset = this.getOffsetForAlignment( - (this._lastWrittenOffset ?? this.getScrollOffset()) + delta, + (this.scrollState?.writtenOffset ?? this.getScrollOffset()) + delta, 'start', ) const now = this.now() @@ -2176,7 +2173,6 @@ export class Virtualizer< this.rafId = null } this.scrollState = null - this._lastWrittenOffset = null } getTotalSize = () => { @@ -2266,8 +2262,10 @@ export class Virtualizer< // Record the intended logical scroll target so the next scroll event // can reconcile against subpixel rounding by the browser. this._intendedScrollOffset = offset + (adjustments ?? 0) - this._lastWrittenOffset = - behavior === 'smooth' ? null : this._intendedScrollOffset + if (this.scrollState) { + this.scrollState.writtenOffset = + behavior === 'smooth' ? null : this._intendedScrollOffset + } this.options.scrollToFn(offset, { behavior, adjustments }, this) }