Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changeset/eager-scroll-anchor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@tanstack/virtual-core': patch
---

Eagerly adjust scrollOffset on prepend to prevent one-frame jump with anchorTo: 'end'

When items are prepended with `anchorTo: 'end'` and dynamic sizes, the virtualizer would compute the wrong visible range for one frame (using stale estimate-based positions) and then correct in the next frame via `_willUpdate`, producing a visible jump. This fix eagerly adjusts `scrollOffset` in `setOptions` during the render pass so `calculateRange`/`getVirtualItems` return the correct items immediately.
61 changes: 41 additions & 20 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -592,10 +592,37 @@ export class Virtualizer<

this.options = merged

if (anchor || followOnAppend) {
// Eagerly adjust scrollOffset so the virtualizer computes the correct
// visible range during the current render pass — before _willUpdate
// syncs the DOM scroll position in a layout effect. Without this,
// the virtualizer would render the wrong items for one frame (the
// estimate-based positions are stale) and then correct in the next
// frame, producing a visible "jump" on prepend with dynamic sizes.
let anchorResolved = false
if (anchor && this.scrollOffset !== null) {
const [anchorKey, anchorOffset] = anchor
const newMeasurements = this.getMeasurements()
const { count, getItemKey } = this.options
let idx = 0
while (idx < count && getItemKey(idx) !== anchorKey) {
idx++
}
if (idx < count) {
const anchorItem = newMeasurements[idx]
if (anchorItem) {
const newOffset = anchorItem.start + anchorOffset
if (newOffset !== this.scrollOffset) {
this.scrollOffset = newOffset
Comment on lines +604 to +615
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Force a fresh measurement rebuild before reading anchorItem.start.

If count stays the same and the caller keeps a stable getItemKey reference, getMeasurements() can still be serving the previous layout here. idx is found from the new key order, but newMeasurements[idx].start can still belong to the old item/order, so the eager correction picks the wrong offset for prepend+trim or other edge-key swaps with dynamic sizes. Invalidate measurements on edge-key changes, or include key identity in the measurement memo deps before using newMeasurements[idx].

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 604 - 615, The code may read
stale measurement data when computing newOffset via newMeasurements[idx].start;
before using newMeasurements[idx] ensure measurements are rebuilt for the
current key ordering: either call your measurement invalidation/rebuild helper
(e.g., this.invalidateMeasurements() or this.rebuildMeasurements()) or include
the item key identity in the measurement memo so getMeasurements() returns a
fresh array for the current getItemKey order; specifically, after computing idx
using getItemKey and before accessing newMeasurements[idx].start, force a
measurements refresh (or verify newMeasurements[idx] corresponds to
getItemKey(idx) and rebuild if it does not) so this.scrollOffset is computed
from up-to-date measurements.

anchorResolved = true
}
}
}
}

if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
anchor?.[0] ?? null,
anchor?.[1] ?? 0,
anchorResolved ? anchor![0] : null,
anchorResolved ? anchor![1] : 0,
followOnAppend,
]
}
Expand Down Expand Up @@ -798,23 +825,17 @@ export class Virtualizer<
this.pendingScrollAnchor = null

if (anchor && this.scrollElement && this.options.enabled) {
const [key, offset, followOnAppend] = anchor

if (key !== null) {
const { count, getItemKey } = this.options
let index = 0
while (index < count && getItemKey(index) !== key) {
index++
}

const item = index < count ? this.getMeasurements()[index] : undefined
if (item) {
const delta = item.start + offset - this.getScrollOffset()

if (!approxEqual(delta, 0)) {
this.applyScrollAdjustment(delta)
}
}
const [key, _offset, followOnAppend] = anchor

if (key !== null && !followOnAppend) {
// scrollOffset was eagerly adjusted in setOptions so the
// virtualizer already computed the correct range during render.
// Now sync the browser's actual scroll position to match.
// Skip when followOnAppend is set — scrollToEnd will handle it.
this._scrollToOffset(this.getScrollOffset(), {
adjustments: undefined,
behavior: undefined,
})
Comment on lines +830 to +838
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve the iOS deferral path for pending anchor sync.

This direct _scrollToOffset call skips applyScrollAdjustment, so prepend-triggered anchor sync can still write scrollTop during iOS touch/momentum and cancel the in-flight scroll. The eager logical scrollOffset fix is good, but the DOM sync still needs the same _iosDeferredAdjustment gating as the other mid-scroll corrections.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/virtual-core/src/index.ts` around lines 830 - 838, The direct call
to _scrollToOffset bypasses the iOS deferral path and can be cancelled by a
prepend-triggered anchor sync; instead, gate this DOM sync through the same iOS
deferral used elsewhere or use the existing applyScrollAdjustment path. Replace
the direct this._scrollToOffset(this.getScrollOffset(), ...) with the code path
that honors _iosDeferredAdjustment (i.e., call applyScrollAdjustment / the
helper that defers/queues adjustments when _iosDeferredAdjustment is set) so the
scroll write is deferred during iOS touch/momentum; ensure you still pass the
same offset from getScrollOffset() and preserve the adjustments/behavior options
when delegating.

}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (followOnAppend) {
Expand Down
4 changes: 2 additions & 2 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2275,8 +2275,8 @@ test('anchorTo:end keeps visible content stable when older items are prepended',

expect(scrollToFn).toHaveBeenCalledTimes(1)
const [offset, options] = scrollToFn.mock.calls[0]!
expect(offset).toBe(100)
expect(options.adjustments).toBe(100)
expect(offset).toBe(200)
expect(options.adjustments).toBeUndefined()
})

test('anchorTo:end does not yank a scrolled-up user when items append', () => {
Expand Down
Loading