Skip to content

iOS scroll-adjustment deferral engages for programmatic scrolls, making scrollToIndex landings paint sagged then snap #1250

Description

@fidalgodev

Describe the bug

On iOS-family WebKit, the iOS scroll-adjustment deferral engages for programmatic scrolls, not just touch-driven ones. The result is that dynamic-measurement compensation which would normally apply pre-paint is deferred past a paint, so a scrollToIndex/scrollToOffset landing paints at a visibly wrong offset and then snaps into place a beat later.

The deferral gate is isScrolling, and isScrolling carries no provenance — observeOffset sets it from any scroll event, including the ones a programmatic scroll write generates itself:

https://github.com/TanStack/virtual/blob/main/packages/virtual-core/src/index.ts — verified in 3.17.7:

// observeOffset — any scroll event, no user/programmatic distinction
const createHandler = (isScrolling) => () => {
  offset = readOffset(element)
  fallback?.()
  cb(offset, isScrolling)   // handler = createHandler(true)
}
element.addEventListener('scroll', handler, addEventListenerOptions)
// applyScrollAdjustment
if (isIOSWebKit() && (this.isScrolling || this._iosTouching || this._iosJustTouchEnded)) {
  this._iosDeferredAdjustment += delta
  return false
}

So the sequence for a programmatic landing is:

  1. App calls scrollToIndex(deepIndex)scrollTop write → scroll event → isScrolling = true.
  2. The newly mounted rows measure. Any row whose real height differs from estimateSize produces an above-fold delta → applyScrollAdjustment(delta).
  3. Because isScrolling is true, every one of those adjustments is deferred instead of applied. Off iOS (and on iOS before this code path existed) they are applied synchronously inside the ResizeObserver callback, i.e. pre-paint.
  4. The frame paints uncompensated — the list sits offset by the accumulated delta.
  5. isScrolling flips false ~isScrollingResetDelay later → _flushIosDeferredIfReady() applies the whole backlog in one write → visible snap.

The deferral exists to protect touch momentum (#884 — writing scrollTop mid-fling cancels it), which is completely reasonable. But a programmatic scroll has no momentum to protect, so on that path the deferral has no upside and produces a user-visible artifact. Touch provenance is already tracked (_iosTouching, _iosJustTouchEnded); it is the bare isScrolling term that over-captures.

Your minimal, reproducible example

I could not provide a hosted sandbox link that demonstrates this: the code path is gated behind isIOSWebKit(), so it only runs when the browser reports an iPhone/iPad UA (or touch-capable MacIntel). A CodeSandbox/StackBlitz preview opened on a desktop UA silently takes the non-iOS branch and looks correct.

The repro below is self-contained and dependency-free beyond react + @tanstack/react-virtual — drop it into any of the official examples and open it under the conditions in "Steps to reproduce".

import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef } from 'react'

const COUNT = 500
const LANDING_INDEX = 300
const ESTIMATE = 50
// Every row renders TALLER than estimateSize, so each measurement above the
// landing offset produces a real scroll-anchoring correction. (In our app this
// delta is not synthetic — WebKit's line-box rounding makes the same rows
// measure ~1px taller than they do in Chromium, which is how we hit it.)
const ACTUAL = 58

export default function App() {
  const parentRef = useRef<HTMLDivElement>(null)

  const virtualizer = useVirtualizer({
    count: COUNT,
    getScrollElement: () => parentRef.current,
    estimateSize: () => ESTIMATE,
  })

  // The "landing": one programmatic jump to a deep index on mount.
  useEffect(() => {
    virtualizer.scrollToIndex(LANDING_INDEX, { align: 'start' })
  }, [])

  return (
    <div ref={parentRef} style={{ height: 600, overflowY: 'auto' }}>
      <div
        style={{
          height: virtualizer.getTotalSize(),
          position: 'relative',
          width: '100%',
        }}
      >
        {virtualizer.getVirtualItems().map((item) => (
          <div
            key={item.key}
            data-index={item.index}
            ref={virtualizer.measureElement}
            style={{
              position: 'absolute',
              top: 0,
              left: 0,
              width: '100%',
              height: ACTUAL, // ← taller than estimateSize
              transform: `translateY(${item.start}px)`,
              borderBottom: '1px solid #ddd',
            }}
          >
            Row {item.index}
          </div>
        ))}
      </div>
    </div>
  )
}

