fix(virtual-core): catch late taps on a fling and reopen a tail its f… · TanStack/virtual@75b0492 · GitHub
Skip to content

Commit 75b0492

Browse files
piecykclaude
andcommitted
fix(virtual-core): catch late taps on a fling and reopen a tail its first frame missed
From phcorp's re-run of c6188ae (iPhone 27.0.1 in Safari, Chrome and Firefox; 27.0 simulator), #1307: - A tap late in a fling stopped it at 0.21–0.35px/ms, under `flingCaught`'s 0.5px/ms, so the waiting command landed. The threshold is now 0.05px/ms: every natural end recorded fades to 0.015px/ms or less. When the tail waits again because the element moved with no scroll event, that position is recorded as a sample, so the speed read when it stops is the silent one. - The first frame of a fling can come 150–250ms after touchend when the main thread is busy, after the tail has closed; the fling then ran with no tail, and a command issued in it was written at once and overridden. Within 1s of a drag whose fling has not reported, its first scroll event now reopens the tail. Only after a drag: other scroll events with no touch before them still open none (#1250). Snapshot unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c6188ae commit 75b0492

4 files changed

Lines changed: 185 additions & 7 deletions

File tree

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

Lines changed: 30 additions & 0 deletions

‎packages/virtual-core/src/scroll-classifier.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -197,16 +197,17 @@ export function flingSpedUp(samples: ReadonlyArray<ScrollSample>): boolean {
197197
}
198198

199199
// Whether a fling that sent its last scroll event was still going at
200-
// 0.5px/ms or more over its last 40ms: a finger caught it. A fling that ends
201-
// on its own fades to about 0.01px/ms first; a tap stopped one at
202-
// 0.83–1.43px/ms (iPhone, 27.0.1, #1307). The finger stays down with no
203-
// touch events, and iOS undoes a write made under it.
200+
// 0.05px/ms or more over its last 40ms: a finger caught it. A fling that
201+
// ends on its own fades to at most 0.015px/ms first; taps stopped one at
202+
// 0.21–1.43px/ms (iPhones on 27.0.1, #1307). The finger stays down with no
203+
// touch events, and iOS undoes a write made under it. A tap in the last
204+
// pixels of a fling reads as its end.
204205
export function flingCaught(samples: ReadonlyArray<ScrollSample>): boolean {
205206
const last = samples[samples.length - 1]
206207
if (last === undefined) return false
207208
let j = samples.length - 2
208209
while (j >= 0 && last.t - samples[j]!.t < FLING_WINDOW_MS) j--
209210
if (j < 0) return false
210211
const from = samples[j]!
211-
return Math.abs(last.offset - from.offset) / (last.t - from.t) >= 0.5
212+
return Math.abs(last.offset - from.offset) / (last.t - from.t) >= 0.05
212213
}

‎packages/virtual-core/tests/index.test.ts‎

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2968,6 +2968,45 @@ test('iOS 27: a command issued after a second flick waits for the new fling', ()
29682968
expect(h.writes).toEqual([4000])
29692969
})
29702970

