Skip to content
Merged
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/resize-adjust-scrolloffset-sync.md
Original file line number Diff line number Diff line change
@@ -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.
13 changes: 13 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
}

Expand Down
27 changes: 27 additions & 0 deletions packages/virtual-core/tests/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
Loading