Skip to content

Commit 35ad56d

Browse files
authored
fix(virtual-core): stop a touch fling for a command issued during it on Android too (#1347)
Chrome on Android carries a fling on from a programmatic write, so a scrollToIndex / scrollToOffset / scrollToEnd issued mid-fling was written and then overshot (Pixel 6, Android 16, Chrome 156: ~3,200px past the target, 3 runs of 3). iOS 27 already stops the fling before such a command (#1332); this does it everywhere a write does not end the fling, which is everywhere but iOS 26 and earlier. - IosGesture opens the post-touch tail on every platform. Corrections are still deferred through it on iOS only; `_canWriteScroll` and `_awaitingCommandReadBack` keep their isIOSWebKit() gates. - `_startScroll` stops the fling when momentumOverridesWrites(): !isIOSWebKit() || iosMomentumOverridesWrites(). - `_stopMomentum` puts the overflow back in the same task when hiding it changed the element's cross-axis client size: a classic scrollbar (a touchscreen laptop) would vanish and reflow the content for two frames. The command then waits for the tail, as without a style. The web trace snapshots move for seeds with touch steps: a command inside a drag's tail now goes through the stop, which in the simulator (no style) falls back to the tail, as on ios27.
1 parent 979c1b4 commit 35ad56d

7 files changed

Lines changed: 213 additions & 93 deletions

File tree

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix(virtual-core): stop a touch fling for a command issued during it on Android too

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

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,7 @@ import {
99
flingCaught,
1010
flingSpedUp,
1111
} from './scroll-classifier'
12-
import {
13-
IosGesture,
14-
iosMomentumOverridesWrites,
15-
isIOSWebKit,
16-
} from './ios-gesture'
12+
import { IosGesture, isIOSWebKit, momentumOverridesWrites } from './ios-gesture'
1713
import type { ScrollPhase, ScrollSample } from './scroll-classifier'
1814

1915
export { _resetIOSDetectionForTests } from './ios-gesture'
@@ -2244,13 +2240,13 @@ export class Virtualizer<
22442240
return null
22452241
}
22462242

2247-
// Stop a running iOS fling, then call `done`: the momentum does not
2248-
// survive the element losing its overflow for a frame. Hidden and
2249-
// restored within one task, iOS never sees the change and the fling
2250-
// carries on (27.0.1 iPhone); hidden for two frames, it stops at once.
2251-
// Nothing moves on screen. A second call while one is in progress waits
2252-
// for the same frames. The app's inline value comes back, unless the app
2253-
// set a new one meanwhile; `done` is then not called.
2243+
// Stop a running fling, then call `done`: the momentum does not survive
2244+
// the element losing its overflow for a frame. Hidden and restored within
2245+
// one task, iOS never sees the change and the fling carries on (27.0.1
2246+
// iPhone); hidden for two frames, it stops at once, there and in Chrome on
2247+
// Android. Nothing moves on screen. A second call while one is in progress
2248+
// waits for the same frames. The app's inline value comes back, unless the
2249+
// app set a new one meanwhile; `done` is then not called.
22542250
private _momentumStop: { restore: () => boolean; done: () => void } | null =
22552251
null
22562252
private _stopMomentum = (done: () => void) => {
@@ -2270,7 +2266,17 @@ export class Virtualizer<
22702266
const property = this.options.horizontal ? 'overflow-x' : 'overflow-y'
22712267
const value = style.getPropertyValue(property)
22722268
const priority = style.getPropertyPriority(property)
2269+
const crossSize = () =>
2270+
this.options.horizontal ? scroller.clientHeight : scroller.clientWidth
2271+
const crossSizeBefore = crossSize()
22732272
style.setProperty(property, 'hidden', 'important')
2273+
// A classic scrollbar takes layout space, so hiding it would reflow the
2274+
// content for two frames (a touchscreen laptop). Put it back before
2275+
// anything paints and leave the command to the tail, as without a style.
2276+
if (crossSize() !== crossSizeBefore) {
2277+
style.setProperty(property, value, priority)
2278+
return
2279+
}
22742280
const stop = {
22752281
// False when the app replaced the hidden value: the fling may not
22762282
// have stopped.
@@ -2490,11 +2496,13 @@ export class Virtualizer<
24902496

24912497
const tail = this._iosGesture.tail()
24922498
if (kind === 'absolute' && tail !== null) {
2493-
if (tail === 'fling' && iosMomentumOverridesWrites()) {
2494-
// iOS 27: a write now shows for one frame and the fling carries on
2495-
// along its own path, so the command would be lost (#1307, step 3).
2496-
// Stop the fling and write two frames later. Otherwise (no style to
2497-
// change, a finger down, a bounce) the tail stays open and
2499+
if (tail === 'fling' && momentumOverridesWrites()) {
2500+
// A write now would not end the fling: on iOS 27 it shows for one
2501+
// frame and the fling carries on along its own path (#1307, step 3);
2502+
// in Chrome on Android the fling carries on from the written
2503+
// position. The command would be lost or overshot. Stop the fling
2504+
// and write two frames later. Otherwise (no style to change, a
2505+
// classic scrollbar, a finger down, a bounce) the tail stays open and
24982506
// the command is written when it closes, ~150ms after the last
24992507
// fling frame. The user taking over before the write (a drag, a
25002508
// second flick, a finger stopping the fling) cancels the command

‎packages/virtual-core/src/ios-gesture.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,17 @@ export const iosMomentumOverridesWrites = (): boolean => {
3232
CSS.supports('overflow-anchor: auto'))
3333
}
3434

35+
// Whether a touch fling survives a scrollTop write, so a command issued
36+
// mid-fling has to stop the fling before it writes. Only iOS 26 and earlier
37+
// stop the momentum on the write itself. Chrome on Android carries the fling
38+
// on from the written position: on a Pixel 6 (Android 16, Chrome 156) a
39+
// `scrollToIndex` issued 150ms into a fling wrote its target, then went on
40+
// ~3,200px past it, 3 runs of 3. Other non-iOS engines are not measured; on
41+
// one whose write does stop the fling, stopping it first costs the two
42+
// frames of the stop.
43+
export const momentumOverridesWrites = (): boolean =>
44+
!isIOSWebKit() || iosMomentumOverridesWrites()
45+
3546
// Test hook: reset the iOS detection caches. Not exported.
3647
export const _resetIOSDetectionForTests = () => {
3748
_isIOSResult = undefined
@@ -52,9 +63,11 @@ const touchListenerOptions = {
5263
// the whole gesture: `down` from touchstart to touchend/touchcancel, then
5364
// `tail` while momentum runs. iOS fires no touch events during momentum, so
5465
// every scroll event re-arms the tail and it closes ~150ms after the last
55-
// one. Null between gestures, and after touchend on non-iOS, which arms no
56-
// tail. The tail record owns its timer: every replacement of the state goes
57-
// through `end`, which clears it, so a live timer always has its record.
66+
// one. Null between gestures. The tail is tracked on every platform, but
67+
// only iOS defers anything through it; elsewhere it only tells a command
68+
// that a fling may be running (`momentumOverridesWrites`). The tail record
69+
// owns its timer: every replacement of the state goes through `end`, which
70+
// clears it, so a live timer always has its record.
5871
//
5972
// `moved`: the content moved under or after the finger other than by our
6073
// own write's read-back. While `down`, a scroll event that changed the
@@ -108,9 +121,9 @@ export class IosGesture {
108121
// read-back will come. A resting finger does not: the write lands.
109122
dragging = () => this._touch?.phase === 'down' && this._touch.moved
110123

111-
// The open tail, if any: after a tap, or after a drag or fling. On iOS 27
112-
// a fling's momentum overrides a write within a frame (see
113-
// `iosMomentumOverridesWrites`).
124+
// The open tail, if any: after a tap, or after a drag or fling. Except on
125+
// iOS 26 and earlier, a fling's momentum outlasts a write (see
126+
// `momentumOverridesWrites`).
114127
tail = (): 'tap' | 'fling' | null =>
115128
this._touch?.phase === 'tail' ? (this._touch.moved ? 'fling' : 'tap') : null
116129

@@ -203,10 +216,10 @@ export class IosGesture {
203216
}
204217

205218
// (Re)open the tail after the finger lifts, closing ~150ms after the last
206-
// touchend or momentum frame. On non-iOS there is nothing to protect.
219+
// touchend or momentum frame.
207220
private _openTail = (moved: boolean, awaitingFling: boolean) => {
208221
const win = this.host.getWindow()
209-
if (!isIOSWebKit() || win == null) return
222+
if (win == null) return
210223
this.end()
211224
const tail = {
212225
phase: 'tail' as const,

0 commit comments

Comments
 (0)