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..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 = [ @@ -973,6 +981,7 @@ export class Virtualizer< this.scrollState = null } this._intendedScrollOffset = 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 @@ -1835,13 +1844,33 @@ 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. 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 = + this.scrollState?.writtenOffset != null && + !approxEqual( + this.scrollState.writtenOffset, + 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 +2109,13 @@ 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. + // 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.scrollState?.writtenOffset ?? this.getScrollOffset()) + delta, + 'start', + ) const now = this.now() this.scrollState = { @@ -2227,6 +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) + if (this.scrollState) { + this.scrollState.writtenOffset = + 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..7fdcad521 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,228 @@ 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 + // 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), + ) + }, + 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) + } + // 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', () => { + // 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() +}) + +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) +}) 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) + } + }) + } +})