diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index dc6f1010..2d9cbd15 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1707,9 +1707,16 @@ export class Virtualizer< } } + private getVirtualMaxScrollOffset = () => { + return Math.max( + this.getTotalSize() - this.options.paddingEnd - this.getSize(), + 0, + ) + } + private getVirtualDistanceFromEnd = () => { return Math.max( - this.getTotalSize() - this.getSize() - this.getScrollOffset(), + this.getVirtualMaxScrollOffset() - this.getScrollOffset(), 0, ) } @@ -1878,7 +1885,7 @@ export class Virtualizer< return } - this.scrollToOffset(Math.max(this.getTotalSize() - this.getSize(), 0), { + this.scrollToOffset(this.getVirtualMaxScrollOffset(), { behavior, }) } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 05a67466..c3c081cd 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2879,6 +2879,7 @@ function createChatVirtualizer({ itemSize = 50, followOnAppend = false, threshold = 1, + paddingEnd = 0, }: { messages: Array<{ id: string }> offset: number @@ -2886,6 +2887,7 @@ function createChatVirtualizer({ itemSize?: number followOnAppend?: boolean | 'auto' | 'smooth' | 'instant' threshold?: number + paddingEnd?: number }) { let currentMessages = messages const scrollToFn = vi.fn() @@ -2941,6 +2943,7 @@ function createChatVirtualizer({ anchorTo: 'end' as const, followOnAppend, scrollEndThreshold: threshold, + paddingEnd, } } @@ -3753,3 +3756,33 @@ test('#1218: first measurement of a spanning item still compensates', () => { expect(v.scrollOffset).toBe(before + 70) }) + +// ─── #1258: paddingEnd must not break anchorTo:'end' streaming growth ───────── +// When paddingEnd > 0, getVirtualDistanceFromEnd() was including paddingEnd in +// its calculation, making it always report a distance >= paddingEnd even when +// the viewport was scrolled to the absolute bottom. This caused wasAtEnd to be +// false in resizeItem, so streaming growth was never followed. + +test('#1258: anchorTo:end keeps a pinned streaming message pinned as it grows with paddingEnd', () => { + // 5 items × 50px = 250px scrollHeight, 200px viewport → maxScrollOffset = 50 + // paddingEnd = 80, scrollEndThreshold = 0 (default) + // User is at the DOM bottom: scrollTop = 50, getDistanceFromEnd() = 0 + // Without the fix: getVirtualDistanceFromEnd() = getTotalSize(330) - size(200) - offset(50) + // = 80 > threshold(0), so wasAtEnd = false and resizeItem skips the + // end-anchor adjustment — the viewport drifts as the item grows. + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollElement, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + paddingEnd: 80, + threshold: 0, + }) + + // Last item grows from 50 → 120px (+70); scrollHeight reflects the growth + ;(scrollElement as any).scrollHeight = 320 + virtualizer.resizeItem(4, 120) + + // resizeItem end-anchor must fire the scroll adjustment + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) +})