Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .changeset/ios-momentum-scroll.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@tanstack/virtual-core': minor
---

feat: iOS momentum-safe scroll adjustments via CSS offset

On iOS WebKit, writing `scrollTop` during momentum scroll cancels the in-flight scroll. Instead of writing `scrollTop`, we now apply a negative `marginTop` on the container element to visually compensate for above-viewport size changes. The CSS offset is flushed to a real `scrollTop` write once momentum fully settles.

- Defer scroll adjustments during iOS touch and momentum phases using CSS offset (`marginTop`/`marginLeft`)
- Force-flush CSS offset before programmatic scroll operations (`scrollToIndex`, `scrollToOffset`, `scrollBy`)
- Compensate `scrollOffset` for active CSS offset in range calculations
- Guard against Safari elastic overscroll (rubber-band) during flush
- Clean up CSS offset on unmount
- Refine backward-scroll suppression: first measurements always adjust regardless of direction; re-measurements skip during backward scroll to avoid the `scrollTop` cascade jank
23 changes: 20 additions & 3 deletions docs/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ const virtualizer = useVirtualizer({
count: messages.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 72,
getItemKey: (index) => messages[index]!.id,
getItemKey: React.useCallback(
(index: number) => messages[index]!.id,
[messages],
),
anchorTo: 'end',
followOnAppend: true,
scrollEndThreshold: 80,
Expand Down Expand Up @@ -46,9 +49,14 @@ setMessages((current) => [...olderMessages, ...current])
Stable keys are required for this to work:

```tsx
getItemKey: (index) => messages[index]!.id
getItemKey: React.useCallback(
(index: number) => messages[index]!.id,
[messages],
),
```

Wrap getItemKey in useCallback so the virtualizer maintains a stable getItemKey reference. Without it, a new function identity can cause memoized measurement options to be recomputed, leading to unnecessary measurement rebuilds/cache invalidation.

Do not use index keys for chat history. After a prepend, every existing message shifts to a new index, so index keys cannot identify the same message across the update.

### Follow appended output only when pinned
Expand Down Expand Up @@ -127,14 +135,23 @@ Use a normal scroll container and normal item order. You do not need `flex-direc

## Production Checklist

- Use stable message ids with `getItemKey`.
- Use stable message ids with `getItemKey`, wrapped in `useCallback`.
- Give the scroll element a fixed height and `overflow: auto`.
- Set `overflow-anchor: none` on the scroll element. Browsers that support native scroll anchoring (Chrome, Firefox) will otherwise fight the virtualizer's own offset adjustments on prepend, causing jumps. Safari does not support `overflow-anchor`, so this has no effect there.
- Call `measureElement` for dynamic message heights.
- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth.
- Use `followOnAppend` when new output should follow only from the latest position.
- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history.
- Keep network loading state outside the virtualizer; prepend or append data normally.

## iOS Safari

iOS WebKit cancels momentum (inertia) scrolling whenever JavaScript writes to `scrollTop` or calls `scrollTo()`. This is a platform limitation — there is no opt-out. Since prepend anchoring and item-resize compensation both need to adjust the scroll position, a naïve implementation would kill the scroll mid-flick, making the list feel broken on iPhones and iPads.

TanStack Virtual works around this with a **CSS offset**: when a scroll adjustment is needed during an active touch or momentum phase, the virtualizer applies a negative `marginTop` on the container element instead of writing `scrollTop`. This shifts the content visually without touching the scroll position, so momentum continues uninterrupted. Once the scroll fully settles (no touch, no momentum, no elastic overscroll), the virtualizer flushes the accumulated CSS offset into a single `scrollTop` write and clears the margin.

No extra configuration is needed — this is handled automatically on iOS.

## API Reference

- [`anchorTo`](api/virtualizer#anchorto)
Expand Down
1 change: 1 addition & 0 deletions examples/react/chat/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ button:hover {
.Messages {
min-height: 0;
overflow: auto;
overflow-anchor: none;
width: 100%;
}

Expand Down
83 changes: 72 additions & 11 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,7 @@ export class Virtualizer<
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
) {
this._iosDeferredAdjustment += delta
this._applyIosCssOffset()
} else {
this._scrollToOffset(this.getScrollOffset(), {
adjustments: (this.scrollAdjustments += delta),
Expand All @@ -695,6 +696,25 @@ export class Virtualizer<
}
}

// Apply CSS compensation for deferred iOS scroll adjustments.
// Uses negative marginTop (or marginLeft for horizontal) on the scroll
// element's first child (the container) to visually shift items without
// writing scrollTop — which would cancel iOS momentum scroll.
// Cleared when _flushIosDeferredIfReady writes the real scroll position.
private _applyIosCssOffset = () => {
if (!this.scrollElement || !(this.scrollElement instanceof Element)) return
// TODO: accept a containerElement option so frameworks can pass the
// inner container directly instead of relying on firstElementChild.
const container = this.scrollElement.firstElementChild as HTMLElement | null
if (!container) return
const prop = this.options.horizontal ? 'marginLeft' : 'marginTop'
if (this._iosDeferredAdjustment === 0) {
container.style[prop] = ''
} else {
container.style[prop] = `${-this._iosDeferredAdjustment}px`
}
}

private maybeNotify = memo(
() => {
this.calculateRange()
Expand All @@ -719,7 +739,28 @@ export class Virtualizer<
},
)

// Force-flush any active iOS CSS offset. Unlike _flushIosDeferredIfReady
// this skips the settling guards — used before public scroll operations
// (scrollToIndex, scrollToEnd, etc.) and cleanup.
private _forceFlushIosCssOffset = () => {
if (this._iosDeferredAdjustment === 0) return
const rawBrowserScroll =
(this.scrollOffset ?? 0) - this._iosDeferredAdjustment
const delta = this._iosDeferredAdjustment
this._iosDeferredAdjustment = 0
this._applyIosCssOffset()
this._scrollToOffset(rawBrowserScroll, {
adjustments: (this.scrollAdjustments += delta),
behavior: undefined,
})
}

private cleanup = () => {
// Clear CSS offset before losing the scrollElement reference.
if (this._iosDeferredAdjustment !== 0) {
this._iosDeferredAdjustment = 0
this._applyIosCssOffset()
}
this.unsubs.filter(Boolean).forEach((d) => d!())
this.unsubs = []
this.observer.disconnect()
Expand Down Expand Up @@ -787,6 +828,14 @@ export class Virtualizer<
}
this._intendedScrollOffset = null

// Compensate for iOS CSS offset: the browser's scrollTop is
// the real position, but items are visually shifted by the
// deferred adjustment via CSS marginTop. Add the deferred
// amount so range calculations match the visual state.
if (this._iosDeferredAdjustment !== 0) {
offset += this._iosDeferredAdjustment
}

this.scrollAdjustments = 0
this.scrollDirection = isScrolling
? this.getScrollOffset() < offset
Expand Down Expand Up @@ -877,15 +926,18 @@ export class Virtualizer<
// Skip when followOnAppend is set — scrollToEnd will handle it.
//
// On iOS WebKit, writing scrollTop during touch/momentum cancels
// the in-flight scroll. Defer the DOM sync the same way
// applyScrollAdjustment does — accumulate the delta and let
// _flushIosDeferredIfReady handle it once the scroll settles.
// the in-flight scroll. Instead of writing scrollTop, apply a
// CSS offset (negative marginTop on the container) that
// visually compensates for the stale browser scroll position.
// The CSS offset is flushed to a real scrollTop write once
// momentum settles (_flushIosDeferredIfReady).
if (
isIOSWebKit() &&
(this.isScrolling || this._iosTouching || this._iosJustTouchEnded)
) {
if (anchorDelta !== 0) {
this._iosDeferredAdjustment += anchorDelta
this._applyIosCssOffset()
}
} else {
this._scrollToOffset(this.getScrollOffset(), {
Expand Down Expand Up @@ -915,11 +967,16 @@ export class Virtualizer<
// while in that zone snaps the page back to the clamped value at the
// end of the bounce, often discarding the user's intent. Skip the
// flush; the next in-bounds scroll event will retry.
const cur = this.getScrollOffset()
// Use the raw browser scroll position (scrollOffset includes the
// CSS offset compensation, so subtract it back out).
const cur = (this.scrollOffset ?? 0) - this._iosDeferredAdjustment
const max = this.getMaxScrollOffset()
if (cur < 0 || cur > max) return
const delta = this._iosDeferredAdjustment
this._iosDeferredAdjustment = 0
// Clear the CSS offset (negative marginTop) before writing the
// real scroll position.
this._applyIosCssOffset()
// Roll the deferred delta into the running accumulator so any resize
// landing between now and the resulting scroll event computes from the
// post-flush offset rather than the stale one.
Expand Down Expand Up @@ -1519,14 +1576,15 @@ export class Virtualizer<
delta,
this,
)
: // Default: adjust scrollTop only when the resize is an above-
// viewport item AND we're not actively scrolling backward.
// Adjusting during backward scroll fights the user's scroll
// direction and produces the "items jump while scrolling up"
// jank reported across many issues. Users who want the old
// behavior can pass shouldAdjustScrollPositionOnItemSizeChange.
: // Default: adjust when the resize is an above-viewport item.
// First measurement (!has(key)): always adjust — the item
// has never been sized, so the estimate→actual delta must
// be compensated regardless of scroll direction.
// Re-measurement (has(key)): skip during backward scroll
// to avoid the "items jump while scrolling up" cascade.
itemStart < this.getScrollOffset() + this.scrollAdjustments &&
this.scrollDirection !== 'backward')
(!this.itemSizeCache.has(key) ||
this.scrollDirection !== 'backward'))

if (this.pendingMin === null || index < this.pendingMin) {
this.pendingMin = index
Expand Down Expand Up @@ -1684,6 +1742,7 @@ export class Virtualizer<
toOffset: number,
{ align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
this._forceFlushIosCssOffset()
const offset = this.getOffsetForAlignment(toOffset, align)

const now = this.now()
Expand All @@ -1708,6 +1767,7 @@ export class Virtualizer<
behavior = 'auto',
}: ScrollToIndexOptions = {},
) => {
this._forceFlushIosCssOffset()
index = Math.max(0, Math.min(index, this.options.count - 1))

const offsetInfo = this.getOffsetForIndex(index, initialAlign)
Expand Down Expand Up @@ -1735,6 +1795,7 @@ export class Virtualizer<
delta: number,
{ behavior = 'auto' }: ScrollToOffsetOptions = {},
) => {
this._forceFlushIosCssOffset()
const offset = this.getScrollOffset() + delta
const now = this.now()

Expand Down
62 changes: 52 additions & 10 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2066,10 +2066,10 @@ test('non-iOS: adjustment is applied immediately during scroll (no regression)',
expect(v['_iosDeferredAdjustment']).toBe(0)
})

test('scroll-up jank: backward-scroll skips scroll-position adjustment by default', () => {
// Default behavior change: when an above-viewport item resizes while the
// user is scrolling BACKWARD, we no longer write to scrollTop. This avoids
// the well-known "items jump while scrolling up" jank.
test('scroll adjustment: backward-scroll adjusts on first measurement (no cache)', () => {
// First measurement (item not in itemSizeCache) always adjusts,
// even during backward scroll — the estimate→actual delta must
// be compensated.
const scrollToFn = vi.fn()
let scrollCb: ((o: number, s: boolean) => void) | null = null
const v = new Virtualizer({
Expand All @@ -2087,26 +2087,68 @@ test('scroll-up jank: backward-scroll skips scroll-position adjustment by defaul
observeElementRect: () => {},
observeElementOffset: (_inst, cb) => {
scrollCb = cb
// Simulate user starting at scrollTop=200, then scrolling up to 100.
cb(200, false)
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()
// Now simulate backward scroll: from 200 to 100 (offset decreases).
// Backward scroll: from 200 to 100.
scrollCb!(100, true)
expect(v.scrollDirection).toBe('backward')
scrollToFn.mockClear()

// Resize an above-viewport item while scrolling backward.
// First measurement of item 0 (not in cache) while scrolling backward.
v.resizeItem(0, 100) // item 0 grows by 50px

// Default behavior: no scroll-position adjustment fires.
// First measurement: adjustment fires even during backward scroll.
expect(scrollToFn).toHaveBeenCalled()
})

test('scroll adjustment: backward-scroll skips re-measurement (has cache)', () => {
// Re-measurement (item already in itemSizeCache) skips during
// backward scroll to avoid the scrollTop cascade jank.
const scrollToFn = vi.fn()
let scrollCb: ((o: number, s: boolean) => void) | null = null
const v = new Virtualizer({
count: 10,
estimateSize: () => 50,
getScrollElement: () =>
({
scrollTop: 200,
scrollLeft: 0,
scrollHeight: 500,
clientHeight: 200,
offsetHeight: 200,
}) as any,
scrollToFn,
observeElementRect: () => {},
observeElementOffset: (_inst, cb) => {
scrollCb = cb
cb(200, false)
return () => {}
},
})
v._willUpdate()
v['getMeasurements']()

// First measurement while idle — populates the cache.
v.resizeItem(0, 80)
scrollToFn.mockClear()

// Now scroll backward.
scrollCb!(100, true)
expect(v.scrollDirection).toBe('backward')
scrollToFn.mockClear()

// Re-measurement during backward scroll.
v.resizeItem(0, 100) // grows again

// Re-measurement: no adjustment during backward scroll.
expect(scrollToFn).not.toHaveBeenCalled()
})

test('scroll-up jank: forward-scroll still applies adjustment (no regression)', () => {
test('scroll adjustment: forward-scroll applies adjustment', () => {
const scrollToFn = vi.fn()
let scrollCb: ((o: number, s: boolean) => void) | null = null
const v = new Virtualizer({
Expand Down Expand Up @@ -2141,7 +2183,7 @@ test('scroll-up jank: forward-scroll still applies adjustment (no regression)',
expect(scrollToFn).toHaveBeenCalled()
})

test('scroll-up jank: idle (scrollDirection=null) still applies adjustment', () => {
test('scroll adjustment: idle (scrollDirection=null) applies adjustment', () => {
// When not actively scrolling, adjustment still fires — needed for the
// mount-time measurement storm where items measure before any scroll.
const scrollToFn = vi.fn()
Expand Down
Loading