virtual-core: desktop Safari drops the prepend anchor write when history lands during the top rubber-band bounce · Issue #1287 · TanStack/virtual · GitHub
Skip to content

virtual-core: desktop Safari drops the prepend anchor write when history lands during the top rubber-band bounce #1287

Closed

Description

@piecyk

Describe the bug

On macOS Safari (18.4, navigator.maxTouchPoints 0, so none of the iOS deferral is involved), an end-anchored list loses its reading position when a prepend lands while the scroller is rubber-banding past its top edge.

Sequence, from a per-frame trace of the react chat example (anchorTo: 'end', directDomUpdates: true, 180 ms mock history load):

 1283ms st=159  off=159  range=1-10 count=45 top=1
 1300ms st=0    off=0    range=0-9  count=45 top=0   <- auto history load triggers
 1316ms st=-20  off=-20  range=0-8  count=45 top=0   <- Safari rubber band, scrollTop negative
 1383ms st=-69  off=-69  range=0-8  count=45 top=0
 1466ms st=-51  off=-51  range=0-8  count=45 top=0
 1484ms st=-46  off=-46  range=0-10 count=57 top=-12 <- 12 rows prepended, anchor write lost

setOptions resolves the anchor and _willUpdate writes scrollTop = 842 to keep row 0 in place. WebKit discards a scrollTop write made during an elastic bounce and animates back to 0. The next bounce scroll event reports the DOM value, the tracked offset follows it, and the viewport now shows the newly prepended rows: the reader is exactly one prepend away from where they were. While the eagerly bumped offset and the DOM disagree the viewport can also render blank, since the range is computed for a position the DOM never reaches.

The iOS flush path already treats the overscroll zone as unwritable (_flushIosDeferredIfReady, "Phase 2b"). The desktop write path has no such guard.

Steps to reproduce

  1. pnpm --filter tanstack-react-virtual-example-chat dev, open in desktop Safari with a trackpad.
  2. Fling upward hard enough to hit the top and bounce.
  3. The 180 ms mock load lands during the bounce. The list shows the newly loaded rows instead of the row you were reading.

With a slower (real) backend the load usually lands after the bounce has settled and the write goes through, so this needs a fast prepend to trigger. Cached or prefetched history makes that common.

What was tried

A deferral of writes made while scrollOffset < 0, replayed on the first in-bounds scroll event, lands on the correct row but shows the new rows for the duration of the bounce and then snaps back. That reads as a glitch every time and was dropped from #1280. On WebKit the reader's row cannot be held still through the bounce with scrollTop at all.

Options

  • App level, to verify first: overscroll-behavior: none on the scroll container. Safari 16+ supports it and per spec none suppresses the boundary bounce. If it removes the rubber band, the failure window disappears and the chat docs can recommend it next to overflow-anchor: none.
  • Core: a CSS offset on the container for the bounce window, swapped for a real scrollTop write once the offset is back in bounds. This is the technique from feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset #1189, scoped to this one case.
  • Example: the auto-load fires on every bounce scroll event below 120 px, so after the first load finishes the bounce triggers a second one. Independent of the core issue, but it amplifies it.

Platform

Related: #1280, #1189, #884.

Activity

  1. piecyk commented on Oct 13, 2026

    @piecyk
    CollaboratorAuthor

    Update: same root cause on iOS, and a fix verified on both

    Per-frame traces from the chat example (st = element scrollTop, off = virtualizer offset, blank = viewport px not covered by rows).

    Desktop Safari 18.4 (maxTouchPoints 0): the anchor write is discarded mid-bounce, and the write also stops the bounce: it stays at -80 with no further scroll events, so the blank stays until the user scrolls again (not "animates back to 0" as above).

    1747ms --- prepend +12
    1752ms WRITE to=794 before=-89
    1754ms   landed? nextFrame=-84 NO
    1918ms st=-80 off=-80 blank=80 scrolling=0   (no more scroll events)
    

    iOS 27.0.1 Safari: the same window on iOS. The deferral correctly writes nothing mid-bounce, but rendering follows the bounce, so the new rows are visible for ~0.8s and then the deferred anchor is flushed. This is the "shows the new rows, then snaps back" from What was tried.

    2986ms --- prepend +12              (deferred, no write)
    2999ms st=-91 rows=0-13 count=57    (new rows on screen)
    3767ms WRITE to=888 landed YES      (tail closes: snap back to the reader's row)
    

    So both platforms need a way to hold the reader's row while scrollTop can't be written. overscroll-behavior: none is ruled out as the fix: apps shouldn't have to add it, and it doesn't cover a prepend during iOS momentum.

    A core fix, prototyped and checked on both devices. While a write is impossible (an iOS touch or its momentum tail, or a scroll event reporting the element past either edge), the correction is held: the virtualizer's offset carries the new layout position, while getVirtualItems()[].start and getTotalSize() are reported shifted back by the hold, so whatever the adapter renders stays exactly where it was. Nothing is written while overscrolled. At the first safe moment it writes scrollTop and drops the hold in the same frame. It works in every adapter, because they all render from those values, and replaces _iosDeferredAdjustment.

    Desktop Safari 18.4 with the hold: no freeze, no blank after the prepend, one write once in bounds.

    8724ms --- prepend +12              (mid-bounce, held)
    8728ms st=-31 off=857 blank=0       (rows stay put)
    9062ms st=-1                        (bounce springs back by itself)
    9167ms WRITE to=1054 before=0  landed YES
    

    iPhone 17 Pro, iOS 27.0.1 with the hold: six prepends landing mid-bounce, blank at 0 (±1px rounding) in every frame, no new rows shown and no snap; each hold released with one write ~150ms after the bounce settles.

    The example also loaded twice per bounce (its return to 0 counted as "near the top"). It now loads only while the reader scrolls up into the top.

    Next: one PR with the core hold and the example fix, after #1318 (it builds on its _iosInMotion() gate). Open before then: whether scrollOffset should report element coordinates during a hold, so every public value agrees with what's on screen; a review of the existing tests the hold changes; benchmarks. Tracked in #1307 as part of step 5.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions