diff --git a/.changeset/ios-fling-stop.md b/.changeset/ios-fling-stop.md new file mode 100644 index 000000000..43d1ae045 --- /dev/null +++ b/.changeset/ios-fling-stop.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): on iOS 27 and later, a scroll command issued during a fling takes effect at once + +A `scrollToIndex`, `scrollToOffset` or `scrollToEnd` issued during a fling waited for the fling to end, so a button outside the list (such as "jump to latest") did nothing until the list stopped. The command now stops the fling by hiding the scroll element's overflow for two frames, then restores the element's own inline value and writes. The user taking over before the write still cancels the command. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 275f0f653..0225b124a 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -995,6 +995,10 @@ export class Virtualizer< this.rafId = null } this.scrollState = null + // The element is let go of: give it back its overflow now. A stop the + // next element needs starts afresh. + this._momentumStop?.restore() + this._momentumStop = null // The debounce cancelled above is the only thing that writes `isScrolling` // back to false, so a cleanup inside the reset window would strand it, and // the direction derived from it, as true. That matters because `cleanup` @@ -2163,6 +2167,60 @@ export class Virtualizer< return null } + // Stop a running iOS fling, then call `done`: the momentum does not + // survive the element losing its overflow for a frame. Hidden and + // restored within one task, iOS never sees the change and the fling + // carries on (27.0.1 iPhone); hidden for two frames, it stops at once. + // Nothing moves on screen. A second call while one is in progress waits + // for the same frames. The app's inline value comes back, unless the app + // set a new one meanwhile; `done` is then not called. + private _momentumStop: { restore: () => boolean; done: () => void } | null = + null + private _stopMomentum = (done: () => void) => { + const el = this.scrollElement + const win = this.targetWindow + if (el === null || win === null) return + const scroller = ( + 'document' in el ? el.document.scrollingElement : el + ) as HTMLElement | null + // A test double may have no style to change. + if (!scroller?.style) return + if (this._momentumStop) { + this._momentumStop.done = done + return + } + const { style } = scroller + const property = this.options.horizontal ? 'overflow-x' : 'overflow-y' + const value = style.getPropertyValue(property) + const priority = style.getPropertyPriority(property) + style.setProperty(property, 'hidden', 'important') + const stop = { + // False when the app replaced the hidden value: the fling may not + // have stopped. + restore: () => { + if ( + style.getPropertyValue(property) !== 'hidden' || + style.getPropertyPriority(property) !== 'important' + ) { + return false + } + style.setProperty(property, value, priority) + return true + }, + done, + } + this._momentumStop = stop + win.requestAnimationFrame(() => + win.requestAnimationFrame(() => { + // Already restored by `cleanup`, which also drops its command. + if (this._momentumStop !== stop) return + this._momentumStop = null + // The command waits for the tail, as without the stop. + if (stop.restore()) stop.done() + }), + ) + } + // A compensation write past the element's current max (the sizer has not // grown yet) is clamped by the browser; record it for the retry (#1258). private _clampedBy = (target: number) => { @@ -2344,12 +2402,32 @@ export class Virtualizer< if (tail === 'fling' && iosMomentumOverridesWrites()) { // iOS 27: a write now shows for one frame and the fling carries on // along its own path, so the command would be lost (#1307, step 3). - // Leave the tail open and write when it closes, ~150ms after the - // last fling frame; the user taking over before then (a drag, a + // Stop the fling and write two frames later. Otherwise (no style to + // change, a finger down, a bounce) the tail stays open and + // the command is written when it closes, ~150ms after the last + // fling frame. The user taking over before the write (a drag, a // second flick, a finger stopping the fling) cancels the command - // (see the scroll handler and `onTailClosed`). A relative command (`scrollBy`) - // is not postponed: its target is taken from the position at the - // call, which the fling leaves behind either way. + // (see the scroll handler and `onTailClosed`). A relative command + // (`scrollBy`) is not postponed: its target is taken from the + // position at the call, which the fling leaves behind either way. + this._stopMomentum(() => { + const state = this.scrollState + if ( + state?.phase !== 'queued' || + // A finger came down: it owns the scroll until it lifts. + this._iosGesture.tail() !== 'fling' || + // In a bounce WebKit can drop the write (#1287): the tail + // decides, as without the stop. (Not `flingCaught`: our stop + // also ends the fling at speed, so a slow frame would read as + // a catch and the tail would drop the command.) + this._reportedOverscroll + ) { + return + } + this._iosGesture.end() + this._flushHoldIfReady(true) + this._writeAwaitingCommand() + }) this.scrollState = { ...target, startedAt: this.now(), diff --git a/packages/virtual-core/tests/ios-fling-wait.test.ts b/packages/virtual-core/tests/ios-fling-wait.test.ts index e90c75075..4254a6ce7 100644 --- a/packages/virtual-core/tests/ios-fling-wait.test.ts +++ b/packages/virtual-core/tests/ios-fling-wait.test.ts @@ -47,6 +47,149 @@ test('iOS 27: a scrollToIndex mid-fling waits for the tail, and the fling does n expect(h.v.scrollState).toBeNull() }) +// A fling whose element records its inline overflow-y, set by the app to +// `auto !important`. +function flingWithStyle() { + const h = flingOnIOS27() + const props = new Map([['overflow-y', ['auto', 'important']]]) + Object.assign(h.el, { + style: { + getPropertyValue: (name: string) => props.get(name)?.[0] ?? '', + getPropertyPriority: (name: string) => props.get(name)?.[1] ?? '', + setProperty: (name: string, value: string, priority = '') => { + props.set(name, [value, priority]) + }, + }, + }) + return { ...h, overflow: () => props.get('overflow-y')!.join(' ') } +} + +test('iOS 27: a command mid-fling stops the fling and writes two frames later', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd({ behavior: 'smooth' }) + // Hidden through a frame: the fling stops. Restored and written after + // the next one, without waiting for the tail. + expect(h.overflow()).toBe('hidden important') + h.frame() + expect(h.overflow()).toBe('hidden important') + expect(h.writes).toEqual([]) + h.frame() + }) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a second command while the fling stops leaves the overflow as the app set it', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToIndex(10, { align: 'start' }) + h.frame() + h.v.scrollToEnd() + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('auto important') + // The later command wins. + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling caught and dragged back while it stops cancels the command', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.scroll(true, 1300) + h.scroll(true, 1350) + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([]) + expect(h.v.scrollState).toBeNull() +}) + +test('iOS 27: an overflow the app sets while the fling stops is kept', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.el.style.setProperty('overflow-y', 'scroll', '') + h.frame() + h.frame() + }) + expect(h.overflow()).toBe('scroll ') + // The fling may still run: the tail writes when it closes. + expect(h.writes).toEqual([]) + h.run(() => h.scroll(false)) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: an element let go of while the fling stops gets its overflow back at once', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.v['cleanup']() + expect(h.overflow()).toBe('auto important') + h.frame() + h.frame() + }) + // Its command went with it. + expect(h.writes).toEqual([]) +}) + +test('iOS 27: a finger down while the fling stops keeps its gesture, and the command waits for it to lift', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.frame() + }) + h.touch('touchstart') + h.run(() => h.frame()) + expect(h.overflow()).toBe('auto important') + expect(h.writes).toEqual([]) + // Still dragging: corrections stay held under it. + expect(h.v['_iosGesture'].touch?.phase).toBe('down') + h.touch('touchend') + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling in a bounce when it stops is written when the tail closes', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + h.scroll(true, -40) + h.frame() + h.frame() + }) + // WebKit can drop a write mid-bounce: wait for the tail. + expect(h.writes).toEqual([]) + h.run(() => { + h.scroll(true, 0) + h.scroll(false) + }) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + +test('iOS 27: a fling the stop ends at speed over a slow frame is still written', () => { + const h = flingWithStyle() + h.run(() => { + h.v.scrollToEnd() + // Fling frames 45ms apart on a busy main thread, then the stop holds: + // the events end at speed, which is not a finger's catch. + h.advance(5) + h.scroll(true, 1480) + h.frame() + h.advance(45) + h.scroll(true, 1390) + h.frame() + }) + expect(h.writes).toEqual([4800]) + h.expireTail() + expect(h.writes).toEqual([4800]) +}) + test('iOS 26: a scrollToIndex mid-fling is written at once, as before', () => { const h = flingOnIOS27(false) h.run(() => h.v.scrollToIndex(90, { align: 'start' }))