From 599656dc5b5a8e159b550e83d2dc02c9b39d57b0 Mon Sep 17 00:00:00 2001 From: duongynhi000001-crypto Date: Thu, 5 Nov 2026 07:36:03 +0700 Subject: [PATCH] fix(virtual-core): preserve reading position on lead offset change with anchorTo: 'end' Track lead offset (paddingStart + scrollMargin) across option updates and adjust scrollOffset under anchorTo: 'end' when lead offset changes while preserving bottom-edge pinning and clamping deferred anchors inside the layout pass. Closes #1366 --- .changeset/anchor-end-lead-offset.md | 5 + packages/virtual-core/src/index.ts | 78 +++++++--- .../virtual-core/tests/anchor-end.test.ts | 141 +++++++++++++++++- 3 files changed, 197 insertions(+), 27 deletions(-) create mode 100644 .changeset/anchor-end-lead-offset.md diff --git a/.changeset/anchor-end-lead-offset.md b/.changeset/anchor-end-lead-offset.md new file mode 100644 index 000000000..dc9b6209e --- /dev/null +++ b/.changeset/anchor-end-lead-offset.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): preserve reading position when `paddingStart` or `scrollMargin` changes under `anchorTo: 'end'` diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index ecaaddf7f..67f0b9d70 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -822,6 +822,7 @@ export class Virtualizer< let anchor: [Key, number] | null = null let followOnAppend: ScrollBehavior | null = null let edgeKeysChanged = false + let leadOffsetChanged = false if ( prevOptions !== undefined && @@ -839,6 +840,9 @@ export class Virtualizer< const prevFirstKey = prevCount > 0 ? getPreviousKey(0) : null const prevLastKey = prevCount > 0 ? getPreviousKey(prevCount - 1) : null const didCountChange = nextCount !== prevCount + const didLeadOffsetChange = + merged.paddingStart !== prevOptions.paddingStart || + merged.scrollMargin !== prevOptions.scrollMargin const didEdgeKeysChange = didCountChange || (prevCount > 0 && @@ -846,8 +850,12 @@ export class Virtualizer< (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey)) - if (didEdgeKeysChange) { - edgeKeysChanged = true + if (didEdgeKeysChange || didLeadOffsetChange) { + if (didEdgeKeysChange) { + edgeKeysChanged = true + } else { + leadOffsetChanged = true + } const item = prevCount > 0 ? (this.getVirtualItemForOffset(this.getScrollOffset()) ?? @@ -858,27 +866,30 @@ export class Virtualizer< anchor = [item.key, this.getScrollOffset() - item.start] } - const behavior = - merged.followOnAppend === true - ? 'auto' - : merged.followOnAppend || null + if (didEdgeKeysChange) { + const behavior = + merged.followOnAppend === true + ? 'auto' + : merged.followOnAppend || null - if ( - behavior && - nextCount > 0 && - this.isAtEnd(prevOptions.scrollEndThreshold) && - (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey) - ) { if ( - nextCount > prevCount || - isAppendWithTrim( - prevCount, - nextCount, - getPreviousKey, - merged.getItemKey, - ) + behavior && + nextCount > 0 && + this.isAtEnd(prevOptions.scrollEndThreshold) && + (prevCount === 0 || + merged.getItemKey(nextCount - 1) !== prevLastKey) ) { - followOnAppend = behavior + if ( + nextCount > prevCount || + isAppendWithTrim( + prevCount, + nextCount, + getPreviousKey, + merged.getItemKey, + ) + ) { + followOnAppend = behavior + } } } } @@ -894,6 +905,8 @@ export class Virtualizer< if (edgeKeysChanged) { this.pendingMin = 0 this.itemSizeCacheVersion++ + } else if (leadOffsetChanged) { + this.pendingMin = 0 } // Eagerly adjust scrollOffset so the virtualizer computes the correct @@ -941,15 +954,22 @@ export class Virtualizer< if (followOnAppend) { this.pendingScrollAnchor = { kind: 'follow', behavior: followOnAppend } } else if (anchorDelta !== null) { + const prevDelta = + this.pendingScrollAnchor?.kind === 'anchor' + ? this.pendingScrollAnchor.delta + : 0 + const nextDelta = prevDelta + anchorDelta if (this._canWriteScroll()) { - this.pendingScrollAnchor = { kind: 'anchor', delta: anchorDelta } + this.pendingScrollAnchor = + nextDelta === 0 ? null : { kind: 'anchor', delta: nextDelta } } else { + this.pendingScrollAnchor = null // The element cannot take the write (a touch owns it on iOS, or it is // overscrolled). Hold the delta instead: this render already exposes // the rows and the total shifted back by it, so the reader's row // stays put, and the flush writes it once a write is possible. - this._holdOffset += anchorDelta - this._followOffsetTarget(anchorDelta) + this._holdOffset += nextDelta + this._followOffsetTarget(nextDelta) } } } @@ -1298,7 +1318,17 @@ export class Virtualizer< // reconcile not yet retired it) still gets the anchor sync. } else { this._followOffsetTarget(anchor.delta) - this._scrollToOffset(this.getScrollOffset()) + const target = this.getScrollOffset() + const layoutMax = Math.max( + this._getLayoutTotalSize() + + this.options.scrollMargin - + this.getSize(), + 0, + ) + if (target <= layoutMax + 0.5) { + this._clampedAdjustment = this._clampedBy(target) + } + this._scrollToOffset(target) } } else { this.scrollToEnd({ behavior: anchor.behavior }) diff --git a/packages/virtual-core/tests/anchor-end.test.ts b/packages/virtual-core/tests/anchor-end.test.ts index 0a95bfb73..07164bd64 100644 --- a/packages/virtual-core/tests/anchor-end.test.ts +++ b/packages/virtual-core/tests/anchor-end.test.ts @@ -14,6 +14,7 @@ function createChatVirtualizer({ itemSize = 50, followOnAppend = false, threshold = 1, + paddingStart = 0, paddingEnd = 0, scrollMargin = 0, }: { @@ -23,10 +24,13 @@ function createChatVirtualizer({ itemSize?: number followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' threshold?: number + paddingStart?: number paddingEnd?: number scrollMargin?: number }) { let currentMessages = messages + let currentPaddingStart = paddingStart + let currentScrollMargin = scrollMargin const scrollToFn = vi.fn() let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null const scrollElement = { @@ -34,7 +38,11 @@ function createChatVirtualizer({ scrollLeft: 0, // The sizer is `getTotalSize()`, which includes paddingEnd. Anything // above the list inside the same scroller adds `scrollMargin` on top. - scrollHeight: scrollMargin + messages.length * itemSize + paddingEnd, + scrollHeight: + currentScrollMargin + + currentPaddingStart + + messages.length * itemSize + + paddingEnd, scrollWidth: 1000, clientHeight: viewportSize, clientWidth: 400, @@ -84,8 +92,9 @@ function createChatVirtualizer({ anchorTo: 'end' as const, followOnAppend, scrollEndThreshold: threshold, + paddingStart: currentPaddingStart, paddingEnd, - scrollMargin, + scrollMargin: currentScrollMargin, } } @@ -102,7 +111,30 @@ function createChatVirtualizer({ currentMessages = nextMessages virtualizer.setOptions(makeOptions()) ;(scrollElement as any).scrollHeight = - scrollMargin + nextMessages.length * itemSize + paddingEnd + currentScrollMargin + + currentPaddingStart + + nextMessages.length * itemSize + + paddingEnd + virtualizer._willUpdate() + }, + setPaddingStart(nextPaddingStart: number) { + currentPaddingStart = nextPaddingStart + virtualizer.setOptions(makeOptions()) + ;(scrollElement as any).scrollHeight = + currentScrollMargin + + nextPaddingStart + + currentMessages.length * itemSize + + paddingEnd + virtualizer._willUpdate() + }, + setScrollMargin(nextScrollMargin: number) { + currentScrollMargin = nextScrollMargin + virtualizer.setOptions(makeOptions()) + ;(scrollElement as any).scrollHeight = + nextScrollMargin + + currentPaddingStart + + currentMessages.length * itemSize + + paddingEnd virtualizer._willUpdate() }, // Simulate the browser's scroll event after a write (or a gesture). @@ -113,6 +145,36 @@ function createChatVirtualizer({ } } +test('anchorTo:end keeps visible content stable when only paddingStart changes (#1366)', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { scrollToFn, setPaddingStart } = createChatVirtualizer({ + messages, + offset: 100, + paddingStart: 0, + }) + + setPaddingStart(50) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset] = scrollToFn.mock.calls[0]! + expect(offset).toBe(150) +}) + +test('anchorTo:end keeps visible content stable when only scrollMargin changes (#1366)', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { scrollToFn, setScrollMargin } = createChatVirtualizer({ + messages, + offset: 100, + scrollMargin: 0, + }) + + setScrollMargin(40) + + expect(scrollToFn).toHaveBeenCalledTimes(1) + const [offset] = scrollToFn.mock.calls[0]! + expect(offset).toBe(140) +}) + test('anchorTo:end keeps visible content stable when older items are prepended', () => { const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) const { setMessages, scrollToFn } = createChatVirtualizer({ @@ -700,3 +762,76 @@ test('#1258: cleanup drops a pending clamped write', () => { expect(virtualizer['_clampedAdjustment']).toBeNull() }) + +test('#1366: a reader inside paddingStart keeps the first item when paddingStart grows or shrinks', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { scrollToFn, setPaddingStart } = createChatVirtualizer({ + messages, + offset: 33, + viewportSize: 300, + itemSize: 100, + paddingStart: 40, + }) + + // m-0 was at viewport y 7 (40 - 33); when paddingStart grows to 80, it stays there at offset 73. + setPaddingStart(80) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(73) + scrollToFn.mockClear() + + // Shrinking paddingStart below the negative anchor offset clamps once at 0. + setPaddingStart(0) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(0) +}) + +test('#1366: paddingStart change in its own update after a prepend keeps the anchored item', () => { + const messages = Array.from({ length: 20 }, (_, i) => ({ id: `m-${i}` })) + const { setMessages, setPaddingStart, scrollToFn } = createChatVirtualizer({ + messages, + offset: 250, + viewportSize: 300, + itemSize: 100, + paddingStart: 40, + }) + + // Update 1: prepend 8 items (m-2 moves from 240 to 1040 -> offset 1050), no scroll read-back yet. + const prepended = [ + ...Array.from({ length: 8 }, (_, i) => ({ id: `p-${i}` })), + ...messages, + ] + setMessages(prepended) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(1050) + scrollToFn.mockClear() + + // Update 2: paddingStart grows 40 -> 80 in its own update -> offset 1090. + setPaddingStart(80) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(1090) +}) + +test('#1366: clamped paddingStart anchor write retries once the sizer grows', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn, emitScroll } = + createChatVirtualizer({ + messages, + offset: 90, + viewportSize: 200, + itemSize: 50, + paddingStart: 40, + }) + + // Consumer updates paddingStart to 90, but commits _willUpdate before growing scrollHeight (still 290, max 90). + virtualizer.setOptions({ ...virtualizer.options, paddingStart: 90 }) + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(140) + + // Browser clamps write at old max (90), then sizer grows to 340 (max 140). + emitScroll(90) + ;(scrollElement as any).scrollHeight = 340 + virtualizer._willUpdate() + expect(scrollToFn).toHaveBeenCalledTimes(2) + expect(scrollToFn.mock.calls[1]![0]).toBe(140) +})