diff --git a/packages/react-virtual/e2e/app/ios-deferral/index.html b/packages/react-virtual/e2e/app/ios-deferral/index.html
new file mode 100644
index 00000000..d47577ab
--- /dev/null
+++ b/packages/react-virtual/e2e/app/ios-deferral/index.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx
new file mode 100644
index 00000000..3fe72768
--- /dev/null
+++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx
@@ -0,0 +1,343 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import { flushSync } from 'react-dom'
+import { elementScroll, useVirtualizer } from '@tanstack/react-virtual'
+
+// Instrumented page for measuring the iOS scroll-correction deferral on a real
+// device or simulator (#1307). It records a timeline of scroll events, touch
+// events, scrollTop writes and resizes; a driver (or a finger) provides the
+// gestures. Nothing here asserts anything.
+
+const params = new URLSearchParams(window.location.search)
+const num = (name: string, fallback: number) =>
+ Number(params.get(name) ?? fallback)
+
+const COUNT = num('count', 400)
+const EST = num('est', 50)
+const VIEWPORT = num('h', 400)
+const OVERSCAN = num('overscan', 5)
+const INITIAL_OFFSET = num('initialOffset', 0)
+// `tall=a-b:px` renders rows a..b at `px` instead of the estimate, so their
+// first measurement differs from it.
+const tall = (params.get('tall') ?? '').match(/^(\d+)-(\d+):(\d+)$/)
+
+const name = params.get('name') ?? params.get('mode') ?? 'run'
+
+const heights = new Map()
+const heightOf = (index: number) =>
+ heights.get(index) ??
+ (tall && index >= Number(tall[1]) && index <= Number(tall[2])
+ ? Number(tall[3])
+ : EST)
+
+type Entry = Record
+
+const t: any = {
+ log: [] as Array,
+ el: null as HTMLElement | null,
+ v: null as any,
+ watch: [] as Array,
+ hooks: [] as Array<{ left: number; when: string; fn: () => void }>,
+ touching: false,
+ touchEnded: false,
+}
+;(window as any).__t = t
+
+const state = () => {
+ const v = t.v
+ const el = t.el as HTMLElement | null
+ const entry: Entry = { top: el ? el.scrollTop : null }
+ if (v) {
+ entry.off = v.scrollOffset
+ entry.scrolling = v.isScrolling
+ entry.dir = v.scrollDirection
+ entry.def = v._iosDeferredAdjustment
+ entry.adj = v.scrollAdjustments
+ const s = v.scrollState
+ entry.st = s
+ ? [s.index, s.lastTargetOffset, s.hasReachedTarget, s.stableFrames]
+ : null
+ if (t.watch.length > 0 && el) {
+ const m = v.getMeasurements()
+ entry.rows = t.watch.map((i: number) => m[i].start - el.scrollTop)
+ }
+ }
+ return entry
+}
+
+const log = (type: string, data: Entry = {}) => {
+ t.log.push({
+ t: Math.round(performance.now() * 10) / 10,
+ type,
+ ...data,
+ ...state(),
+ })
+}
+t.mark = log
+t.clear = () => {
+ t.log.length = 0
+}
+// Viewport-relative top of a mounted row, read from the DOM.
+t.rowTop = (index: number) => {
+ const row = t.el?.querySelector(`[data-index="${index}"]`)
+ if (!row) return null
+ return row.getBoundingClientRect().top - t.el.getBoundingClientRect().top
+}
+t.snap = (label: string, rows: Array = t.watch) =>
+ log('snap', { label, dom: rows.map((i) => t.rowTop(i)) })
+// Run `fn` on the n-th scroll event from now, or on the n-th one after the
+// next touchend (i.e. during momentum).
+t.onScroll = (n: number, fn: () => void) =>
+ t.hooks.push({ left: n, when: 'any', fn })
+t.onMomentumScroll = (n: number, fn: () => void) =>
+ t.hooks.push({ left: n, when: 'momentum', fn })
+// Same, counting only scroll events while a finger is down.
+t.onTouchScroll = (n: number, fn: () => void) =>
+ t.hooks.push({ left: n, when: 'touching', fn })
+t.frames = (n: number) =>
+ new Promise((resolve) => {
+ const step = () => (n-- > 0 ? requestAnimationFrame(step) : resolve())
+ step()
+ })
+t.wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
+t.send = (label: string) =>
+ fetch(`/log?name=${encodeURIComponent(label)}`, {
+ method: 'POST',
+ body: JSON.stringify({
+ name: label,
+ ua: navigator.userAgent,
+ url: location.href,
+ log: t.log,
+ }),
+ }).then(
+ (response) => (response.ok ? 'sent' : `failed: HTTP ${response.status}`),
+ (e) => `failed: ${e}`,
+ )
+
+// `autosend=ms` posts the log once the scroller has been quiet for `ms`
+// after a touch, so a run needs no WebDriver session (which blocks touches).
+const AUTOSEND = num('autosend', 0)
+let quietTimer = 0
+const quiet = () => {
+ if (!AUTOSEND || !t.touchEnded) return
+ clearTimeout(quietTimer)
+ quietTimer = window.setTimeout(() => {
+ t.snap('quiet')
+ t.send(name).then((status: string) => {
+ document.title = status
+ const out = document.getElementById('status')
+ if (out) out.textContent = `${status} (${t.log.length} entries)`
+ })
+ t.touchEnded = false
+ }, AUTOSEND)
+}
+
+const attach = (el: HTMLElement) => {
+ t.el = el
+ el.addEventListener(
+ 'scroll',
+ () => {
+ log('scroll')
+ for (const hook of t.hooks.slice()) {
+ if (hook.when === 'momentum' && (!t.touchEnded || t.touching)) continue
+ if (hook.when === 'touching' && !t.touching) continue
+ if (--hook.left > 0) continue
+ t.hooks.splice(t.hooks.indexOf(hook), 1)
+ hook.fn()
+ }
+ quiet()
+ },
+ { passive: true },
+ )
+ el.addEventListener(
+ 'touchstart',
+ () => {
+ t.touching = true
+ log('touchstart')
+ },
+ { passive: true },
+ )
+ const end = (type: string) => () => {
+ t.touching = false
+ t.touchEnded = true
+ log(type)
+ quiet()
+ }
+ el.addEventListener('touchend', end('touchend'), { passive: true })
+ el.addEventListener('touchcancel', end('touchcancel'), { passive: true })
+}
+
+// Presets for runs driven by a finger instead of a WebDriver session, each
+// fired on the `n`-th momentum scroll event:
+// `arm=write` writes scrollTop (+`d`); `arm=resize` changes row `row`
+// (default: a mounted one above the viewport) to `px`; `arm=index` calls
+// scrollToIndex(`to`). `arm=smooth` starts a smooth scrollToIndex(`to`) on
+// load, to be interrupted by a drag.
+const arm = () => {
+ const n = num('n', 5)
+ const preset = params.get('arm')
+ t.watch = params.get('watch')?.split(',').map(Number) ?? []
+ if (preset === 'write') {
+ t.onMomentumScroll(n, () => {
+ t.el.scrollTop += num('d', 0)
+ log('rawWrite', { d: num('d', 0) })
+ })
+ } else if (preset === 'resize') {
+ t.onMomentumScroll(n, () => {
+ // A mounted row above the viewport that stays mounted for a few frames.
+ const above = t.v.getVirtualItems()[0].index + num('skip', 2)
+ t.setH(num('row', above), num('px', EST * 2), true)
+ })
+ } else if (preset === 'index') {
+ // `any=1` counts every scroll event (wheel), `touching=1` only those
+ // under a finger, instead of momentum ones.
+ const on = params.has('any')
+ ? t.onScroll
+ : params.has('touching')
+ ? t.onTouchScroll
+ : t.onMomentumScroll
+ on(n, () => {
+ log('scrollToIndex', { index: num('to', 0) })
+ t.v.scrollToIndex(num('to', 0), { align: 'start' })
+ // `late=ms` then grows the first mounted row, which moves the target.
+ if (params.has('late')) {
+ window.setTimeout(
+ () => t.setH(t.v.getVirtualItems()[0].index, EST * 3),
+ num('late', 0),
+ )
+ }
+ })
+ } else if (preset === 'smooth') {
+ window.setTimeout(
+ () => {
+ log('scrollToIndex', { index: num('to', 0) })
+ t.v.scrollToIndex(num('to', 0), { align: 'start', behavior: 'smooth' })
+ },
+ num('delay', 1000),
+ )
+ }
+}
+
+const Controls = ({ label }: { label: string }) => {
+ const [status, setStatus] = React.useState('')
+ return (
+
+ {' '}
+ {' '}
+ {label} {status}
+
+ )
+}
+
+// No virtualizer: checks what a scrollTop write does to iOS momentum.
+const Plain = () => {
+ const ref = React.useRef(null)
+ React.useEffect(() => {
+ attach(ref.current!)
+ arm()
+ }, [])
+ return (
+
+ )
+}
+
+const App = () => {
+ const parentRef = React.useRef(null)
+ const [, bump] = React.useReducer((x: number) => x + 1, 0)
+
+ const rowVirtualizer = useVirtualizer({
+ count: COUNT,
+ getScrollElement: () => parentRef.current,
+ estimateSize: () => EST,
+ overscan: OVERSCAN,
+ initialOffset: INITIAL_OFFSET,
+ scrollToFn: (offset, options, instance) => {
+ log('write', {
+ to: offset + (options.adjustments ?? 0),
+ behavior: options.behavior,
+ })
+ elementScroll(offset, options, instance)
+ },
+ })
+ t.v = rowVirtualizer
+
+ React.useEffect(() => {
+ attach(parentRef.current!)
+ const resizeItem = rowVirtualizer.resizeItem
+ rowVirtualizer.resizeItem = (index, size) => {
+ const before = t.v.getMeasurements()[index]?.size
+ resizeItem(index, size)
+ if (before !== size) log('resizeItem', { index, size, before })
+ }
+ // Changes a mounted row's height; the ResizeObserver reports it.
+ t.setH = (index: number, px: number, sync = false) => {
+ heights.set(index, px)
+ log('setH', { index, px, sync })
+ if (sync) flushSync(bump)
+ else bump()
+ }
+ arm()
+ }, [])
+
+ return (
+
+ )
+}
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+ {params.get('mode') === 'plain' ?
:
}
+
,
+)
diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts
index a7910bcf..9fab88e9 100644
--- a/packages/react-virtual/e2e/app/vite.config.ts
+++ b/packages/react-virtual/e2e/app/vite.config.ts
@@ -24,6 +24,7 @@ export default defineConfig({
'direct-dom-updates/index.html',
),
'lanes-change': path.resolve(__dirname, 'lanes-change/index.html'),
+ 'ios-deferral': path.resolve(__dirname, 'ios-deferral/index.html'),
'cached-measurements': path.resolve(
__dirname,
'cached-measurements/index.html',
diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts
index 0dc7db3f..46012e3c 100644
--- a/packages/virtual-core/tests/index.test.ts
+++ b/packages/virtual-core/tests/index.test.ts
@@ -2371,6 +2371,278 @@ test('iOS deferral: a positive delta at the end clamp still replays (growth abov
})
})
+// ─── #1307: orderings the summed deferral gets wrong, and ones to keep ──────
+//
+// `test.fails` cases document current behaviour that #1307 is meant to fix:
+// each one asserts the wanted outcome and fails today. The plain tests pin
+// behaviour a redesign must not change by accident. 100 rows × 50px, 200px
+// viewport, hand-driven rAF and scroll events.
+
+function makeDeferralHarness({
+ ios = true,
+ start = 0,
+}: { ios?: boolean; start?: number } = {}) {
+ const rafCallbacks: Array = []
+ let now = 0
+ const mockWindow = {
+ requestAnimationFrame: (cb: FrameRequestCallback) => rafCallbacks.push(cb),
+ cancelAnimationFrame: () => {
+ rafCallbacks.length = 0
+ },
+ performance: { now: () => now },
+ }
+ const el = {
+ scrollTop: start,
+ scrollLeft: 0,
+ scrollHeight: 5000,
+ clientHeight: 200,
+ offsetHeight: 200,
+ ownerDocument: { defaultView: mockWindow },
+ }
+ let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null =
+ null
+ const run = (fn: () => T) => (ios ? withFakeIOSUserAgent(fn) : fn())
+ const v = run(() => {
+ const instance = new Virtualizer({
+ count: 100,
+ estimateSize: () => 50,
+ initialOffset: start,
+ getScrollElement: () => el as any,
+ scrollToFn: (offset, { adjustments = 0 }) => {
+ el.scrollTop = offset + adjustments
+ },
+ observeElementRect: (_inst, cb) => {
+ cb({ width: 400, height: 200 })
+ return () => {}
+ },
+ observeElementOffset: (_inst, cb) => {
+ scrollCallback = cb
+ cb(start, false)
+ return () => {}
+ },
+ })
+ instance._willUpdate()
+ instance['getMeasurements']()
+ return instance
+ })
+ return {
+ v,
+ el,
+ run,
+ // A scroll event; moves the element first when `offset` is given.
+ scroll: (isScrolling: boolean, offset = el.scrollTop) => {
+ el.scrollTop = offset
+ scrollCallback!(offset, isScrolling)
+ },
+ frame: () => rafCallbacks.splice(0).forEach((cb) => cb(0)),
+ advance: (ms: number) => {
+ now += ms
+ },
+ // Where a row sits relative to the top of the viewport.
+ rowTop: (index: number) =>
+ v['getMeasurements']()[index]!.start - el.scrollTop,
+ }
+}
+
+// scrollToIndex(20) has landed on 1000 and reconcile has seen it once; row 10
+// (above the target) then grows by 50 and reconcile has not run since.
+function landedIndexScrollWithResizeAbove(ios = true) {
+ const h = makeDeferralHarness({ ios })
+ h.run(() => {
+ h.v.scrollToIndex(20, { align: 'start' })
+ h.scroll(true)
+ h.frame()
+ h.v.resizeItem(10, 100)
+ })
+ return h
+}
+
+test.fails(
+ 'iOS deferral (#1307): a gesture before the index scroll retargets keeps the resize correction',
+ () => {
+ const h = landedIndexScrollWithResizeAbove()
+ h.run(() => {
+ h.scroll(true, 1010)
+ h.frame()
+ h.scroll(false)
+ // The user dragged 10px, so row 20 belongs 10px above the viewport top.
+ expect(h.rowTop(20)).toBe(-10)
+ })
+ },
+)
+
+test('non-iOS: a gesture before the index scroll retargets keeps the resize correction', () => {
+ const h = landedIndexScrollWithResizeAbove(false)
+ h.scroll(true, h.el.scrollTop + 10)
+ h.frame()
+ h.scroll(false)
+ expect(h.rowTop(20)).toBe(-10)
+})
+
+test.fails(
+ 'iOS deferral (#1307): cancelScroll before the index scroll retargets keeps the resize correction',
+ () => {
+ const h = landedIndexScrollWithResizeAbove()
+ h.run(() => {
+ h.v.cancelScroll()
+ h.frame()
+ h.scroll(false)
+ expect(h.rowTop(20)).toBe(0)
+ })
+ },
+)
+
+test.fails(
+ 'iOS deferral (#1307): the reconcile safety valve keeps the resize correction',
+ () => {
+ const h = landedIndexScrollWithResizeAbove()
+ h.run(() => {
+ h.advance(6000)
+ h.frame()
+ h.scroll(false)
+ expect(h.rowTop(20)).toBe(0)
+ })
+ },
+)
+
+test.fails(
+ 'iOS deferral (#1307): scrollBy replacing an index scroll keeps the resize correction',
+ () => {
+ const h = landedIndexScrollWithResizeAbove()
+ h.run(() => {
+ h.v.scrollBy(100)
+ h.scroll(true)
+ h.frame()
+ h.scroll(true)
+ h.frame()
+ h.scroll(false)
+ expect(h.rowTop(20)).toBe(-100)
+ })
+ },
+)
+
+test.fails(
+ 'iOS deferral (#1307): a correction is not replayed once the viewport is above the resized row',
+ () => {
+ const h = makeDeferralHarness({ start: 2000 })
+ h.run(() => {
+ h.scroll(true, 2010)
+ h.v.resizeItem(30, 100)
+ expect(h.v['_iosDeferredAdjustment']).toBe(50)
+ // The gesture carries the viewport above row 30, then settles.
+ h.scroll(true, 1000)
+ h.scroll(false)
+ expect(h.rowTop(20)).toBe(0)
+ })
+ },
+)
+
+test.fails(
+ 'scrollToIndex (#1307): a resize classified against the stale offset does not overwrite the index scroll',
+ () => {
+ // Not iOS-specific. scrollOffset still holds the old position until the
+ // scroll event, so row 38 (below the target) reads as above the viewport.
+ const h = makeDeferralHarness({ ios: false, start: 2000 })
+ h.v.scrollToIndex(10, { align: 'start' })
+ expect(h.el.scrollTop).toBe(500)
+ h.v.resizeItem(38, 100)
+ h.scroll(true)
+ h.frame()
+ expect(h.el.scrollTop).toBe(500)
+ },
+)
+
+test.fails(
+ 'scrollToIndex (#1307): a drag that never reached the target is not pulled back when the target moves',
+ () => {
+ // Seen in iOS Safari: a scrollToIndex issued while a finger is dragging is
+ // ignored by the browser, so the offset never gets near the target.
+ const h = makeDeferralHarness()
+ h.run(() => {
+ h.v.scrollToIndex(50, { align: 'start' })
+ h.scroll(true, 20)
+ h.frame()
+ h.scroll(true, 60)
+ h.frame()
+ // A row measures and moves the target; the user is still at 60.
+ h.v.resizeItem(30, 100)
+ h.frame()
+ expect(h.el.scrollTop).toBe(60)
+ })
+ },
+)
+
+test.fails(
+ 'scrollToIndex (#1307): a scroll away from a target that moved before landing is not pulled back, non-iOS',
+ () => {
+ // Not specific to iOS ignoring writes under a finger: a target that moves
+ // before reconcile sees the scroll land leaves the state alive too.
+ const h = makeDeferralHarness({ ios: false })
+ h.v.scrollToIndex(50, { align: 'start' })
+ h.scroll(true)
+ h.v.resizeItem(10, 100)
+ h.frame()
+ h.scroll(true)
+ expect(h.el.scrollTop).toBe(2550)
+ // The user scrolls away, then a row above them measures.
+ h.scroll(true, 1000)
+ h.frame()
+ h.v.resizeItem(5, 100)
+ h.frame()
+ expect(h.el.scrollTop).toBe(1050)
+ },
+)
+
+test('iOS deferral: a deferred correction follows the viewport through momentum', () => {
+ const h = makeDeferralHarness({ start: 1000 })
+ h.run(() => {
+ h.scroll(true, 1010)
+ h.v.resizeItem(5, 100)
+ h.scroll(true, 3000)
+ h.scroll(false)
+ // Applied where the momentum ended, not where the resize was seen.
+ expect(h.el.scrollTop).toBe(3050)
+ expect(h.rowTop(60)).toBe(0)
+ })
+})
+
+test('iOS deferral: a re-measure above the viewport is deferred scrolling forward, skipped scrolling backward', () => {
+ const h = makeDeferralHarness({ start: 2000 })
+ h.run(() => {
+ h.v.resizeItem(5, 60)
+ h.scroll(false)
+ h.scroll(true, 1990)
+ h.v.resizeItem(5, 100)
+ expect(h.v['_iosDeferredAdjustment']).toBe(0)
+ h.scroll(true, 2020)
+ h.v.resizeItem(5, 120)
+ expect(h.v['_iosDeferredAdjustment']).toBe(20)
+ })
+})
+
+test('iOS deferral: a custom adjust predicate that opts out defers nothing', () => {
+ const h = makeDeferralHarness({ start: 2000 })
+ h.run(() => {
+ h.v.shouldAdjustScrollPositionOnItemSizeChange = () => false
+ h.scroll(true, 2010)
+ h.v.resizeItem(5, 100)
+ expect(h.v['_iosDeferredAdjustment']).toBe(0)
+ })
+})
+
+test('iOS deferral: the first measure of the row spanning the viewport top is compensated', () => {
+ const h = makeDeferralHarness({ start: 2000 })
+ h.run(() => {
+ h.scroll(true, 2010)
+ expect(h.rowTop(41)).toBe(40)
+ // Row 40 starts at 2000, 10px above the viewport top.
+ h.v.resizeItem(40, 100)
+ expect(h.v['_iosDeferredAdjustment']).toBe(50)
+ h.scroll(false)
+ expect(h.rowTop(41)).toBe(40)
+ })
+})
+
// ─── Phase 1: touch event distinction ────────────────────────────────────────
// Mock EventTarget that records listeners so tests can dispatch events