@@ -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
18151943test ( 'Phase 2a: browser-rounded scrollTop after self-write is reconciled to intended value' , ( ) => {
0 commit comments