From 9c73123b22a8db73613f54fb35a6d6e15766284f Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 15:00:58 +0200 Subject: [PATCH 1/5] test(virtual-core): pin the iOS deferral orderings measured for #1307 Six test.fails cases document orderings the summed deferral gets wrong, five passing tests pin behaviour a redesign should not change by accident. Adds an instrumented e2e page (ios-deferral) used to check the same orderings in real iOS Safari. --- .../e2e/app/ios-deferral/index.html | 11 + .../e2e/app/ios-deferral/main.tsx | 323 ++++++++++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 1 + packages/virtual-core/tests/index.test.ts | 231 +++++++++++++ 4 files changed, 566 insertions(+) create mode 100644 packages/react-virtual/e2e/app/ios-deferral/index.html create mode 100644 packages/react-virtual/e2e/app/ios-deferral/main.tsx 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..f64394ff --- /dev/null +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -0,0 +1,323 @@ +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; momentum: boolean; 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, momentum: false, fn }) +t.onMomentumScroll = (n: number, fn: () => void) => + t.hooks.push({ left: n, momentum: true, 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( + () => 'sent', + (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) + 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.momentum && (!t.touchEnded || 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), not only momentum ones. + const on = params.has('any') ? t.onScroll : t.onMomentumScroll + on(n, () => { + log('scrollToIndex', { index: num('to', 0) }) + t.v.scrollToIndex(num('to', 0), { align: 'start' }) + }) + } 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 ( +
+ {Array.from({ length: COUNT }, (_, i) => ( +
+ Row {i} +
+ ))} +
+ ) +} + +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 ( +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+
+ ) +} + +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..519dc96c 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2371,6 +2371,237 @@ 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('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 From 6809c49d9bf4dd4838299146181cdbf0e8b0825d Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 15:13:01 +0200 Subject: [PATCH 2/5] test(virtual-core): pin the pull-back after an index scroll a drag never let land Adds finger-down and late-resize triggers to the ios-deferral page. --- .../e2e/app/ios-deferral/main.tsx | 28 +++++++++++++++---- packages/virtual-core/tests/index.test.ts | 20 +++++++++++++ 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx index f64394ff..854e2ff0 100644 --- a/packages/react-virtual/e2e/app/ios-deferral/main.tsx +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -37,7 +37,7 @@ const t: any = { el: null as HTMLElement | null, v: null as any, watch: [] as Array, - hooks: [] as Array<{ left: number; momentum: boolean; fn: () => void }>, + hooks: [] as Array<{ left: number; when: string; fn: () => void }>, touching: false, touchEnded: false, } @@ -88,9 +88,12 @@ t.snap = (label: string, rows: Array = t.watch) => // 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, momentum: false, fn }) + t.hooks.push({ left: n, when: 'any', fn }) t.onMomentumScroll = (n: number, fn: () => void) => - t.hooks.push({ left: n, momentum: true, fn }) + 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()) @@ -132,7 +135,8 @@ const attach = (el: HTMLElement) => { () => { log('scroll') for (const hook of t.hooks.slice()) { - if (hook.momentum && (!t.touchEnded || t.touching)) continue + 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() @@ -181,11 +185,23 @@ const arm = () => { t.setH(num('row', above), num('px', EST * 2), true) }) } else if (preset === 'index') { - // `any=1` counts every scroll event (wheel), not only momentum ones. - const on = params.has('any') ? t.onScroll : t.onMomentumScroll + // `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( diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 519dc96c..c940624d 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2552,6 +2552,26 @@ test.fails( }, ) +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('iOS deferral: a deferred correction follows the viewport through momentum', () => { const h = makeDeferralHarness({ start: 1000 }) h.run(() => { From d6ab75d8c3c1204953c3960821e579f0e691d293 Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 15:22:37 +0200 Subject: [PATCH 3/5] test(react-virtual): show the auto-send status on the ios-deferral page --- packages/react-virtual/e2e/app/ios-deferral/main.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx index 854e2ff0..8616e51a 100644 --- a/packages/react-virtual/e2e/app/ios-deferral/main.tsx +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -123,7 +123,11 @@ const quiet = () => { clearTimeout(quietTimer) quietTimer = window.setTimeout(() => { t.snap('quiet') - t.send(name) + 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) } @@ -224,7 +228,7 @@ const Controls = ({ label }: { label: string }) => { {' '} - {label} {status} + {label} {status} ) } From bf93397e2ed109cbec2eedfea0d493d5829b40a5 Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 15:59:20 +0200 Subject: [PATCH 4/5] test(virtual-core): pin the pull-back after a target that moved before landing, non-iOS Repro from the #1307 thread: the index-scroll state survives a user scroll whenever the target moved before reconcile saw it land, not only when iOS ignores the write under a finger. --- packages/virtual-core/tests/index.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index c940624d..46012e3c 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2572,6 +2572,27 @@ test.fails( }, ) +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(() => { From 355db9d4f65bc9e853fa4a70488a39a987211625 Mon Sep 17 00:00:00 2001 From: Pierre Hennequart Date: Sun, 11 Oct 2026 16:22:13 +0200 Subject: [PATCH 5/5] test(react-virtual): report HTTP errors when the ios-deferral page sends its log --- packages/react-virtual/e2e/app/ios-deferral/main.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-virtual/e2e/app/ios-deferral/main.tsx b/packages/react-virtual/e2e/app/ios-deferral/main.tsx index 8616e51a..3fe72768 100644 --- a/packages/react-virtual/e2e/app/ios-deferral/main.tsx +++ b/packages/react-virtual/e2e/app/ios-deferral/main.tsx @@ -110,7 +110,7 @@ t.send = (label: string) => log: t.log, }), }).then( - () => 'sent', + (response) => (response.ok ? 'sent' : `failed: HTTP ${response.status}`), (e) => `failed: ${e}`, )