Skip to content
Open
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
9 changes: 9 additions & 0 deletions .changeset/ios-touch-provenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@tanstack/virtual-core': patch
---

Gate the iOS scroll-adjustment deferral on touch provenance instead of `isScrolling`. The deferral exists to keep `scrollTop` writes from cancelling touch momentum (#884), but `isScrolling` is set by any scroll event, including the echo of the virtualizer's own programmatic write, so a `scrollToIndex` / `scrollToOffset` landing had its measurement compensation deferred past a paint and snapped a beat later (#1250). Adjustments are now deferred only while a finger is down or inside a timer-bounded post-touchend tail that momentum scroll events keep re-arming, so it spans the whole fling and self-terminates 150 ms after the last frame. Absolute scroll commands close that tail (their write cancels momentum anyway), so a landing triggered from a tap handler compensates synchronously too. `touchcancel` is handled like `touchend`, so a system gesture stealing the touch no longer leaves the deferral gate stuck.

Also fix a double-applied prepend correction on iOS: when an end-anchored prepend landed while a finger was down, `setOptions` had already folded the anchor delta into the tracked offset and the deferred flush added it again, throwing the reader a whole prepend past their row once the gesture settled. The deferred path now keeps the tracked offset at the DOM's value and compensates prepended rows' measurements against the offset the flush will land on, so the flush applies exactly one measured prepend.

Also recover an iOS compensation write that a touch undoes. iOS scrolls on a separate thread, so when a deferred correction flushes and the user touches the screen within a frame, the scrolling thread still holds the pre-write position and the write is reverted; the resulting scroll event used to be treated as the user scrolling and the correction was lost, leaving the reader a whole prepend away from their row. The missing delta now goes back into the deferred accumulator and replays once the gesture settles.
167 changes: 167 additions & 0 deletions packages/react-virtual/e2e/app/test/ios-touch-deferral.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { expect, test } from '@playwright/test'
import type { CDPSession, Page } from '@playwright/test'

// Browser gate for the iOS scroll-adjustment deferral. `isIOSWebKit()` keys off
// the user agent, so an iPhone UA plus touch emulation puts Chromium on the iOS
// code path; CDP `Input.dispatchTouchEvent` then drives a real touch gesture.
// This exercises the deferral state machine end to end in a real browser. It
// cannot reproduce WebKit's momentum physics (the reason the deferral exists),
// only the bookkeeping around it.

test.use({
hasTouch: true,
userAgent:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
})

const container = '#scroll-container'

async function waitForEnd(page: Page) {
await expect
.poll(() =>
page.evaluate((sel) => {
const el = document.querySelector(sel)!
return Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight)
}, container),
)
.toBeLessThan(1.01)
}

const scrollTop = (page: Page) =>
page.evaluate((sel) => document.querySelector(sel)!.scrollTop, container)

// Screen position of the message row nearest the top of the viewport, so we
// can tell whether the reader's row stayed put across the prepend.
async function topRow(page: Page) {
return page.evaluate((sel) => {
const el = document.querySelector(sel)!
const top = el.getBoundingClientRect().top
const row = [...el.querySelectorAll<HTMLElement>('[data-message-id]')]
.map((n) => ({
id: n.dataset.messageId!,
y: n.getBoundingClientRect().top - top,
}))
.filter((r) => r.y > -1)
.sort((a, b) => a.y - b.y)[0]!
return row
}, container)
}

async function rowY(page: Page, id: string) {
return page.evaluate(
({ sel, id }) => {
const el = document.querySelector(sel)!
const n = el.querySelector<HTMLElement>(`[data-message-id="${id}"]`)
return n
? n.getBoundingClientRect().top - el.getBoundingClientRect().top
: null
},
{ sel: container, id },
)
}

async function fingerDown(cdp: CDPSession, x: number, y: number) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x, y }],
})
}
async function fingerMove(cdp: CDPSession, x: number, y: number) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x, y }],
})
}
async function fingerUp(cdp: CDPSession) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: [],
})
}

test('a prepend landing mid-touch is anchored once the gesture settles, not doubled', async ({
page,
browserName,
}) => {
test.skip(browserName !== 'chromium', 'CDP touch dispatch is Chromium-only')
await page.goto('/chat/')
await waitForEnd(page)
// Reading history: well away from the end so followOnAppend stays out of it.
await page.evaluate((sel) => {
document.querySelector(sel)!.scrollTop = 600
}, container)
await page.waitForTimeout(200)

const box = (await page.locator(container).boundingBox())!
const x = box.x + box.width / 2
const y = box.y + box.height / 2
const cdp = await page.context().newCDPSession(page)

// Finger down and a short drag: the user owns the scroll.
await fingerDown(cdp, x, y)
await fingerMove(cdp, x, y + 10)
await fingerMove(cdp, x, y + 20)
await page.waitForTimeout(80)
const before = await topRow(page)
const stBefore = await scrollTop(page)

// History lands while the finger is still down: 5 x 50px above the reader.
await page.evaluate(() => document.getElementById('prepend')!.click())
await page.waitForTimeout(150)
// Deferred: no scrollTop write yet, so the DOM still sits where the user left it.
expect(await scrollTop(page)).toBe(stBefore)

// Release. The post-touchend tail expires ~150ms later and the deferred
// delta flushes in one write.
await fingerUp(cdp)
await page.waitForTimeout(500)

// The reader's row is back at the same screen position, offset by exactly
// one prepend (250px): the deferred delta was applied once, in one write.
// (The double-count regression with measured rows is covered by the core
// unit test '#884: ... applies it once'; this page's rows match their
// estimate, so it exercises the deferral state machine, not that path.)
const yAfter = await rowY(page, before.id)
expect(yAfter).not.toBeNull()
expect(Math.abs(yAfter! - before.y)).toBeLessThan(2)
expect(await scrollTop(page)).toBe(stBefore + 250)
})

test('a programmatic scrollToIndex landing with no touch compensates on the spot (#1250)', async ({
page,
browserName,
}) => {
test.skip(browserName !== 'chromium', 'CDP touch dispatch is Chromium-only')
// /scroll/ has 1002 rows of random height against a 50px estimate, so a
// landing far down the list measures rows above the fold that differ from
// their estimate and needs compensation. With the old `isScrolling` gate the
// write's own scroll event deferred that compensation; reconcileScroll still
// landed the target, and ~150ms later the flush replayed the deferred delta
// on top of it — a visible snap after the landing. No touch is involved
// here, so nothing may be deferred and the position must not move once the
// landing has settled.
await page.goto('/scroll/')
await page.click('#scroll-to-1000')

const container = '#scroll-container'
const st = () =>
page.evaluate((sel) => document.querySelector(sel)!.scrollTop, container)
// Let the landing and its measurement corrections settle.
await page.waitForTimeout(300)
const settled = await st()
await expect(page.locator('[data-testid="item-1000"]')).toBeVisible()

// Nothing may be waiting to land later.
await page.waitForTimeout(600)
expect(await st()).toBe(settled)

// And the landing itself is exact: item 1000 ends flush with the viewport.
const delta = await page.evaluate((sel) => {
const item = document.querySelector('[data-testid="item-1000"]')!
const el = document.querySelector(sel)!
const itemRect = item.getBoundingClientRect()
const rect = el.getBoundingClientRect()
return Math.abs(itemRect.bottom - rect.bottom)
}, container)
expect(delta).toBeLessThan(1.01)
})
Loading
Loading