You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(virtual-core): 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, 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.
Changes:
- Add CSS offset (marginTop) approach for iOS scroll adjustments
- Defer adjustments through touch→momentum→settled lifecycle
- Force-flush CSS offset before programmatic scroll operations
- Compensate scrollOffset for CSS offset in range calculations
- Guard against Safari elastic overscroll during flush
- Clean up CSS offset on unmount
- Refine backward-scroll suppression: first measurements always
adjust (needed for prepend), re-measurements skip during backward
scroll (avoids scrollTop cascade jank)
- Add overflow-anchor: none to chat example CSS
- Update chat docs with iOS section and getItemKey best practices
feat: iOS momentum-safe scroll adjustments via CSS offset
6
+
7
+
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.
8
+
9
+
- Defer scroll adjustments during iOS touch and momentum phases using CSS offset (`marginTop`/`marginLeft`)
- Compensate `scrollOffset` for active CSS offset in range calculations
12
+
- Guard against Safari elastic overscroll (rubber-band) during flush
13
+
- Clean up CSS offset on unmount
14
+
- Refine backward-scroll suppression: first measurements always adjust regardless of direction; re-measurements skip during backward scroll to avoid the `scrollTop` cascade jank
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.
59
+
52
60
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.
53
61
54
62
### Follow appended output only when pinned
@@ -127,14 +135,23 @@ Use a normal scroll container and normal item order. You do not need `flex-direc
127
135
128
136
## Production Checklist
129
137
130
-
- Use stable message ids with `getItemKey`.
138
+
- Use stable message ids with `getItemKey`, wrapped in `useCallback`.
131
139
- Give the scroll element a fixed height and `overflow: auto`.
140
+
- 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.
132
141
- Call `measureElement` for dynamic message heights.
133
142
- Use `anchorTo: 'end'` for prepend stability and streaming bottom growth.
134
143
- Use `followOnAppend` when new output should follow only from the latest position.
135
144
- Use `isAtEnd()` to show "Jump to latest" UI when the user is reading history.
136
145
- Keep network loading state outside the virtualizer; prepend or append data normally.
137
146
147
+
## iOS Safari
148
+
149
+
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.
150
+
151
+
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.
152
+
153
+
No extra configuration is needed — this is handled automatically on iOS.
0 commit comments