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:
- App calls
scrollToIndex(deepIndex) → scrollTop write → scroll event → isScrolling = true.
- The newly mounted rows measure. Any row whose real height differs from
estimateSize produces an above-fold delta → applyScrollAdjustment(delta).
- 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.
- The frame paints uncompensated — the list sits offset by the accumulated delta.
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
- 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()).
- 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.
- Observe: the list first paints with row 300 offset from where it belongs, then snaps to the correct position shortly after (roughly
isScrollingResetDelay later).
- 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.
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/scrollToOffsetlanding paints at a visibly wrong offset and then snaps into place a beat later.The deferral gate is
isScrolling, andisScrollingcarries no provenance —observeOffsetsets it from anyscrollevent, 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:So the sequence for a programmatic landing is:
scrollToIndex(deepIndex)→scrollTopwrite →scrollevent →isScrolling = true.estimateSizeproduces an above-fold delta →applyScrollAdjustment(delta).isScrollingis 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.isScrollingflips false ~isScrollingResetDelaylater →_flushIosDeferredIfReady()applies the whole backlog in one write → visible snap.The deferral exists to protect touch momentum (#884 — writing
scrollTopmid-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 bareisScrollingterm 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".Steps to reproduce
isIOSWebKit()).isScrollingResetDelaylater).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):
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
tanstack-virtual version
@tanstack/react-virtualv3.14.9 (@tanstack/virtual-corev3.17.6). Re-verified the code paths above againstvirtual-corev3.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
isScrollingterm and gating on_iosTouching || _iosJustTouchEnded— is not sufficient on its own: momentum continues well pasttouchend, while_iosJustTouchEndedis only true for a 150ms window, soisScrollingis 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 asisScrollingremains 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
marginTopcompensation. 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
requestAnimationFrametick clearsisScrollingon 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:
isScrollingis not only the deferral gate — the React adapter also uses it to chooseflushSyncover 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 thanisIOSWebKit()itself, because Chromium's mobile emulation on a macOS host reportsplatform === 'MacIntel'withmaxTouchPoints > 0and therefore satisfiesisIOSWebKit()while running Blink.