Steps to reproduce

  1. Serve the example above and open it in iOS Safari — either a real iPhone/iPad, or desktop Safari's Responsive Design Mode with an iPhone selected (RDM spoofs the UA, which is what satisfies isIOSWebKit()).
  2. Load the page and watch row 300 as it lands. Do not touch the screen — the whole point is that this is a purely programmatic scroll.
  3. Observe: the list first paints with row 300 offset from where it belongs, then snaps to the correct position shortly after (roughly isScrollingResetDelay later).
  4. For contrast, open the same page in Chrome, or in Safari on a desktop UA: the landing is correct on the first painted frame, because the adjustments are applied synchronously in the ResizeObserver callback rather than deferred.

Expected behavior

A programmatic scroll should land compensated on its first painted frame on iOS, exactly as it does on other engines — no sag-then-snap. Deferral should apply to scrolls where a real touch is involved (where cancelling momentum is the concern it was introduced for), not to scrolls the application initiated itself.

How often does this bug happen?

Every time

Screenshots or Videos

Measured in our app with a per-animation-frame sampler recording the landing anchor's viewport offset (WebKit, iPhone UA, 390×874, deferral engaged):

anchor transient vs settled sag frame painted?
deferral engaged 4.0px yes — lands 8.0 → 12.8 → settles 8.8
deferral not engaged 0.8px no — 8.0 → 8.8 directly

That was with a deliberately synthesized measurement delta to make it deterministic. The original user-reported artifact in the real app was a ~10 CSS px whole-list snap, which is very visible.

Platform

  • OS: macOS 26.5.2 (host) — reproduced on iOS Safari (real device) and Safari Responsive Design Mode; also reproduced deterministically in Playwright WebKit 26.5 with an iPhone 13 device profile
  • Browser: Safari / WebKit
  • Chromium and Firefox are unaffected (the code path is iOS-gated)

tanstack-virtual version

@tanstack/react-virtual v3.14.9 (@tanstack/virtual-core v3.17.6). Re-verified the code paths above against virtual-core v3.17.7 — unchanged. Bumping 3.17.6 → 3.17.7 does not change the behavior.

TypeScript version

v6.0.3

Additional context

On a possible fix. The obvious narrowing — dropping the bare isScrolling term and gating on _iosTouching || _iosJustTouchEnded — is not sufficient on its own: momentum continues well past touchend, while _iosJustTouchEnded is only true for a 150ms window, so isScrolling is presumably in the gate precisely to cover the rest of the fling. Removing it would reintroduce #884.

What seems to be missing is touch provenance for the whole scroll sequence rather than a short timer: set a flag on touchstart, keep it set for as long as isScrolling remains true, and clear it when the scroll fully settles. Deferring on that flag would cover the entire momentum phase (fixing #884 as designed) while leaving app-initiated scrolls — which cannot have momentum to cancel — on the synchronous path. Happy to open a PR along those lines if that direction seems right.

Relationship to #1189. I found the draft PR "feat(virtual-core): iOS momentum-safe scroll adjustments via CSS offset", which replaces deferral with a CSS marginTop compensation. If that lands, it plausibly removes this symptom as a side effect, since the compensation would be visible immediately rather than withheld until flush. It does list "force-flush CSS offset before programmatic scroll operations", which addresses the inverse direction (a backlog existing before a programmatic scroll starts) rather than adjustments arriving during one — so I wanted to report this case explicitly in case it is not already covered. It has been a draft since June, so I did not want to assume it was the intended path here.

Workaround, for anyone hitting this. We keep the deferral disengaged for a bounded window after our own landing writes: a requestAnimationFrame tick clears isScrolling on the instance and flushes any backlog, stopping permanently as soon as a real touch is observed. rAF runs before ResizeObserver callbacks within a frame, so measurements arriving later in the same frame compensate pre-paint again. It works, but it pokes at private fields (_iosDeferredAdjustment, _flushIosDeferredIfReady, isScrolling), which is exactly why a supported behavior upstream would be better.

One caveat worth flagging for anyone writing a similar workaround: isScrolling is not only the deferral gate — the React adapter also uses it to choose flushSync over an async re-render on notify. Clearing it on engines where the deferral does not even engage makes rows lag a frame behind real scrolls. We ended up gating our workaround on a narrower environment predicate than isIOSWebKit() itself, because Chromium's mobile emulation on a macOS host reports platform === 'MacIntel' with maxTouchPoints > 0 and therefore satisfies isIOSWebKit() while running Blink.

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