iOS scroll-adjustment deferral engages for programmatic scrolls, making scrollToIndex landings paint sagged then snap
メンテナーはふだん 1 日以内に返信
評価
この issue はまだ評価されていません。
説明
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)→scrollTopwrite →scrollevent →isScrolling = true. - The newly mounted rows measure. Any row whose real height differs from
estimateSizeproduces an above-fold delta →applyScrollAdjustment(delta). - Because
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. - The frame paints uncompensated — the list sits offset by the accumulated delta.
isScrollingflips false ~isScrollingResetDelaylater →_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
isScrollingResetDelaylater). - 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.
- 主要言語
- TypeScript
- スター
- 7.1k
- フォーク
- 472
- 平均マージ
- 1日 2時間
- マージ済み PR(30日)
- 12
環境構築
- Dockerfile・Docker Compose ファイルなし
- プルリクエストのテンプレートあり
- コントリビューションガイドを読む
はじめの一歩
- issue を最後まで読み、次にプロジェクトのコントリビューションガイドを読みます。
- 着手することを issue にコメントします — 二人が同じ作業をするのを防げます。
- リポジトリをフォークし、ブランチを切って変更します。
- issue 番号を参照したプルリクエストを送ります。
TanStack/virtual のほかの issue
-
measureElement caches 0 size for nodes that aren't attached yet (Vue: rows stack at start 0 and the list scrolls away on mount)対応中かも @webn00b が 9 日前に担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
メンテナーはふだん 1 日以内に返信
-
`scrollToIndex(last, { align: 'end' })` stays short of the end after a measured row grows (virtual-core ≥ 3.17.0)対応中かも @minwookshin が 6 日前に担当しました。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 55/100
TanStack/virtual#1290 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
virtual-core: desktop Safari drops the prepend anchor write when history lands during the top rubber-band bounce対応中かも このイシューにリンクされたプルリクエストがオープン中、またはマージ済みです。 オープン
難易度 5/5 1週間以上 初心者へのやさしさ 38/100
メンテナーはふだん 1 日以内に返信
-
難易度 4/5 3〜5日 初心者へのやさしさ 62/100
メンテナーはふだん 1 日以内に返信
-
marko-virtual: scrollToEnd during an in-flight prepend strands the view one prepend above the bottom (chat-pretext e2e fails on CI)再び着手できるかも このイシューのプルリクエストはマージされずにクローズされました。 オープン
難易度 4/5 3〜5日 初心者へのやさしさ 55/100
メンテナーはふだん 1 日以内に返信
TanStack/virtual の issue をすべて見る
似ている issue
-
bug
難易度 2/5 1〜3時間 初心者へのやさしさ 72/100
AOSSIE-Org/DebateAI#611 ·
メンテナーはふだん 3 日以内に返信
-
難易度 2/5 1〜3時間 初心者へのやさしさ 65/100
openzim/mwoffliner#2933 ·
メンテナーはふだん 1 日以内に返信
-
Use the README category name for website links and submissions対応中かも @dajiaohuang が今日担当しました。 オープン
難易度 2/5 1〜3時間 初心者へのやさしさ 78/100
birobirobiro/awesome-shadcn-ui#647 ·
メンテナーはふだん 2 日以内に返信
-
Twake Drive picker: closePicker() never destroys the intent (stop() is on the promise returned by start(), not by create())対応中かも @chibenwa が今日担当しました。 オープンclaude
難易度 2/5 1〜3時間 初心者へのやさしさ 84/100
linagora/twake-calendar-frontend#1498 · コメント 1 件 ·
メンテナーはふだん 1 日以内に返信
-
check:passed streams:add
難易度 2/5 1〜3時間 初心者へのやさしさ 68/100
メンテナーはふだん 1 日以内に返信