diff --git a/.changeset/ios-touch-provenance.md b/.changeset/ios-touch-provenance.md new file mode 100644 index 00000000..18ce4145 --- /dev/null +++ b/.changeset/ios-touch-provenance.md @@ -0,0 +1,9 @@ +--- +'@tanstack/virtual-core': patch +--- + +Gate the iOS scroll-adjustment deferral on touch provenance instead of `isScrolling`. The deferral exists to keep `scrollTop` writes from cancelling touch momentum (#884), but `isScrolling` is set by any scroll event, including the echo of the virtualizer's own programmatic write, so a `scrollToIndex` / `scrollToOffset` landing had its measurement compensation deferred past a paint and snapped a beat later (#1250). Adjustments are now deferred only while a finger is down or inside a timer-bounded post-touchend tail that momentum scroll events keep re-arming, so it spans the whole fling and self-terminates 150 ms after the last frame. Absolute scroll commands close that tail (their write cancels momentum anyway), so a landing triggered from a tap handler compensates synchronously too. `touchcancel` is handled like `touchend`, so a system gesture stealing the touch no longer leaves the deferral gate stuck. + +Also fix a double-applied prepend correction on iOS: when an end-anchored prepend landed while a finger was down, `setOptions` had already folded the anchor delta into the tracked offset and the deferred flush added it again, throwing the reader a whole prepend past their row once the gesture settled. The deferred path now keeps the tracked offset at the DOM's value and compensates prepended rows' measurements against the offset the flush will land on, so the flush applies exactly one measured prepend. + +Also recover an iOS compensation write that a touch undoes. iOS scrolls on a separate thread, so when a deferred correction flushes and the user touches the screen within a frame, the scrolling thread still holds the pre-write position and the write is reverted; the resulting scroll event used to be treated as the user scrolling and the correction was lost, leaving the reader a whole prepend away from their row. The missing delta now goes back into the deferred accumulator and replays once the gesture settles. diff --git a/packages/react-virtual/e2e/app/test/ios-touch-deferral.spec.ts b/packages/react-virtual/e2e/app/test/ios-touch-deferral.spec.ts new file mode 100644 index 00000000..dd8895db --- /dev/null +++ b/packages/react-virtual/e2e/app/test/ios-touch-deferral.spec.ts @@ -0,0 +1,167 @@ +import { expect, test } from '@playwright/test' +import type { CDPSession, Page } from '@playwright/test' + +// Browser gate for the iOS scroll-adjustment deferral. `isIOSWebKit()` keys off +// the user agent, so an iPhone UA plus touch emulation puts Chromium on the iOS +// code path; CDP `Input.dispatchTouchEvent` then drives a real touch gesture. +// This exercises the deferral state machine end to end in a real browser. It +// cannot reproduce WebKit's momentum physics (the reason the deferral exists), +// only the bookkeeping around it. + +test.use({ + hasTouch: true, + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1', +}) + +const container = '#scroll-container' + +async function waitForEnd(page: Page) { + await expect + .poll(() => + page.evaluate((sel) => { + const el = document.querySelector(sel)! + return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) + }, container), + ) + .toBeLessThan(1.01) +} + +const scrollTop = (page: Page) => + page.evaluate((sel) => document.querySelector(sel)!.scrollTop, container) + +// Screen position of the message row nearest the top of the viewport, so we +// can tell whether the reader's row stayed put across the prepend. +async function topRow(page: Page) { + return page.evaluate((sel) => { + const el = document.querySelector(sel)! + const top = el.getBoundingClientRect().top + const row = [...el.querySelectorAll('[data-message-id]')] + .map((n) => ({ + id: n.dataset.messageId!, + y: n.getBoundingClientRect().top - top, + })) + .filter((r) => r.y > -1) + .sort((a, b) => a.y - b.y)[0]! + return row + }, container) +} + +async function rowY(page: Page, id: string) { + return page.evaluate( + ({ sel, id }) => { + const el = document.querySelector(sel)! + const n = el.querySelector(`[data-message-id="${id}"]`) + return n + ? n.getBoundingClientRect().top - el.getBoundingClientRect().top + : null + }, + { sel: container, id }, + ) +} + +async function fingerDown(cdp: CDPSession, x: number, y: number) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchStart', + touchPoints: [{ x, y }], + }) +} +async function fingerMove(cdp: CDPSession, x: number, y: number) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchMove', + touchPoints: [{ x, y }], + }) +} +async function fingerUp(cdp: CDPSession) { + await cdp.send('Input.dispatchTouchEvent', { + type: 'touchEnd', + touchPoints: [], + }) +} + +test('a prepend landing mid-touch is anchored once the gesture settles, not doubled', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'CDP touch dispatch is Chromium-only') + await page.goto('/chat/') + await waitForEnd(page) + // Reading history: well away from the end so followOnAppend stays out of it. + await page.evaluate((sel) => { + document.querySelector(sel)!.scrollTop = 600 + }, container) + await page.waitForTimeout(200) + + const box = (await page.locator(container).boundingBox())! + const x = box.x + box.width / 2 + const y = box.y + box.height / 2 + const cdp = await page.context().newCDPSession(page) + + // Finger down and a short drag: the user owns the scroll. + await fingerDown(cdp, x, y) + await fingerMove(cdp, x, y + 10) + await fingerMove(cdp, x, y + 20) + await page.waitForTimeout(80) + const before = await topRow(page) + const stBefore = await scrollTop(page) + + // History lands while the finger is still down: 5 x 50px above the reader. + await page.evaluate(() => document.getElementById('prepend')!.click()) + await page.waitForTimeout(150) + // Deferred: no scrollTop write yet, so the DOM still sits where the user left it. + expect(await scrollTop(page)).toBe(stBefore) + + // Release. The post-touchend tail expires ~150ms later and the deferred + // delta flushes in one write. + await fingerUp(cdp) + await page.waitForTimeout(500) + + // The reader's row is back at the same screen position, offset by exactly + // one prepend (250px): the deferred delta was applied once, in one write. + // (The double-count regression with measured rows is covered by the core + // unit test '#884: ... applies it once'; this page's rows match their + // estimate, so it exercises the deferral state machine, not that path.) + const yAfter = await rowY(page, before.id) + expect(yAfter).not.toBeNull() + expect(Math.abs(yAfter! - before.y)).toBeLessThan(2) + expect(await scrollTop(page)).toBe(stBefore + 250) +}) + +test('a programmatic scrollToIndex landing with no touch compensates on the spot (#1250)', async ({ + page, + browserName, +}) => { + test.skip(browserName !== 'chromium', 'CDP touch dispatch is Chromium-only') + // /scroll/ has 1002 rows of random height against a 50px estimate, so a + // landing far down the list measures rows above the fold that differ from + // their estimate and needs compensation. With the old `isScrolling` gate the + // write's own scroll event deferred that compensation; reconcileScroll still + // landed the target, and ~150ms later the flush replayed the deferred delta + // on top of it — a visible snap after the landing. No touch is involved + // here, so nothing may be deferred and the position must not move once the + // landing has settled. + await page.goto('/scroll/') + await page.click('#scroll-to-1000') + + const container = '#scroll-container' + const st = () => + page.evaluate((sel) => document.querySelector(sel)!.scrollTop, container) + // Let the landing and its measurement corrections settle. + await page.waitForTimeout(300) + const settled = await st() + await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() + + // Nothing may be waiting to land later. + await page.waitForTimeout(600) + expect(await st()).toBe(settled) + + // And the landing itself is exact: item 1000 ends flush with the viewport. + const delta = await page.evaluate((sel) => { + const item = document.querySelector('[data-testid="item-1000"]')! + const el = document.querySelector(sel)! + const itemRect = item.getBoundingClientRect() + const rect = el.getBoundingClientRect() + return Math.abs(itemRect.bottom - rect.bottom) + }, container) + expect(delta).toBeLessThan(1.01) +}) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index ec3f30d4..6ad749f0 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -461,15 +461,29 @@ export class Virtualizer< scrollOffset: number | null = null scrollDirection: ScrollDirection | null = null scrollAdjustments = 0 - // Sum of size-change deltas above-viewport that were skipped during - // iOS momentum scroll (writing scrollTop mid-momentum cancels it). - // Flushed in a single scrollTo when iOS is fully settled. + // Sum of size-change deltas above-viewport that were skipped during a + // touch-driven iOS scroll (writing scrollTop mid-momentum cancels it, + // #884). Flushed in a single scrollTo once the gesture has settled. private _iosDeferredAdjustment = 0 - // Touch state. iOS WebKit cancels momentum when scrollTop is written, so - // we defer adjustments not only during `isScrolling` but also through the - // touchstart→touchend window (active drag) and a short tail after - // touchend (early-momentum window — iOS only fires touch events once at - // the start of momentum, so we use a timer rather than another event). + // The last compensation-type write on iOS (adjustment, flush, or prepend + // anchor sync), so the next scroll event can tell whether it landed. iOS + // scrolls on a separate thread: a touch that begins before the write has + // been committed wins with the pre-write position and the write is undone. + private _iosCompensationWrite: { + target: number + delta: number + at: number + } | null = null + // Touch provenance. iOS WebKit cancels momentum when scrollTop is written, + // so adjustments are deferred through the touchstart→touchend window + // (active drag) and a timer-bounded tail after touchend that spans the + // momentum phase: iOS fires no touch events during momentum, so every + // scroll event that arrives while the tail is armed re-arms it, and it + // expires ~150 ms after the last one. `isScrolling` is deliberately NOT + // part of the gate — it is set by any scroll event, including the echo of + // our own programmatic write, so gating on it deferred the compensation of + // a `scrollToIndex` landing past a paint and made it snap (#1250). A + // programmatic scroll has no momentum to protect; only a touch does. private _iosTouching = false private _iosJustTouchEnded = false private _iosTouchEndTimerId: number | null = null @@ -752,10 +766,7 @@ export class Virtualizer< console.info('correction', delta) } - if ( - isIOSWebKit() && - (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) - ) { + if (isIOSWebKit() && (this._iosTouching || this._iosJustTouchEnded)) { this._iosDeferredAdjustment += delta return false } else { @@ -775,6 +786,10 @@ export class Virtualizer< adjustments: (this.scrollAdjustments += delta), behavior, }) + this._recordIosCompensationWrite( + this.getScrollOffset() + this.scrollAdjustments, + delta, + ) // Eagerly carry the intended target in `scrollOffset` so callers that // read it before the next scroll event — notably the next `resizeItem` // tick's `getVirtualDistanceFromEnd()` / `wasAtEnd` check — see the @@ -850,6 +865,7 @@ export class Virtualizer< // pending reset of the flag), deferring every adjustment on the new // element until its next touch cycle. this._iosDeferredAdjustment = 0 + this._iosCompensationWrite = null this._iosTouching = false this._iosJustTouchEnded = false this._clampedAdjustment = null @@ -897,6 +913,31 @@ export class Virtualizer< this.unsubs.push( this.options.observeElementOffset(this, (offset, isScrolling) => { + // iOS: a touch that begins within a frame of a compensation write + // undoes it — the scrolling thread still holds the pre-write + // position and wins — and this event then reports that old + // position. Since the finger is down we cannot rewrite now; put the + // missing delta back into the deferred accumulator so the flush + // replays it once the gesture settles, instead of treating the + // revert as the user having scrolled and losing the correction. + // A write that landed echoes near its target; a pan that started + // after a landed write moves it by pixels, not by the whole delta. + const write = this._iosCompensationWrite + if (write !== null) { + this._iosCompensationWrite = null + if ( + this._iosTouching && + this.now() - write.at < 120 && + Math.abs(write.target - offset) > Math.abs(write.delta) / 2 + ) { + this._iosDeferredAdjustment += write.target - offset + // The write's share of `scrollAdjustments` never reached the + // DOM either; the replay will add it back. + this.scrollAdjustments = 0 + this._intendedScrollOffset = null + } + } + // A scroll event that reports movement but lands on the offset we // already hold — and isn't a self-write read-back — is a spurious // no-op re-emit that Safari/Firefox fire after a re-render's layout @@ -954,9 +995,17 @@ export class Virtualizer< this.scrollOffset = offset this.isScrolling = isScrolling - // Flush deferred iOS adjustments if we're now fully settled. - // "Fully settled" means: not actively scrolling, no finger on - // screen, and the post-touchend grace window has expired. + // Momentum after touchend fires a scroll event per frame but no + // touch events. Each one re-arms the post-touchend tail so it + // spans the whole fling and expires ~150 ms after the last frame. + // Only an already-armed tail is extended: a scroll event with no + // preceding touch (a programmatic write's echo) never opens it. + if (isScrolling && this._iosJustTouchEnded) { + this._armIosTouchWindow() + } + + // Flush deferred iOS adjustments if the gesture has settled: no + // finger on screen and the post-touchend tail has expired. this._flushIosDeferredIfReady() if (this.scrollState) { @@ -980,21 +1029,12 @@ export class Virtualizer< this._iosTouchEndTimerId = null } } + // touchcancel fires instead of touchend when a system gesture steals + // the touch; without it `_iosTouching` would stay true with no timer + // to recover it, deferring every adjustment until the next touch. const onTouchEnd = () => { this._iosTouching = false - if (!isIOSWebKit() || this.targetWindow == null) { - // Non-iOS: nothing more to track. Just clear the touching flag. - return - } - this._iosJustTouchEnded = true - // After ~150 ms with no scroll/touch events, momentum is done. - this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { - this._iosJustTouchEnded = false - this._iosTouchEndTimerId = null - // After the grace window, attempt to flush. The scroll event - // for momentum decay may have already fired before our timer. - this._flushIosDeferredIfReady() - }, 150) + this._armIosTouchWindow() } scrollEl.addEventListener( 'touchstart', @@ -1006,9 +1046,15 @@ export class Virtualizer< onTouchEnd, addEventListenerOptions, ) + scrollEl.addEventListener( + 'touchcancel', + onTouchEnd, + addEventListenerOptions, + ) this.unsubs.push(() => { scrollEl.removeEventListener('touchstart', onTouchStart) scrollEl.removeEventListener('touchend', onTouchEnd) + scrollEl.removeEventListener('touchcancel', onTouchEnd) if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { this.targetWindow.clearTimeout(this._iosTouchEndTimerId) this._iosTouchEndTimerId = null @@ -1038,12 +1084,19 @@ export class Virtualizer< // the in-flight scroll. Defer the DOM sync the same way // applyScrollAdjustment does — accumulate the delta and let // _flushIosDeferredIfReady handle it once the scroll settles. - if ( - isIOSWebKit() && - (this.isScrolling || this._iosTouching || this._iosJustTouchEnded) - ) { + if (isIOSWebKit() && (this._iosTouching || this._iosJustTouchEnded)) { if (anchorDelta !== 0) { this._iosDeferredAdjustment += anchorDelta + // setOptions folded anchorDelta into scrollOffset assuming this + // sync would land now. It won't until the gesture settles, and + // the flush writes `scrollOffset + deferred`, so leaving the + // eager value in place applies the delta twice and throws the + // reader a whole prepend past their row. Hand scrollOffset back + // to the DOM's truth and re-render the range for it. + if (this.scrollOffset !== null) { + this.scrollOffset = Math.max(0, this.scrollOffset - anchorDelta) + this.maybeNotify() + } } } else if ( this.scrollState?.behavior === 'smooth' && @@ -1065,6 +1118,7 @@ export class Virtualizer< adjustments: undefined, behavior: undefined, }) + this._recordIosCompensationWrite(this.getScrollOffset(), anchorDelta) } } @@ -1111,9 +1165,45 @@ export class Virtualizer< // truly settled — not actively scrolling, not under an active touch, and // past the post-touchend grace window. Called from the scroll callback // and the touchend grace-timer. + private _recordIosCompensationWrite = (target: number, delta: number) => { + if (!isIOSWebKit() || delta === 0) return + this._iosCompensationWrite = { target, delta, at: this.now() } + } + + // (Re)arm the post-touchend tail. Called from touchend/touchcancel and from + // every momentum scroll event while the tail is armed, so it self-terminates + // ~150 ms after the last frame and no piece of touch state can latch. + private _armIosTouchWindow = () => { + if (!isIOSWebKit() || this.targetWindow == null) return + this._iosJustTouchEnded = true + if (this._iosTouchEndTimerId !== null) { + this.targetWindow.clearTimeout(this._iosTouchEndTimerId) + } + this._iosTouchEndTimerId = this.targetWindow.setTimeout(() => { + this._iosJustTouchEnded = false + this._iosTouchEndTimerId = null + this._flushIosDeferredIfReady() + }, 150) + } + + // An absolute scroll command (`scrollToOffset` / `scrollToIndex`) writes + // scrollTop itself, which cancels any in-flight momentum, so the tail has + // nothing left to protect. Close it so the landing's compensation applies + // synchronously even when the command came from a tap handler and lands + // inside the post-touchend window (#1250). `_iosTouching` is left alone: + // with a finger still down the user owns the scroll. + private _closeIosTouchWindow = () => { + this._iosJustTouchEnded = false + if (this._iosTouchEndTimerId !== null && this.targetWindow != null) { + this.targetWindow.clearTimeout(this._iosTouchEndTimerId) + this._iosTouchEndTimerId = null + } + } + + // Deferral gate and flush predicate must agree: a delta deferred by a + // condition the flush path never re-checks would be stranded. private _flushIosDeferredIfReady = () => { if (this._iosDeferredAdjustment === 0) return - if (this.isScrolling) return if (this._iosTouching) return if (this._iosJustTouchEnded) return // Phase 2b: Safari elastic-overscroll (rubber-band) lets scrollTop go @@ -1141,6 +1231,7 @@ export class Virtualizer< adjustments: (this.scrollAdjustments += delta), behavior: undefined, }) + this._recordIosCompensationWrite(cur + this.scrollAdjustments, delta) } private rafId: number | null = null @@ -1714,8 +1805,15 @@ export class Virtualizer< const prevTotalSize = wasAtEnd ? this.getTotalSize() : 0 // Default anchoring predicate (used unless the consumer supplies a // custom shouldAdjustScrollPositionOnItemSizeChange). + // Compare against the offset the viewport will sit at once every + // pending write has landed. On iOS a deferred delta is such a write: + // during a touch the tracked offset is the DOM's, but rows prepended + // above the reader will end up above the fold after the flush, so their + // estimate error has to be folded in too (it is 0 off iOS). const scrollOffsetWithAdj = - this.getScrollOffset() + this.scrollAdjustments + this.getScrollOffset() + + this.scrollAdjustments + + this._iosDeferredAdjustment const isFirstMeasure = !this.itemSizeCache.has(key) const defaultShouldAdjust = isFirstMeasure ? // First measurement: compensate any item whose top sits above the @@ -1931,6 +2029,7 @@ export class Virtualizer< // it onto the just-established position (relative commands like scrollBy // intentionally keep the deferral, since they build on the current offset). this._iosDeferredAdjustment = 0 + this._closeIosTouchWindow() const offset = this.getOffsetForAlignment(toOffset, align) @@ -1956,10 +2055,6 @@ 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) @@ -1968,6 +2063,13 @@ export class Virtualizer< } const [offset, align] = offsetInfo + // See scrollToOffset: an absolute target invalidates any pending + // iOS-deferred compensation and closes the post-touchend tail. Only once + // there is a target: without one no write follows, so the fling still + // owns the scroll and a resize inside it must keep deferring. + this._iosDeferredAdjustment = 0 + this._closeIosTouchWindow() + const now = this.now() this.scrollState = { index, diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index cb1e742d..d6fba2fc 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -1564,84 +1564,106 @@ function withFakeIOSUserAgent(fn: () => T): T { } } -test('iOS deferral: scroll-position write is deferred during isScrolling', () => { - withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: - | ((offset: number, isScrolling: boolean) => void) - | null = null - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => - ({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - }) as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(100, true) // Start scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() +// Touch-driven iOS scroll fixture. Deferral is gated on touch provenance, so +// tests drive the gesture explicitly: `touch('touchstart')` puts the finger +// down, `touch('touchend')` releases it and arms the momentum tail, +// `scroll(offset, true)` is a momentum frame (re-arms the tail), and +// `expireTouchTail()` fires the tail's timer — the gesture has settled. +function makeIOSTouchVirtualizer( + props: Record = {}, + options: Record = {}, +) { + const timers = new Map void>() + let timerId = 0 + const mockWindow = { + setTimeout: (fn: () => void, _ms: number) => { + const id = ++timerId + timers.set(id, fn) + return id + }, + clearTimeout: (id: number) => timers.delete(id), + requestAnimationFrame: () => 1, + cancelAnimationFrame: () => {}, + performance: { now: () => Date.now() }, + } + const el = makeMockScrollElement({ + scrollTop: 100, + scrollLeft: 0, + scrollHeight: 500, + clientHeight: 200, + offsetHeight: 200, + ...props, + ownerDocument: { defaultView: mockWindow }, + }) + const scrollToFn = vi.fn() + let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null = + null + const v = new Virtualizer({ + count: 10, + estimateSize: () => 50, + getScrollElement: () => el as any, + scrollToFn, + observeElementRect: () => {}, + observeElementOffset: (_inst, cb) => { + scrollCallback = cb + cb(el.scrollTop, false) + return () => {} + }, + ...options, + }) + v._willUpdate() + v['getMeasurements']() + scrollToFn.mockClear() + return { + v, + el, + scrollToFn, + scroll: (offset: number, isScrolling: boolean) => + scrollCallback!(offset, isScrolling), + touch: (type: 'touchstart' | 'touchend' | 'touchcancel') => + el._dispatch(type), + expireTouchTail: () => { + const id = v['_iosTouchEndTimerId'] + expect(id, 'a touch tail timer is armed').not.toBeNull() + const fn = timers.get(id!)! + fn() + }, + } +} - // Resize an item above the current scroll position while isScrolling=true - // The default condition (item.start < scrollOffset + scrollAdjustments) - // would normally trigger an immediate scroll adjustment. +test('iOS deferral: compensation is deferred during a touch-driven scroll and flushed once the gesture settles', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + // Finger down, drag, release into momentum. + touch('touchstart') + scroll(120, true) + touch('touchend') + scroll(140, true) // momentum frame + + // Resize an item above the viewport mid-fling. The default predicate + // (item.start < scrollOffset) would write scrollTop immediately, and on + // iOS that cancels momentum (#884) — so it must defer. v.resizeItem(0, 100) // item 0 was at start=0; now 50→100 grows by 50 - - // On iOS during scroll, the adjustment should be DEFERRED — scrollToFn - // should NOT have been called for the adjustment. expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) - // Now transition isScrolling → false - scrollCallback!(100, false) - - // The deferred adjustment should be flushed. - expect(scrollToFn).toHaveBeenCalled() + // The tail armed by the last momentum frame expires: gesture settled. + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) }) }) -test('iOS deferral: multiple resizes during scroll accumulate and flush as one', () => { +test('iOS deferral: multiple resizes during a fling accumulate and flush as one', () => { 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() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) - // Three resizes during scroll: 10 + 15 + 20 = 45 total + // Three resizes during the fling: 10 + 15 + 20 = 45 total v.resizeItem(0, 60) v.resizeItem(1, 65) v.resizeItem(2, 70) @@ -1649,7 +1671,7 @@ test('iOS deferral: multiple resizes during scroll accumulate and flush as one', expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(45) - scrollCallback!(200, false) + expireTouchTail() // Single flush call expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) @@ -1663,34 +1685,11 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad // 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() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') // finger down: the user owns the scroll - // Accumulate a deferred adjustment during scroll. + // Accumulate a deferred adjustment during the drag. v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) @@ -1698,7 +1697,7 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad v.scrollToOffset(300) expect(v['_iosDeferredAdjustment']).toBe(0) - // scrollToIndex clears it too (still scrolling, so the resize defers). + // scrollToIndex clears it too (finger still down, so the resize defers). v.resizeItem(1, 100) expect(v['_iosDeferredAdjustment']).toBe(50) v.scrollToIndex(5) @@ -1706,8 +1705,11 @@ test('iOS deferral: an absolute scroll command invalidates a pending deferred ad scrollToFn.mockClear() // Settling must not replay any (now dropped) delta. - scrollCallback!(300, false) + touch('touchend') + expireTouchTail() + scroll(300, false) expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).not.toHaveBeenCalled() }) }) @@ -1719,41 +1721,19 @@ test('iOS deferral: flushed delta is rolled into scrollAdjustments so back-to-ba // scrollAdjustments` would miss the flushed delta and the next correction // would compute from the stale offset. 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() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 200 }) + touch('touchstart') + touch('touchend') + scroll(200, true) - // Build up a deferred adjustment of 50 during scroll. + // Build up a deferred adjustment of 50 during the fling. v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) expect(v['scrollAdjustments']).toBe(0) - // Settle: scroll event resets scrollAdjustments to 0, then the flush - // runs and must roll the deferred delta back into scrollAdjustments. - scrollCallback!(200, false) + // Settle: the flush must roll the deferred delta into scrollAdjustments. + expireTouchTail() expect(scrollToFn).toHaveBeenCalledTimes(1) const [, opts] = scrollToFn.mock.calls[0]! @@ -1768,39 +1748,20 @@ 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 + // during a fling 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() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer( + { scrollTop: 300 }, // pinned at the bottom: scrollHeight - clientHeight + { anchorTo: 'end' }, + ) + touch('touchstart') + touch('touchend') + scroll(300, true) // at the bottom, flinging // A row above the viewport re-measures smaller while at the end. v.resizeItem(0, 30) // 50 → 30: total shrinks by 20 @@ -1810,7 +1771,7 @@ test('iOS deferral: a negative delta at the end clamp is dropped, not replayed', // 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) + expireTouchTail() expect(v['_iosDeferredAdjustment']).toBe(0) expect(scrollToFn).not.toHaveBeenCalled() }) @@ -1822,33 +1783,11 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov // 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() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer({ scrollTop: 300 }, { anchorTo: 'end' }) + touch('touchstart') + touch('touchend') + scroll(300, true) // A row above the viewport re-measures larger while at the end. v.resizeItem(0, 70) // 50 → 70: total grows by 20 @@ -1856,7 +1795,7 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov expect(v['_iosDeferredAdjustment']).toBe(20) // Settle. Growth doesn't clamp, so the positive delta must replay. - scrollCallback!(300, false) + expireTouchTail() expect(v['_iosDeferredAdjustment']).toBe(0) expect(scrollToFn).toHaveBeenCalledTimes(1) }) @@ -1912,7 +1851,10 @@ function makeIOSVirtualizerWithRealEl( return { v, el } } -function dispatchTouchEvent(el: any, type: 'touchstart' | 'touchend') { +function dispatchTouchEvent( + el: any, + type: 'touchstart' | 'touchend' | 'touchcancel', +) { el._dispatch(type) } @@ -2010,47 +1952,35 @@ test('iOS Phase 1: resize in post-touchend grace window defers; flushes when tim }) }) -test('iOS Phase 1: scroll-event after touchend timer cleanup also flushes', () => { +test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until the fling ends', () => { withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCallback: ((o: number, s: boolean) => void) | null = null - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { - defaultView: { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }, - }, - }) - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCallback = cb - cb(100, true) // scrolling - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + const firstTimer = v['_iosTouchEndTimerId'] + expect(firstTimer).not.toBeNull() + + // iOS fires no touch events during momentum, only scroll events. Each + // one must push the tail out — otherwise it would expire 150 ms into a + // fling that lasts a second or more, and the next resize would write + // scrollTop and kill the momentum (#884). + scroll(120, true) + expect(v['_iosJustTouchEnded']).toBe(true) + expect(v['_iosTouchEndTimerId']).not.toBe(firstTimer) - // Resize during scroll (no touch tracked here — pure scroll). v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) - // Scroll ends. Touch never started here, so the flush gate's - // !isScrolling && !_iosTouching && !_iosJustTouchEnded all hold. - scrollCallback!(100, false) + scroll(140, true) // still flinging: re-armed again, still deferred + expect(v['_iosJustTouchEnded']).toBe(true) + expect(scrollToFn).not.toHaveBeenCalled() + + // 150 ms after the last frame the tail expires and the delta flushes. + expireTouchTail() + expect(v['_iosJustTouchEnded']).toBe(false) + expect(v['_iosTouchEndTimerId']).toBeNull() expect(scrollToFn).toHaveBeenCalledTimes(1) expect(v['_iosDeferredAdjustment']).toBe(0) }) @@ -2134,13 +2064,12 @@ test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustme clearTimeout: globalThis.clearTimeout.bind(globalThis), } const { v, holder, makeEl, getScrollCallback } = - makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow, { - startScrolling: true, - }) + makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow) scrollToFn.mockClear() - // A resize above the viewport during the live scroll defers its + // A resize above the viewport during an active touch defers its // adjustment instead of writing scrollTop. + dispatchTouchEvent(holder.el, 'touchstart') v.resizeItem(0, 100) expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) @@ -2288,53 +2217,27 @@ test('Phase 2a: user-initiated scroll (large delta) is NOT reconciled to intende test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (negative)', () => { withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, - offsetHeight: 200, - ownerDocument: { - defaultView: { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }, - }, - }) - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - cb(100, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') - // Resize during scroll: defers + // Resize during the drag: defers v.resizeItem(0, 100) expect(v['_iosDeferredAdjustment']).toBe(50) - // User rubber-bands past the top: scrollTop becomes negative. - // Even though isScrolling=false now, the elastic-zone check blocks - // the flush so we don't snap-back to a clamped position. + // User rubber-bands past the top and lets go: scrollTop is negative + // when the gesture settles. The elastic-zone check blocks the flush so + // we don't snap back to a clamped position. el.scrollTop = -25 - scrollCb!(-25, false) + scroll(-25, true) + touch('touchend') + expireTouchTail() expect(scrollToFn).not.toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(50) // still deferred - // User releases, scroll snaps back in-bounds. Next scroll event - // should successfully flush. + // Bounce-back resolves in-bounds. The next scroll event flushes. el.scrollTop = 100 - scrollCb!(100, false) + scroll(100, false) expect(scrollToFn).toHaveBeenCalled() expect(v['_iosDeferredAdjustment']).toBe(0) }) @@ -2342,47 +2245,24 @@ test('Phase 2b: flush skipped when scrollTop is in elastic-overscroll zone (nega test('Phase 2b: flush skipped when scrollTop > scrollHeight-clientHeight (overscroll bottom)', () => { withFakeIOSUserAgent(() => { - const scrollToFn = vi.fn() - let scrollCb: ((o: number, s: boolean) => void) | null = null - const el = makeMockScrollElement({ - scrollTop: 100, - scrollLeft: 0, - scrollHeight: 500, - clientHeight: 200, // max valid scrollTop = 300 - offsetHeight: 200, - ownerDocument: { - defaultView: { - setTimeout: globalThis.setTimeout.bind(globalThis), - clearTimeout: globalThis.clearTimeout.bind(globalThis), - }, - }, - }) - const v = new Virtualizer({ - count: 10, - estimateSize: () => 50, - getScrollElement: () => el as any, - scrollToFn, - observeElementRect: () => {}, - observeElementOffset: (_inst, cb) => { - scrollCb = cb - cb(100, true) - return () => {} - }, - }) - v._willUpdate() - v['getMeasurements']() - scrollToFn.mockClear() + const { v, el, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() // clientHeight 200 → max valid scrollTop = 300 + touch('touchstart') v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(50) - // User pulls past the bottom: scrollTop becomes 350 (> max 300). + // User pulls past the bottom and lets go: scrollTop is 350 (> max 300) + // when the tail expires, so the flush is skipped. el.scrollTop = 350 - scrollCb!(350, false) + scroll(350, true) + touch('touchend') + expireTouchTail() expect(scrollToFn).not.toHaveBeenCalled() // Bounce-back resolves el.scrollTop = 300 - scrollCb!(300, false) + scroll(300, false) expect(scrollToFn).toHaveBeenCalled() }) }) @@ -2461,6 +2341,295 @@ test('Phase 2a: a second self-write replaces the intended target', () => { expect(v.scrollOffset).toBe(101) }) +// ─── #1250: programmatic scrolls are never deferred ───────────────────────── +// The deferral protects touch momentum (#884). A programmatic scroll has no +// momentum to protect, but its scrollTop write echoes as a scroll event that +// sets `isScrolling`, so gating on `isScrolling` deferred the compensation of +// a scrollToIndex landing past a paint: the list painted sagged by the +// accumulated delta and snapped a beat later. The gate is touch provenance +// only. + +test('#1250: a programmatic scrollToIndex landing compensates synchronously with no touch', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll } = makeIOSTouchVirtualizer() + + v.scrollToIndex(8) + expect(scrollToFn).toHaveBeenCalledTimes(1) + scroll(250, true) // the write's own scroll-event echo: isScrolling=true + expect(v.isScrolling).toBe(true) + expect(v['_iosJustTouchEnded']).toBe(false) // an echo never opens the tail + scrollToFn.mockClear() + + // Newly mounted rows measure. Item 0 sits above the fold, so its first + // measurement must compensate pre-paint — not defer past it. + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: a tap that never scrolls does not latch the deferral gate', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + // Tap a row: touchstart + touchend, no scroll events at all. + touch('touchstart') + touch('touchend') + expireTouchTail() + expect(v['_iosTouching']).toBe(false) + expect(v['_iosJustTouchEnded']).toBe(false) + expect(v['_iosTouchEndTimerId']).toBeNull() + + // Every piece of touch state is timer-bounded, so the next compensation + // is applied synchronously rather than deferred by a stuck gate. + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: a scrollToIndex issued from a tap handler lands compensated', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch } = makeIOSTouchVirtualizer() + + // Tapping a search result: its click handler calls scrollToIndex inside + // the post-touchend window. + touch('touchstart') + touch('touchend') + expect(v['_iosJustTouchEnded']).toBe(true) + + // The command's own write cancels any momentum, so it closes the tail. + v.scrollToIndex(8) + expect(v['_iosJustTouchEnded']).toBe(false) + expect(v['_iosTouchEndTimerId']).toBeNull() + scroll(250, true) // the echo must not re-open it + expect(v['_iosJustTouchEnded']).toBe(false) + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +test('#1250: an absolute command with the finger still down keeps deferring', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch } = makeIOSTouchVirtualizer() + + touch('touchstart') + v.scrollToIndex(8) // finger down: the user still owns the scroll + expect(v['_iosTouching']).toBe(true) + scrollToFn.mockClear() + + v.resizeItem(0, 100) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(50) + }) +}) + +test('#1250: a scrollToIndex with no target leaves the fling and its deferral untouched', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(120, true) // momentum + v.resizeItem(0, 100) + expect(v['_iosDeferredAdjustment']).toBe(50) + const timer = v['_iosTouchEndTimerId'] + expect(timer).not.toBeNull() + + // getOffsetForIndex finds no measurement for the index (e.g. called + // before the layout for new data exists), so scrollToIndex writes + // nothing. It must not drop the deferral or close the tail: the fling is + // still running and owns the scroll. + v.measurementsCache = [] + v.scrollToIndex(5) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(50) + expect(v['_iosJustTouchEnded']).toBe(true) + expect(v['_iosTouchEndTimerId']).toBe(timer) + + // A resize during the remaining momentum still defers rather than + // writing scrollTop into the fling. + v.resizeItem(1, 80) + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(80) + + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(v['_iosDeferredAdjustment']).toBe(0) + }) +}) + +test('iOS Phase 1: touchcancel releases the touch like touchend', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail } = makeIOSTouchVirtualizer() + + // A system gesture steals the touch: iOS fires touchcancel, never + // touchend. Without handling it `_iosTouching` would stay true forever. + touch('touchstart') + expect(v['_iosTouching']).toBe(true) + touch('touchcancel') + expect(v['_iosTouching']).toBe(false) + expect(v['_iosJustTouchEnded']).toBe(true) + + expireTouchTail() + expect(v['_iosJustTouchEnded']).toBe(false) + + v.resizeItem(0, 100) + expect(scrollToFn).toHaveBeenCalledTimes(1) + }) +}) + +// The end-anchor prepend sync in _willUpdate shares the gate. +function makeIOSPrependFixture() { + let keys = Array.from({ length: 10 }, (_, i) => `k-${i}`) + const fixture = makeIOSTouchVirtualizer( + {}, + { anchorTo: 'end', getItemKey: (i: number) => keys[i]! }, + ) + const prepend = () => { + keys = ['p-0', 'p-1', ...keys] + fixture.v.setOptions({ + ...fixture.v.options, + count: keys.length, + getItemKey: (i: number) => keys[i]!, + }) + fixture.v._willUpdate() + } + return { ...fixture, prepend } +} + +test('#1250: an end-anchored prepend during a programmatic scroll syncs the anchor immediately', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, prepend } = makeIOSPrependFixture() + v.scrollToIndex(2) + scroll(100, true) // echo: isScrolling=true, no touch + scrollToFn.mockClear() + + prepend() // 2 x 50px above the reader + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(200) + }) +}) + +test('#884: an end-anchored prepend during an active touch defers the anchor delta and applies it once', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, touch, expireTouchTail, prepend } = + makeIOSPrependFixture() + touch('touchstart') + + prepend() // 2 x 50px above the reader, finger still down + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(100) + // setOptions bumped scrollOffset eagerly for a sync that did not happen. + // The DOM is still at 100, so the tracked offset must say so too — + // otherwise the flush below would write 200 + 100 and land a whole + // prepend past the reader's row. + expect(v.scrollOffset).toBe(100) + + // A prepended row measures taller than its estimate while the finger is + // still down. It sits above where the viewport will land once the flush + // applies, so its estimate error is deferred on top of the anchor delta. + v.resizeItem(0, 80) // 50 → 80 + expect(scrollToFn).not.toHaveBeenCalled() + expect(v['_iosDeferredAdjustment']).toBe(130) + + // Gesture settles: the single flush lands exactly one (measured) prepend + // lower. + touch('touchend') + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, opts] = scrollToFn.mock.calls[0]! + expect(offset + opts.adjustments).toBe(230) + expect(v['_iosDeferredAdjustment']).toBe(0) + }) +}) + +// ─── iOS: a touch that starts right after a compensation write undoes it ──── +// iOS scrolls on a separate thread. When the flush (or any compensation write) +// lands and the user touches the screen within a frame, the scrolling thread +// still holds the pre-write position and wins: scrollTop reverts and the next +// scroll event reports the old offset. Seen on-device as a fling into the top +// whose history prepend flushes correctly and is then thrown away the moment +// the finger comes back down. The delta must go back into the deferred +// accumulator and replay once the gesture settles. + +test('iOS: a flush undone by a touch is re-deferred and replayed after the gesture', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(100, true) + v.resizeItem(0, 150) // 50 → 150 above the fold: +100 held for the fling + expect(v['_iosDeferredAdjustment']).toBe(100) + + // Tail expires: the flush writes 100 + 100. + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset, opts] = scrollToFn.mock.calls[0]! + expect(offset + opts.adjustments).toBe(200) + expect(v['_iosDeferredAdjustment']).toBe(0) + + // Finger comes down within a frame and WebKit reverts the write: the + // scroll event reports the pre-write position. + touch('touchstart') + scroll(100, true) + expect(v.scrollOffset).toBe(100) + expect(v['_iosDeferredAdjustment']).toBe(100) // put back, not lost + + // Gesture settles: the correction lands once more, exactly once. + scrollToFn.mockClear() + touch('touchend') + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset2, opts2] = scrollToFn.mock.calls[0]! + expect(offset2 + opts2.adjustments).toBe(200) + expect(v['_iosDeferredAdjustment']).toBe(0) + }) +}) + +test('iOS: the echo of a write that landed leaves nothing deferred', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(100, true) + v.resizeItem(0, 150) + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + + // The browser reads the write back (finger still up). + scroll(200, true) + expect(v.scrollOffset).toBe(200) + expect(v['_iosDeferredAdjustment']).toBe(0) + expect(v['_iosCompensationWrite']).toBeNull() + }) +}) + +test('iOS: a pan that starts after a landed write is not mistaken for a revert', () => { + withFakeIOSUserAgent(() => { + const { v, scrollToFn, scroll, touch, expireTouchTail } = + makeIOSTouchVirtualizer() + touch('touchstart') + touch('touchend') + scroll(100, true) + v.resizeItem(0, 150) + expireTouchTail() + expect(scrollToFn).toHaveBeenCalledTimes(1) + + // The write landed (200); the user touches and drags a few pixels. + touch('touchstart') + scroll(196, true) + expect(v.scrollOffset).toBe(196) + expect(v['_iosDeferredAdjustment']).toBe(0) + }) +}) + test('iOS Phase 1: non-iOS still does NOT install touch state machine', () => { // On non-iOS, touchend should not arm the grace timer. _resetIOSDetectionForTests()