Skip to content

Commit cf7834d

Browse files
authored
fix(virtual-core): reset iOS gesture/deferral state on cleanup (#1220)
1 parent d49cc52 commit cf7834d

3 files changed

Lines changed: 146 additions & 0 deletions

File tree

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+
Reset iOS gesture/deferral state in `cleanup()` so it no longer leaks across scroll element swaps.

packages/virtual-core/src/index.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -741,6 +741,19 @@ export class Virtualizer<
741741
this.rafId = null
742742
}
743743
this.scrollState = null
744+
// The iOS gesture/deferral state is scoped to the current scroll
745+
// element: the touch listeners that maintain it were just removed, and
746+
// an in-flight touch keeps targeting the old element (implicit touch
747+
// capture), so the new element never reports it. Carrying the state
748+
// over would replay a stale deferred delta on the new element's first
749+
// flush, and a cleanup that lands mid-touch or inside the post-touchend
750+
// grace window would strand _iosTouching / _iosJustTouchEnded as true
751+
// (the listener unsub clears the grace timer, and with it the only
752+
// pending reset of the flag), deferring every adjustment on the new
753+
// element until its next touch cycle.
754+
this._iosDeferredAdjustment = 0
755+
this._iosTouching = false
756+
this._iosJustTouchEnded = false
744757
this.scrollElement = null
745758
this.targetWindow = null
746759
}

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

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1810,6 +1810,134 @@ test('iOS Phase 1: new touchstart during grace window cancels pending flush time
18101810
})
18111811
})
18121812

1813+
// Helper for the element-swap tests below: like makeIOSVirtualizerWithRealEl
1814+
// but getScrollElement reads a mutable holder, so tests can swap the scroll
1815+
// element and re-run _willUpdate (which triggers cleanup + re-attach).
1816+
function makeIOSVirtualizerWithSwappableEl(
1817+
scrollToFn: ReturnType<typeof vi.fn>,
1818+
mockWindow: any,
1819+
{ startScrolling = false }: { startScrolling?: boolean } = {},
1820+
) {
1821+
const makeEl = () =>
1822+
makeMockScrollElement({
1823+
scrollTop: 100,
1824+
scrollLeft: 0,
1825+
scrollHeight: 500,
1826+
clientHeight: 200,
1827+
offsetHeight: 200,
1828+
ownerDocument: { defaultView: mockWindow },
1829+
})
1830+
const holder = { el: makeEl() }
1831+
let scrollCallback: ((offset: number, isScrolling: boolean) => void) | null =
1832+
null
1833+
const v = new Virtualizer({
1834+
count: 10,
1835+
estimateSize: () => 50,
1836+
getScrollElement: () => holder.el as any,
1837+
scrollToFn,
1838+
observeElementRect: () => {},
1839+
observeElementOffset: (_inst, cb) => {
1840+
scrollCallback = cb
1841+
cb(100, startScrolling)
1842+
return () => {}
1843+
},
1844+
})
1845+
v._willUpdate()
1846+
v['getMeasurements']()
1847+
return {
1848+
v,
1849+
holder,
1850+
makeEl,
1851+
getScrollCallback: () => scrollCallback!,
1852+
}
1853+
}
1854+
1855+
test('iOS Phase 1: scroll-element swap does not replay a stale deferred adjustment', () => {
1856+
withFakeIOSUserAgent(() => {
1857+
const scrollToFn = vi.fn()
1858+
const mockWindow = {
1859+
setTimeout: globalThis.setTimeout.bind(globalThis),
1860+
clearTimeout: globalThis.clearTimeout.bind(globalThis),
1861+
}
1862+
const { v, holder, makeEl, getScrollCallback } =
1863+
makeIOSVirtualizerWithSwappableEl(scrollToFn, mockWindow, {
1864+
startScrolling: true,
1865+
})
1866+
scrollToFn.mockClear()
1867+
1868+
// A resize above the viewport during the live scroll defers its
1869+
// adjustment instead of writing scrollTop.
1870+
v.resizeItem(0, 100)
1871+
expect(scrollToFn).not.toHaveBeenCalled()
1872+
expect(v['_iosDeferredAdjustment']).toBe(50)
1873+
1874+
// The scroll element is swapped while the deferral is pending — the
1875+
// delta was computed against the old element's content and must not
1876+
// survive into the new element.
1877+
holder.el = makeEl()
1878+
v._willUpdate()
1879+
expect(v['_iosDeferredAdjustment']).toBe(0)
1880+
1881+
// The new element's first quiescence must not write the stale delta.
1882+
scrollToFn.mockClear()
1883+
getScrollCallback()(100, false)
1884+
expect(scrollToFn).not.toHaveBeenCalled()
1885+
})
1886+
})
1887+
1888+
test('iOS Phase 1: scroll-element swap mid-touch does not strand _iosTouching', () => {
1889+
withFakeIOSUserAgent(() => {
1890+
const mockWindow = {
1891+
setTimeout: globalThis.setTimeout.bind(globalThis),
1892+
clearTimeout: globalThis.clearTimeout.bind(globalThis),
1893+
}
1894+
const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl(
1895+
vi.fn(),
1896+
mockWindow,
1897+
)
1898+
dispatchTouchEvent(holder.el, 'touchstart')
1899+
expect(v['_iosTouching']).toBe(true)
1900+
1901+
// The in-flight touch keeps targeting the old element (implicit touch
1902+
// capture), so the new element will never deliver its touchend. Without
1903+
// a reset, every adjustment on the new element would be deferred and
1904+
// the flush blocked until the user's next full touch cycle.
1905+
holder.el = makeEl()
1906+
v._willUpdate()
1907+
expect(v['_iosTouching']).toBe(false)
1908+
})
1909+
})
1910+
1911+
test('iOS Phase 1: scroll-element swap during grace window does not strand _iosJustTouchEnded', () => {
1912+
withFakeIOSUserAgent(() => {
1913+
let timerId = 0
1914+
const timers = new Map<number, () => void>()
1915+
const mockWindow = {
1916+
setTimeout: (fn: () => void, _ms: number) => {
1917+
const id = ++timerId
1918+
timers.set(id, fn)
1919+
return id
1920+
},
1921+
clearTimeout: (id: number) => timers.delete(id),
1922+
}
1923+
const { v, holder, makeEl } = makeIOSVirtualizerWithSwappableEl(
1924+
vi.fn(),
1925+
mockWindow,
1926+
)
1927+
dispatchTouchEvent(holder.el, 'touchstart')
1928+
dispatchTouchEvent(holder.el, 'touchend')
1929+
expect(v['_iosJustTouchEnded']).toBe(true)
1930+
1931+
// Swapping inside the grace window removes the listeners and clears the
1932+
// grace timer — which was the only pending reset of the flag. Without
1933+
// the cleanup reset the flag would stay true indefinitely.
1934+
holder.el = makeEl()
1935+
v._willUpdate()
1936+
expect(v['_iosJustTouchEnded']).toBe(false)
1937+
expect(timers.size).toBe(0)
1938+
})
1939+
})
1940+
18131941
// ─── Phase 2a: subpixel scrollTop reconciliation ─────────────────────────────
18141942

18151943
test('Phase 2a: browser-rounded scrollTop after self-write is reconciled to intended value', () => {

0 commit comments

Comments
 (0)