diff --git a/.changeset/resize-adjust-scrolloffset-sync.md b/.changeset/resize-adjust-scrolloffset-sync.md new file mode 100644 index 00000000..09548bdc --- /dev/null +++ b/.changeset/resize-adjust-scrolloffset-sync.md @@ -0,0 +1,7 @@ +--- +'@tanstack/virtual-core': patch +--- + +Sync `scrollOffset` in `applyScrollAdjustment` so end-anchored streaming resize isn't lost to browser clamp + +With `anchorTo: 'end'` and a dynamically growing last item (token streaming), `resizeItem` writes the scroll adjustment to `scrollTop` before the consumer has grown the sizer, so the browser clamps the write and no scroll event fires. `scrollOffset` stayed stale, the next tick's `wasAtEnd` check failed, and the viewport drifted away from the end. This fix carries the intended target in `scrollOffset` (zeroing `scrollAdjustments`) the same way the prepend path in `setOptions` does, so the next `getVirtualDistanceFromEnd()` reads the post-adjustment position. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 7af791d1..9a0bb91a 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -692,6 +692,19 @@ export class Virtualizer< adjustments: (this.scrollAdjustments += delta), behavior, }) + // Eagerly carry the intended target in `scrollOffset` so callers that + // read it before the next scroll event — notably the next `resizeItem` + // tick's `getVirtualDistanceFromEnd()` / `wasAtEnd` check — see the + // post-adjustment position even when the DOM `scrollTop` write was + // clamped because the consumer hasn't grown the sizer yet (`notify()` + // runs after this in `resizeItem`). Same idea as the eager + // `scrollOffset` adjustment for prepend in `setOptions` (#1176). The + // adjustment is now baked into `scrollOffset`, so zero + // `scrollAdjustments` to keep their sum invariant. + if (this.scrollOffset !== null) { + this.scrollOffset += this.scrollAdjustments + this.scrollAdjustments = 0 + } } } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 341925ab..c1276685 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -2380,6 +2380,33 @@ test('anchorTo:end keeps a pinned streaming message pinned as it grows', () => { expect(options.adjustments).toBe(70) }) +test('anchorTo:end stays pinned across consecutive resizes when the scrollTop write is clamped', () => { + const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` })) + const { virtualizer, scrollToFn } = createChatVirtualizer({ + messages, + offset: 50, + }) + + // First growth tick. The DOM `scrollTop` write may be clamped because the + // consumer hasn't grown the sizer yet (`notify()` runs after the + // adjustment in `resizeItem`), so no scroll event fires — `scrollToFn` + // here is a no-op mock, mirroring that. + virtualizer.resizeItem(4, 120) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70) + scrollToFn.mockClear() + + // Second growth tick with no scroll event in between. Before the fix, + // `getVirtualDistanceFromEnd()` would compute against the stale + // `scrollOffset` (50) and a grown `getTotalSize()` (320), conclude we had + // drifted 70 px from the end (> `scrollEndThreshold: 1`), and skip the + // adjustment — drifting forever from tick 2 onward. + virtualizer.resizeItem(4, 200) + expect(scrollToFn).toHaveBeenCalledTimes(1) + expect(scrollToFn.mock.calls[0]![0]).toBe(120) + expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(80) +}) + test('anchorTo:end does not follow streaming growth when user is away from end', () => { const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` })) const { virtualizer, scrollToFn } = createChatVirtualizer({