Skip to content

Commit 6789f7a

Browse files
authored
fix(virtual-core): stop an iOS fling for a command issued during it (#1332)
1 parent 56b2a63 commit 6789f7a

3 files changed

Lines changed: 233 additions & 5 deletions

File tree

‎.changeset/ios-fling-stop.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix(virtual-core): on iOS 27 and later, a scroll command issued during a fling takes effect at once
6+
7+
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.

‎packages/virtual-core/src/index.ts‎

Lines changed: 83 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,10 @@ export class Virtualizer<
10041004
this.rafId = null
10051005
}
10061006
this.scrollState = null
1007+
// The element is let go of: give it back its overflow now. A stop the
1008+
// next element needs starts afresh.
1009+
this._momentumStop?.restore()
1010+
this._momentumStop = null
10071011
// The debounce cancelled above is the only thing that writes `isScrolling`
10081012
// back to false, so a cleanup inside the reset window would strand it, and
10091013
// the direction derived from it, as true. That matters because `cleanup`
@@ -2203,6 +2207,60 @@ export class Virtualizer<
22032207
return null
22042208
}
22052209

2210+
// Stop a running iOS fling, then call `done`: the momentum does not
2211+
// survive the element losing its overflow for a frame. Hidden and
2212+
// restored within one task, iOS never sees the change and the fling
2213+
// carries on (27.0.1 iPhone); hidden for two frames, it stops at once.
2214+
// Nothing moves on screen. A second call while one is in progress waits
2215+
// for the same frames. The app's inline value comes back, unless the app
2216+
// set a new one meanwhile; `done` is then not called.
2217+
private _momentumStop: { restore: () => boolean; done: () => void } | null =
2218+
null
2219+
private _stopMomentum = (done: () => void) => {
2220+
const el = this.scrollElement
2221+
const win = this.targetWindow
2222+
if (el === null || win === null) return
2223+
const scroller = (
2224+
'document' in el ? el.document.scrollingElement : el
2225+
) as HTMLElement | null
2226+
// A test double may have no style to change.
2227+
if (!scroller?.style) return
2228+
if (this._momentumStop) {
2229+
this._momentumStop.done = done
2230+
return
2231+
}
2232+
const { style } = scroller
2233+
const property = this.options.horizontal ? 'overflow-x' : 'overflow-y'
2234+
const value = style.getPropertyValue(property)
2235+
const priority = style.getPropertyPriority(property)
2236+
style.setProperty(property, 'hidden', 'important')
2237+
const stop = {
2238+
// False when the app replaced the hidden value: the fling may not
2239+
// have stopped.
2240+
restore: () => {
2241+
if (
2242+
style.getPropertyValue(property) !== 'hidden' ||
2243+
style.getPropertyPriority(property) !== 'important'
2244+
) {
2245+
return false
2246+
}
2247+
style.setProperty(property, value, priority)
2248+
return true
2249+
},
2250+
done,
2251+
}
2252+
this._momentumStop = stop
2253+
win.requestAnimationFrame(() =>
2254+
win.requestAnimationFrame(() => {
2255+
// Already restored by `cleanup`, which also drops its command.
2256+
if (this._momentumStop !== stop) return
2257+
this._momentumStop = null
2258+
// The command waits for the tail, as without the stop.
2259+
if (stop.restore()) stop.done()
2260+
}),
2261+
)
2262+
}
2263+
22062264
// A compensation write past the element's current max (the sizer has not
22072265
// grown yet) is clamped by the browser; record it for the retry (#1258).
22082266
private _clampedBy = (target: number) => {
@@ -2384,12 +2442,32 @@ export class Virtualizer<
23842442
if (tail === 'fling' && iosMomentumOverridesWrites()) {
23852443
// iOS 27: a write now shows for one frame and the fling carries on
23862444
// along its own path, so the command would be lost (#1307, step 3).
2387-
// Leave the tail open and write when it closes, ~150ms after the
2388-
// last fling frame; the user taking over before then (a drag, a
2445+
// Stop the fling and write two frames later. Otherwise (no style to
2446+
// change, a finger down, a bounce) the tail stays open and
2447+
// the command is written when it closes, ~150ms after the last
2448+
// fling frame. The user taking over before the write (a drag, a
23892449
// second flick, a finger stopping the fling) cancels the command
2390-
// (see the scroll handler and `onTailClosed`). A relative command (`scrollBy`)
2391-
// is not postponed: its target is taken from the position at the
2392-
// call, which the fling leaves behind either way.
2450+
// (see the scroll handler and `onTailClosed`). A relative command
2451+
// (`scrollBy`) is not postponed: its target is taken from the
2452+
// position at the call, which the fling leaves behind either way.
2453+
this._stopMomentum(() => {
2454+
const state = this.scrollState
2455+
if (
2456+
state?.phase !== 'queued' ||
2457+
// A finger came down: it owns the scroll until it lifts.
2458+
this._iosGesture.tail() !== 'fling' ||
2459+
// In a bounce WebKit can drop the write (#1287): the tail
2460+
// decides, as without the stop. (Not `flingCaught`: our stop
2461+
// also ends the fling at speed, so a slow frame would read as
2462+
// a catch and the tail would drop the command.)
2463+
this._reportedOverscroll
2464+
) {
2465+
return
2466+
}
2467+
this._iosGesture.end()
2468+
this._flushHoldIfReady(true)
2469+
this._writeAwaitingCommand()
2470+
})
23932471
this.scrollState = {
23942472
...target,
23952473
startedAt: this.now(),

‎packages/virtual-core/tests/ios-fling-wait.test.ts‎

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,149 @@ test('iOS 27: a scrollToIndex mid-fling waits for the tail, and the fling does n
4747
expect(h.v.scrollState).toBeNull()
4848
})
4949

50+
// A fling whose element records its inline overflow-y, set by the app to
51+
// `auto !important`.
52+
function flingWithStyle() {
53+
const h = flingOnIOS27()
54+
const props = new Map([['overflow-y', ['auto', 'important']]])
55+
Object.assign(h.el, {
56+
style: {
57+
getPropertyValue: (name: string) => props.get(name)?.[0] ?? '',
58+
getPropertyPriority: (name: string) => props.get(name)?.[1] ?? '',
59+
setProperty: (name: string, value: string, priority = '') => {
60+
props.set(name, [value, priority])
61+
},
62+
},
63+
})
64+
return { ...h, overflow: () => props.get('overflow-y')!.join(' ') }
65+
}
66+
67+
test('iOS 27: a command mid-fling stops the fling and writes two frames later', () => {
68+
const h = flingWithStyle()
69+
h.run(() => {
70+
h.v.scrollToEnd({ behavior: 'smooth' })
71+
// Hidden through a frame: the fling stops. Restored and written after
72+
// the next one, without waiting for the tail.
73+
expect(h.overflow()).toBe('hidden important')
74+
h.frame()
75+
expect(h.overflow()).toBe('hidden important')
76+
expect(h.writes).toEqual([])
77+
h.frame()
78+
})
79+
expect(h.overflow()).toBe('auto important')
80+
expect(h.writes).toEqual([4800])
81+
})
82+
83+
test('iOS 27: a second command while the fling stops leaves the overflow as the app set it', () => {
84+
const h = flingWithStyle()
85+
h.run(() => {
86+
h.v.scrollToIndex(10, { align: 'start' })
87+
h.frame()
88+
h.v.scrollToEnd()
89+
h.frame()
90+
h.frame()
91+
})
92+
expect(h.overflow()).toBe('auto important')
93+
// The later command wins.
94+
expect(h.writes).toEqual([4800])
95+
})
96+
97+
test('iOS 27: a fling caught and dragged back while it stops cancels the command', () => {
98+
const h = flingWithStyle()
99+
h.run(() => {
100+
h.v.scrollToEnd()
101+
h.scroll(true, 1300)
102+
h.scroll(true, 1350)
103+
h.frame()
104+
h.frame()
105+
})
106+
expect(h.overflow()).toBe('auto important')
107+
expect(h.writes).toEqual([])
108+
expect(h.v.scrollState).toBeNull()
109+
})
110+
111+
test('iOS 27: an overflow the app sets while the fling stops is kept', () => {
112+
const h = flingWithStyle()
113+
h.run(() => {
114+
h.v.scrollToEnd()
115+
h.el.style.setProperty('overflow-y', 'scroll', '')
116+
h.frame()
117+
h.frame()
118+
})
119+
expect(h.overflow()).toBe('scroll ')
120+
// The fling may still run: the tail writes when it closes.
121+
expect(h.writes).toEqual([])
122+
h.run(() => h.scroll(false))
123+
h.expireTail()
124+
expect(h.writes).toEqual([4800])
125+
})
126+
127+
test('iOS 27: an element let go of while the fling stops gets its overflow back at once', () => {
128+
const h = flingWithStyle()
129+
h.run(() => {
130+
h.v.scrollToEnd()
131+
h.v['cleanup']()
132+
expect(h.overflow()).toBe('auto important')
133+
h.frame()
134+
h.frame()
135+
})
136+
// Its command went with it.
137+
expect(h.writes).toEqual([])
138+
})
139+
140+
test('iOS 27: a finger down while the fling stops keeps its gesture, and the command waits for it to lift', () => {
141+
const h = flingWithStyle()
142+
h.run(() => {
143+
h.v.scrollToEnd()
144+
h.frame()
145+
})
146+
h.touch('touchstart')
147+
h.run(() => h.frame())
148+
expect(h.overflow()).toBe('auto important')
149+
expect(h.writes).toEqual([])
150+
// Still dragging: corrections stay held under it.
151+
expect(h.v['_iosGesture'].touch?.phase).toBe('down')
152+
h.touch('touchend')
153+
h.expireTail()
154+
expect(h.writes).toEqual([4800])
155+
})
156+
157+
test('iOS 27: a fling in a bounce when it stops is written when the tail closes', () => {
158+
const h = flingWithStyle()
159+
h.run(() => {
160+
h.v.scrollToEnd()
161+
h.scroll(true, -40)
162+
h.frame()
163+
h.frame()
164+
})
165+
// WebKit can drop a write mid-bounce: wait for the tail.
166+
expect(h.writes).toEqual([])
167+
h.run(() => {
168+
h.scroll(true, 0)
169+
h.scroll(false)
170+
})
171+
h.expireTail()
172+
expect(h.writes).toEqual([4800])
173+
})
174+
175+
test('iOS 27: a fling the stop ends at speed over a slow frame is still written', () => {
176+
const h = flingWithStyle()
177+
h.run(() => {
178+
h.v.scrollToEnd()
179+
// Fling frames 45ms apart on a busy main thread, then the stop holds:
180+
// the events end at speed, which is not a finger's catch.
181+
h.advance(5)
182+
h.scroll(true, 1480)
183+
h.frame()
184+
h.advance(45)
185+
h.scroll(true, 1390)
186+
h.frame()
187+
})
188+
expect(h.writes).toEqual([4800])
189+
h.expireTail()
190+
expect(h.writes).toEqual([4800])
191+
})
192+
50193
test('iOS 26: a scrollToIndex mid-fling is written at once, as before', () => {
51194
const h = flingOnIOS27(false)
52195
h.run(() => h.v.scrollToIndex(90, { align: 'start' }))

0 commit comments

Comments
 (0)