Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/ios-fling-stop.md
Original file line number Diff line number Diff line change
@@ -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.
88 changes: 83 additions & 5 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
},
Comment thread
piecyk marked this conversation as resolved.
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) => {
Expand Down Expand Up @@ -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(),
Expand Down
143 changes: 143 additions & 0 deletions packages/virtual-core/tests/ios-fling-wait.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' }))
Expand Down
Loading