diff --git a/.changeset/eager-scroll-anchor.md b/.changeset/eager-scroll-anchor.md new file mode 100644 index 00000000..fe7cc6ff --- /dev/null +++ b/.changeset/eager-scroll-anchor.md @@ -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. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 7890df33..b90782cd 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -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 + anchorResolved = true + } + } + } + } + + if (anchorResolved || followOnAppend) { this.pendingScrollAnchor = [ - anchor?.[0] ?? null, - anchor?.[1] ?? 0, + anchorResolved ? anchor![0] : null, + anchorResolved ? anchor![1] : 0, followOnAppend, ] } @@ -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, + }) } if (followOnAppend) { diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index e57119a1..0dc72977 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -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', () => {