2971+
test('iOS 27: a tap late in the fling (0.21px/ms) cancels the waiting command', () => {
2972+
const h = flingOnIOS27() // heading up, passing 1500
2973+
h.run(() => {
2974+
h.v.scrollToIndex(90, { align: 'start' })
2975+
// ~3.4px a frame when the finger lands (another iPhone, #1307).
2976+
for (const top of [1497, 1493, 1490, 1487, 1483, 1480]) {
2977+
h.advance(16)
2978+
h.scroll(true, top)
2979+
}
2980+
h.scroll(false)
2981+
})
2982+
h.expireTail()
2983+
expect(h.writes).toEqual([])
2984+
expect(h.v.scrollState).toBeNull()
2985+
})
2986+
2987+
test('iOS 27: a fling that went on with no scroll events is not read as caught', () => {
2988+
const h = flingOnIOS27() // heading up, passing 1500
2989+
h.run(() => {
2990+
h.v.scrollToIndex(90, { align: 'start' })
2991+
// 0.25px/ms when the events stop...
2992+
for (const top of [1496, 1492, 1488, 1484]) {
2993+
h.advance(16)
2994+
h.scroll(true, top)
2995+
}
2996+
})
2997+
// ...but the element keeps moving, fading out, with none.
2998+
for (const top of [1478, 1476]) {
2999+
h.advance(150)
3000+
h.el.scrollTop = top
3001+
h.expireTail()
3002+
expect(h.writes).toEqual([])
3003+
}
3004+
// It stopped: the speed it stopped at is the silent one, not 0.25px/ms.
3005+
h.advance(150)
3006+
h.expireTail()
3007+
expect(h.writes).toEqual([4500])
3008+
})
3009+
29713010
test('iOS 27: the waiting command is not written while the fling still moves between scroll events', () => {
29723011
const h = flingOnIOS27()
29733012
h.run(() => {
@@ -3310,6 +3349,91 @@ test('iOS Phase 1: momentum scroll events re-arm the post-touchend tail until th
33103349
})
33113350
})
33123351

3352+
// The first frame of a fling can come 150–250ms after touchend when the main
3353+
// thread is busy (27.0 simulator, #1307): after the tail has closed.
3354+
function dragThenLateFling({ ios27 = false } = {}) {
3355+
const h = makeDeferralHarness({ start: 2000, ios27 })
3356+
h.touch('touchstart')
3357+
h.run(() => h.scroll(true, 1990))
3358+
h.touch('touchend')
3359+
h.advance(150)
3360+
h.expireTail() // no momentum frame yet
3361+
h.writes.length = 0
3362+
return h
3363+
}
3364+
3365+
test('iOS: a fling whose first frame comes after the tail closed reopens it', () => {
3366+
const h = dragThenLateFling()
3367+
h.advance(100)
3368+
h.run(() => {
3369+
h.scroll(true, 1960)
3370+
h.v.resizeItem(5, 100)
3371+
})
3372+
// Held: a write would stop the fling (26) or be overridden (27).
3373+
expect(h.writes).toEqual([])
3374+
h.run(() => {
3375+
h.scroll(true, 1900)
3376+
h.scroll(false)
3377+
})
3378+
h.expireTail()
3379+
expect(h.writes).toEqual([1950])
3380+
})
3381+
3382+
test('iOS 27: a command mid-fling waits when the first frame came late', () => {
3383+
const h = dragThenLateFling({ ios27: true })
3384+
h.advance(100)
3385+
h.run(() => {
3386+
h.scroll(true, 1960)
3387+
h.v.scrollToIndex(90, { align: 'start' })
3388+
})
3389+
expect(h.writes).toEqual([])
3390+
expect(h.v.scrollState?.phase).toBe('queued')
3391+
})
3392+
3393+
test('iOS: a scroll event over 1s after the tail closed does not reopen it', () => {
3394+
const h = dragThenLateFling()
3395+
h.advance(1100)
3396+
h.run(() => {
3397+
h.scroll(true, 1960)
3398+
h.v.resizeItem(5, 100)
3399+
})
3400+
expect(h.writes).toEqual([2010])
3401+
})
3402+
3403+
test('iOS: after a fling that reported in time, a later scroll event opens no tail', () => {
3404+
const h = makeDeferralHarness({ start: 2000 })
3405+
h.touch('touchstart')
3406+
h.run(() => h.scroll(true, 1990))
3407+
h.touch('touchend')
3408+
h.run(() => {
3409+
h.scroll(true, 1900)
3410+
h.scroll(false)
3411+
})
3412+
h.expireTail()
3413+
h.writes.length = 0
3414+
// Say the app scrolls the element itself.
3415+
h.advance(100)
3416+
h.run(() => {
3417+
h.scroll(true, 1800)
3418+
h.v.resizeItem(5, 100)
3419+
})
3420+
expect(h.writes).toEqual([1850])
3421+
})
3422+
3423+
test('iOS: a tap opens no late-fling window', () => {
3424+
const h = makeDeferralHarness({ start: 2000 })
3425+
h.touch('touchstart')
3426+
h.touch('touchend')
3427+
h.expireTail()
3428+
h.writes.length = 0
3429+
h.advance(100)
3430+
h.run(() => {
3431+
h.scroll(true, 1960)
3432+
h.v.resizeItem(5, 100)
3433+
})
3434+
expect(h.writes).toEqual([2010])
3435+
})
3436+
33133437
test('iOS Phase 1: new touchstart during grace window cancels pending flush timer', () => {
33143438
withFakeIOSUserAgent(() => {
33153439
const scrollToFn = vi.fn()

‎packages/virtual-core/tests/scroll-classifier.test.ts‎

Lines changed: 25 additions & 2 deletions

0 commit comments

Comments
 (0)