Skip to content

Commit 2343004

Browse files
authored
fix(virtual-core): account for scrollMargin when deciding the viewport is at the end (#1340)
1 parent c462c61 commit 2343004

3 files changed

Lines changed: 55 additions & 4 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/virtual-core': patch
3+
---
4+
5+
fix(virtual-core): account for scrollMargin when deciding the viewport is at the end

‎packages/virtual-core/src/index.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2269,8 +2269,16 @@ export class Virtualizer<
22692269
}
22702270

22712271
private getVirtualDistanceFromEnd = () => {
2272+
// `_getLayoutTotalSize()` measures the sizer, which starts `scrollMargin`
2273+
// into the scroll content, while `getScrollOffset()` is measured from the
2274+
// top of that content. Lift the total into the element's space before
2275+
// subtracting, or the viewport reads `scrollMargin` px closer to the end
2276+
// than it is and `getDistanceFromEnd()` disagrees by that much.
22722277
return Math.max(
2273-
this._getLayoutTotalSize() - this.getSize() - this.getScrollOffset(),
2278+
this._getLayoutTotalSize() +
2279+
this.options.scrollMargin -
2280+
this.getSize() -
2281+
this.getScrollOffset(),
22742282
0,
22752283
)
22762284
}

‎packages/virtual-core/tests/anchor-end.test.ts‎

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ function createChatVirtualizer({
1515
followOnAppend = false,
1616
threshold = 1,
1717
paddingEnd = 0,
18+
scrollMargin = 0,
1819
}: {
1920
messages: Array<{ id: string }>
2021
offset: number
@@ -23,15 +24,17 @@ function createChatVirtualizer({
2324
followOnAppend?: boolean | 'auto' | 'smooth' | 'instant'
2425
threshold?: number
2526
paddingEnd?: number
27+
scrollMargin?: number
2628
}) {
2729
let currentMessages = messages
2830
const scrollToFn = vi.fn()
2931
let offsetCb: ((offset: number, isScrolling: boolean) => void) | null = null
3032
const scrollElement = {
3133
scrollTop: offset,
3234
scrollLeft: 0,
33-
// The sizer is `getTotalSize()`, which includes paddingEnd.
34-
scrollHeight: messages.length * itemSize + paddingEnd,
35+
// The sizer is `getTotalSize()`, which includes paddingEnd. Anything
36+
// above the list inside the same scroller adds `scrollMargin` on top.
37+
scrollHeight: scrollMargin + messages.length * itemSize + paddingEnd,
3538
scrollWidth: 1000,
3639
clientHeight: viewportSize,
3740
clientWidth: 400,
@@ -82,6 +85,7 @@ function createChatVirtualizer({
8285
followOnAppend,
8386
scrollEndThreshold: threshold,
8487
paddingEnd,
88+
scrollMargin,
8589
}
8690
}
8791

@@ -98,7 +102,7 @@ function createChatVirtualizer({
98102
currentMessages = nextMessages
99103
virtualizer.setOptions(makeOptions())
100104
;(scrollElement as any).scrollHeight =
101-
nextMessages.length * itemSize + paddingEnd
105+
scrollMargin + nextMessages.length * itemSize + paddingEnd
102106
virtualizer._willUpdate()
103107
},
104108
// Simulate the browser's scroll event after a write (or a gesture).
@@ -374,6 +378,40 @@ test('anchorTo:end does not follow streaming growth when user is away from end',
374378
expect(scrollToFn).not.toHaveBeenCalled()
375379
})
376380

381+
test('anchorTo:end does not follow streaming growth when user is away from end, with a scrollMargin', () => {
382+
// Same situation as the test above, with a 150px header sharing the
383+
// scroller. The reader sits 100px above the end, inside the margin.
384+
const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` }))
385+
const { virtualizer, scrollToFn } = createChatVirtualizer({
386+
messages,
387+
offset: 250,
388+
scrollMargin: 150,
389+
})
390+
391+
expect(virtualizer.getDistanceFromEnd()).toBe(100)
392+
expect(virtualizer.isAtEnd()).toBe(false)
393+
394+
virtualizer.resizeItem(7, 120)
395+
396+
expect(scrollToFn).not.toHaveBeenCalled()
397+
})
398+
399+
test('anchorTo:end still pins an end-pinned viewport with a scrollMargin', () => {
400+
const messages = Array.from({ length: 8 }, (_, i) => ({ id: `m-${i}` }))
401+
const { virtualizer, scrollToFn } = createChatVirtualizer({
402+
messages,
403+
offset: 350,
404+
scrollMargin: 150,
405+
})
406+
407+
expect(virtualizer.isAtEnd()).toBe(true)
408+
409+
virtualizer.resizeItem(7, 120)
410+
411+
expect(scrollToFn).toHaveBeenCalledTimes(1)
412+
expect(scrollToFn.mock.calls[0]![1].adjustments).toBe(70)
413+
})
414+
377415
test('isAtEnd and getDistanceFromEnd use the scroll element max offset', () => {
378416
const messages = Array.from({ length: 5 }, (_, i) => ({ id: `m-${i}` }))
379417
const { virtualizer } = createChatVirtualizer({

0 commit comments

Comments
 (0)