@@ -1004,6 +1004,10 @@ export class Virtualizer<
10041004 this . rafId = null
10051005 }
10061006 this . scrollState = null
1007+ // The element is let go of: give it back its overflow now. A stop the
1008+ // next element needs starts afresh.
1009+ this . _momentumStop ?. restore ( )
1010+ this . _momentumStop = null
10071011 // The debounce cancelled above is the only thing that writes `isScrolling`
10081012 // back to false, so a cleanup inside the reset window would strand it, and
10091013 // the direction derived from it, as true. That matters because `cleanup`
@@ -2203,6 +2207,60 @@ export class Virtualizer<
22032207 return null
22042208 }
22052209
2210+ // Stop a running iOS fling, then call `done`: the momentum does not
2211+ // survive the element losing its overflow for a frame. Hidden and
2212+ // restored within one task, iOS never sees the change and the fling
2213+ // carries on (27.0.1 iPhone); hidden for two frames, it stops at once.
2214+ // Nothing moves on screen. A second call while one is in progress waits
2215+ // for the same frames. The app's inline value comes back, unless the app
2216+ // set a new one meanwhile; `done` is then not called.
2217+ private _momentumStop : { restore : ( ) => boolean ; done : ( ) => void } | null =
2218+ null
2219+ private _stopMomentum = ( done : ( ) => void ) => {
2220+ const el = this . scrollElement
2221+ const win = this . targetWindow
2222+ if ( el === null || win === null ) return
2223+ const scroller = (
2224+ 'document' in el ? el . document . scrollingElement : el
2225+ ) as HTMLElement | null
2226+ // A test double may have no style to change.
2227+ if ( ! scroller ?. style ) return
2228+ if ( this . _momentumStop ) {
2229+ this . _momentumStop . done = done
2230+ return
2231+ }
2232+ const { style } = scroller
2233+ const property = this . options . horizontal ? 'overflow-x' : 'overflow-y'
2234+ const value = style . getPropertyValue ( property )
2235+ const priority = style . getPropertyPriority ( property )
2236+ style . setProperty ( property , 'hidden' , 'important' )
2237+ const stop = {
2238+ // False when the app replaced the hidden value: the fling may not
2239+ // have stopped.
2240+ restore : ( ) => {
2241+ if (
2242+ style . getPropertyValue ( property ) !== 'hidden' ||
2243+ style . getPropertyPriority ( property ) !== 'important'
2244+ ) {
2245+ return false
2246+ }
2247+ style . setProperty ( property , value , priority )
2248+ return true
2249+ } ,
2250+ done,
2251+ }
2252+ this . _momentumStop = stop
2253+ win . requestAnimationFrame ( ( ) =>
2254+ win . requestAnimationFrame ( ( ) => {
2255+ // Already restored by `cleanup`, which also drops its command.
2256+ if ( this . _momentumStop !== stop ) return
2257+ this . _momentumStop = null
2258+ // The command waits for the tail, as without the stop.
2259+ if ( stop . restore ( ) ) stop . done ( )
2260+ } ) ,
2261+ )
2262+ }
2263+
22062264 // A compensation write past the element's current max (the sizer has not
22072265 // grown yet) is clamped by the browser; record it for the retry (#1258).
22082266 private _clampedBy = ( target : number ) => {
@@ -2384,12 +2442,32 @@ export class Virtualizer<
23842442 if ( tail === 'fling' && iosMomentumOverridesWrites ( ) ) {
23852443 // iOS 27: a write now shows for one frame and the fling carries on
23862444 // along its own path, so the command would be lost (#1307, step 3).
2387- // Leave the tail open and write when it closes, ~150ms after the
2388- // last fling frame; the user taking over before then (a drag, a
2445+ // Stop the fling and write two frames later. Otherwise (no style to
2446+ // change, a finger down, a bounce) the tail stays open and
2447+ // the command is written when it closes, ~150ms after the last
2448+ // fling frame. The user taking over before the write (a drag, a
23892449 // second flick, a finger stopping the fling) cancels the command
2390- // (see the scroll handler and `onTailClosed`). A relative command (`scrollBy`)
2391- // is not postponed: its target is taken from the position at the
2392- // call, which the fling leaves behind either way.
2450+ // (see the scroll handler and `onTailClosed`). A relative command
2451+ // (`scrollBy`) is not postponed: its target is taken from the
2452+ // position at the call, which the fling leaves behind either way.
2453+ this . _stopMomentum ( ( ) => {
2454+ const state = this . scrollState
2455+ if (
2456+ state ?. phase !== 'queued' ||
2457+ // A finger came down: it owns the scroll until it lifts.
2458+ this . _iosGesture . tail ( ) !== 'fling' ||
2459+ // In a bounce WebKit can drop the write (#1287): the tail
2460+ // decides, as without the stop. (Not `flingCaught`: our stop
2461+ // also ends the fling at speed, so a slow frame would read as
2462+ // a catch and the tail would drop the command.)
2463+ this . _reportedOverscroll
2464+ ) {
2465+ return
2466+ }
2467+ this . _iosGesture . end ( )
2468+ this . _flushHoldIfReady ( true )
2469+ this . _writeAwaitingCommand ( )
2470+ } )
23932471 this . scrollState = {
23942472 ...target ,
23952473 startedAt : this . now ( ) ,
0 commit comments