From 5d3040980c8b20598e67083ffbe9f58f161b4acc Mon Sep 17 00:00:00 2001 From: Githena Date: Thu, 10 Sep 2026 11:19:35 +0000 Subject: [PATCH 1/4] fix(virtual-core): use getMaxScrollOffset() - paddingEnd for scrollToIndex(last) end-align MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #1257: when paddingEnd > 0, scrollToIndex(last, { align: 'end' }) was returning the raw DOM max scroll (scrollHeight - clientHeight), which equals (content + paddingEnd - clientHeight) and overshoots the rendered end of the last item by exactly paddingEnd pixels. The fix subtracts paddingEnd from getMaxScrollOffset(), which equals (content - clientHeight) — the correct virtual max offset that keeps the last item flush with the bottom of the viewport. Also preserves #1001: getMaxScrollOffset() still absorbs DOM extras (borders, padding, unmeasured items) that aren't in our measurements, so multi-lane layouts where the last item lives in a shorter lane still scroll to the lane-max rather than leaving the item above the viewport. Closes TANStack/virtual#1263 --- .changeset/fix-scrolltoindex-paddingend.md | 5 + packages/virtual-core/src/index.ts | 9 +- packages/virtual-core/tests/index.test.ts | 146 +++++++++++++++++++++ 3 files changed, 158 insertions(+), 2 deletions(-) create mode 100644 .changeset/fix-scrolltoindex-paddingend.md diff --git a/.changeset/fix-scrolltoindex-paddingend.md b/.changeset/fix-scrolltoindex-paddingend.md new file mode 100644 index 000000000..6c849fa29 --- /dev/null +++ b/.changeset/fix-scrolltoindex-paddingend.md @@ -0,0 +1,5 @@ +--- +"@tanstack/virtual-core": patch +--- + +fix(virtual-core): use getMaxScrollOffset() - paddingEnd for scrollToIndex(last) end-align, preserving lane-max behavior from #1001 diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 38b7e426c..5a45da43c 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -1904,9 +1904,14 @@ export class Virtualizer< } // For the last item with 'end' alignment, use browser's actual max scroll - // to account for borders/padding that aren't in our measurements + // to account for borders/padding that aren't in our measurements (#1001), + // but subtract paddingEnd to keep the last item flush with the viewport + // bottom rather than overshooting past it (#1257). if (align === 'end' && index === this.options.count - 1) { - return [this.getMaxScrollOffset(), align] as const + return [ + Math.max(this.getMaxScrollOffset() - this.options.paddingEnd, 0), + align, + ] as const } const toOffset = diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index a6ce4021d..7d8fef53f 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4255,3 +4255,149 @@ test('a prepend right after a smooth scroll landed still syncs the anchor', () = // The reader's position is preserved: the DOM is synced to the shifted offset. expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) }) +// ─── #1257: paddingEnd must not make scrollToIndex(last) overshoot the last item ───────────── +// When paddingEnd > 0, getOffsetForIndex(last, 'end') was returning the raw +// DOM max scroll offset (scrollHeight - clientHeight), which equals +// (content + paddingEnd - clientHeight). This caused scrollToIndex(last) to +// scroll past the rendered end of the last item. The fix uses +// getMaxScrollOffset() - paddingEnd, which equals +// (content - clientHeight) — the correct virtual max offset that keeps the +// last item flush with the bottom of the viewport. + +test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with the viewport bottom', () => { + // 5 items × 50px = 250px content, paddingEnd = 80, scrollMargin = 0 + // viewport = 200px → total scrollHeight = 330px (250 + 80) + // Expected virtual max scroll offset: + // getMaxScrollOffset() - paddingEnd = (330 - 200) - 80 = 50 + // Without the fix (using raw scrollHeight - clientHeight): + // 330 - 200 = 130 → overshoots by 80px (exactly the paddingEnd) + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 330, // 250 (content) + 80 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 80, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + // Scroll to last item with 'end' alignment + virtualizer.scrollToIndex(4, { align: 'end' }) + + // getMaxScrollOffset() = 330 - 200 = 130; minus paddingEnd(80) = 50 + expect(scrollToFn).toHaveBeenCalledWith(50, expect.any(Object), expect.any(Object)) +}) + +// ─── #1263: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── +// The fix uses getMaxScrollOffset() directly (rather than getTotalSize()), which +// preserves the lane-max behavior added in #1105 (#1001 fix). In multi-lane layouts +// where the last item lives in a shorter lane, getMaxScrollOffset() still absorbs +// DOM extras that aren't in our measurements. + +test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lane-max', () => { + // 2 lanes: lane 0 = [0..3] @100px (ends at 400), lane 1 = [4] @50px (ends at 50). + // getTotalSize() = 400 (lane-max), getMaxScrollOffset() = 400 - 200 = 200. + // The fix uses getMaxScrollOffset() - paddingEnd = 200 - 0 = 200. + // This preserves the #1001 behavior where lane-max absorbs DOM extras. + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 200, + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: (index) => (index === 4 ? 50 : 100), + lanes: 2, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(4, { align: 'end' }) + + // getMaxScrollOffset() = 200; minus paddingEnd(0) = 200 + expect(scrollToFn).toHaveBeenCalledWith(200, expect.any(Object), expect.any(Object)) +}) + +// ─── #1257: paddingEnd=0 is a no-op ───────────── +test('#1257: scrollToIndex(last) with paddingEnd=0 uses getMaxScrollOffset() unchanged', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 250, // 5 × 50px content, no paddingEnd + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 0, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(4, { align: 'end' }) + + // getMaxScrollOffset() = 250 - 200 = 50; minus paddingEnd(0) = 50 + expect(scrollToFn).toHaveBeenCalledWith(50, expect.any(Object), expect.any(Object)) +}) From 431a122380a1a3eaeb317fe1387d00f55a290fc9 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 15:00:41 +0000 Subject: [PATCH 2/4] ci: apply automated fixes --- .changeset/fix-scrolltoindex-paddingend.md | 2 +- packages/virtual-core/tests/index.test.ts | 18 +++++++++++++++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.changeset/fix-scrolltoindex-paddingend.md b/.changeset/fix-scrolltoindex-paddingend.md index 6c849fa29..16ccf6ebf 100644 --- a/.changeset/fix-scrolltoindex-paddingend.md +++ b/.changeset/fix-scrolltoindex-paddingend.md @@ -1,5 +1,5 @@ --- -"@tanstack/virtual-core": patch +'@tanstack/virtual-core': patch --- fix(virtual-core): use getMaxScrollOffset() - paddingEnd for scrollToIndex(last) end-align, preserving lane-max behavior from #1001 diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 7d8fef53f..4531bd5f9 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4308,7 +4308,11 @@ test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with virtualizer.scrollToIndex(4, { align: 'end' }) // getMaxScrollOffset() = 330 - 200 = 130; minus paddingEnd(80) = 50 - expect(scrollToFn).toHaveBeenCalledWith(50, expect.any(Object), expect.any(Object)) + expect(scrollToFn).toHaveBeenCalledWith( + 50, + expect.any(Object), + expect.any(Object), + ) }) // ─── #1263: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── @@ -4358,7 +4362,11 @@ test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lan virtualizer.scrollToIndex(4, { align: 'end' }) // getMaxScrollOffset() = 200; minus paddingEnd(0) = 200 - expect(scrollToFn).toHaveBeenCalledWith(200, expect.any(Object), expect.any(Object)) + expect(scrollToFn).toHaveBeenCalledWith( + 200, + expect.any(Object), + expect.any(Object), + ) }) // ─── #1257: paddingEnd=0 is a no-op ───────────── @@ -4399,5 +4407,9 @@ test('#1257: scrollToIndex(last) with paddingEnd=0 uses getMaxScrollOffset() unc virtualizer.scrollToIndex(4, { align: 'end' }) // getMaxScrollOffset() = 250 - 200 = 50; minus paddingEnd(0) = 50 - expect(scrollToFn).toHaveBeenCalledWith(50, expect.any(Object), expect.any(Object)) + expect(scrollToFn).toHaveBeenCalledWith( + 50, + expect.any(Object), + expect.any(Object), + ) }) From 49b397d8739dcc6a36e2de30346a463c52b127c0 Mon Sep 17 00:00:00 2001 From: Githena Date: Sun, 4 Oct 2026 02:41:22 +0000 Subject: [PATCH 3/4] fix(virtual-core): set scrollHeight=400 in shorter lane test so getMaxScrollOffset() yields 200px --- packages/virtual-core/tests/index.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 4531bd5f9..11400a0ef 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4330,7 +4330,7 @@ test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lan scrollTop: 0, scrollLeft: 0, scrollWidth: 200, - scrollHeight: 200, + scrollHeight: 400, clientWidth: 200, clientHeight: 200, offsetWidth: 200, From 5a115afa9ff81305b9e60af6a2315b36e0f2e1ab Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 9 Oct 2026 07:19:07 +0200 Subject: [PATCH 4/4] fix(virtual-core): respect scrollPaddingEnd for the last item and keep scrollToEnd at the DOM bottom - scrollToIndex(last, 'end') now targets getMaxScrollOffset() - paddingEnd + scrollPaddingEnd, clamped to [0, getMaxScrollOffset()], so a sticky footer reserved with scrollPaddingEnd still keeps the last item visible. - scrollToEnd() targets getMaxScrollOffset() directly (tracked by reconcile via a toEnd flag) so it still reaches the DOM bottom including paddingEnd and isAtEnd()/followOnAppend keep working. - Rewrite the #1001 lane test so the last item really sits in the shorter lane, and add tests for scrollPaddingEnd and scrollToEnd(). Co-Authored-By: Claude Opus 5.5 --- .changeset/fix-scrolltoindex-paddingend.md | 2 +- packages/virtual-core/src/index.ts | 38 ++++-- packages/virtual-core/tests/index.test.ts | 143 ++++++++++++++++++--- 3 files changed, 157 insertions(+), 26 deletions(-) diff --git a/.changeset/fix-scrolltoindex-paddingend.md b/.changeset/fix-scrolltoindex-paddingend.md index 16ccf6ebf..7ba9f571c 100644 --- a/.changeset/fix-scrolltoindex-paddingend.md +++ b/.changeset/fix-scrolltoindex-paddingend.md @@ -2,4 +2,4 @@ '@tanstack/virtual-core': patch --- -fix(virtual-core): use getMaxScrollOffset() - paddingEnd for scrollToIndex(last) end-align, preserving lane-max behavior from #1001 +Fix `scrollToIndex(last, { align: 'end' })` overshooting the last item by `paddingEnd`; it now respects `scrollPaddingEnd` like other end-aligned items. To scroll to the very bottom including `paddingEnd`, use `scrollToEnd()`. diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 5a45da43c..df393795c 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -383,6 +383,8 @@ type ScrollState = { index: number | null align: ScrollAlignment behavior: ScrollBehavior + // scrollToEnd: track the DOM max scroll offset instead of the item's offset + toEnd?: boolean // lifecycle startedAt: number @@ -1168,8 +1170,9 @@ export class Virtualizer< return } - const offsetInfo = - this.scrollState.index != null + const offsetInfo = this.scrollState.toEnd + ? ([Math.max(this.getMaxScrollOffset(), 0)] as const) + : this.scrollState.index != null ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) : undefined const targetOffset = offsetInfo @@ -1905,13 +1908,13 @@ export class Virtualizer< // For the last item with 'end' alignment, use browser's actual max scroll // to account for borders/padding that aren't in our measurements (#1001), - // but subtract paddingEnd to keep the last item flush with the viewport - // bottom rather than overshooting past it (#1257). + // but swap paddingEnd for scrollPaddingEnd so the last item lands where + // any other end-aligned item would instead of overshooting past it (#1257). if (align === 'end' && index === this.options.count - 1) { - return [ - Math.max(this.getMaxScrollOffset() - this.options.paddingEnd, 0), - align, - ] as const + const maxOffset = this.getMaxScrollOffset() + const toOffset = + maxOffset - this.options.paddingEnd + this.options.scrollPaddingEnd + return [Math.max(Math.min(toOffset, maxOffset), 0), align] as const } const toOffset = @@ -2011,10 +2014,25 @@ export class Virtualizer< scrollToEnd = ({ behavior = 'auto' }: ScrollToEndOptions = {}) => { if (this.options.count > 0) { - this.scrollToIndex(this.options.count - 1, { + // Target the browser's max scroll rather than the last item's end-align + // offset, so paddingEnd is included and isAtEnd() holds afterwards. + this._iosDeferredAdjustment = 0 + + const offset = Math.max(this.getMaxScrollOffset(), 0) + + this.scrollState = { + index: this.options.count - 1, align: 'end', behavior, - }) + toEnd: true, + startedAt: this.now(), + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() return } diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index 11400a0ef..4c4fed706 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4255,14 +4255,16 @@ test('a prepend right after a smooth scroll landed still syncs the anchor', () = // The reader's position is preserved: the DOM is synced to the shifted offset. expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) }) + // ─── #1257: paddingEnd must not make scrollToIndex(last) overshoot the last item ───────────── // When paddingEnd > 0, getOffsetForIndex(last, 'end') was returning the raw // DOM max scroll offset (scrollHeight - clientHeight), which equals // (content + paddingEnd - clientHeight). This caused scrollToIndex(last) to // scroll past the rendered end of the last item. The fix uses -// getMaxScrollOffset() - paddingEnd, which equals -// (content - clientHeight) — the correct virtual max offset that keeps the -// last item flush with the bottom of the viewport. +// getMaxScrollOffset() - paddingEnd + scrollPaddingEnd (clamped to +// [0, getMaxScrollOffset()]), which with scrollPaddingEnd = 0 equals +// (content - clientHeight) — the offset that keeps the last item flush with +// the bottom of the viewport. test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with the viewport bottom', () => { // 5 items × 50px = 250px content, paddingEnd = 80, scrollMargin = 0 @@ -4315,22 +4317,23 @@ test('#1257: scrollToIndex(last) with paddingEnd keeps the last item flush with ) }) -// ─── #1263: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── +// ─── #1001: getMaxScrollOffset() preserves lane-max for multi-lane layouts ───────────── // The fix uses getMaxScrollOffset() directly (rather than getTotalSize()), which // preserves the lane-max behavior added in #1105 (#1001 fix). In multi-lane layouts // where the last item lives in a shorter lane, getMaxScrollOffset() still absorbs // DOM extras that aren't in our measurements. -test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lane-max', () => { - // 2 lanes: lane 0 = [0..3] @100px (ends at 400), lane 1 = [4] @50px (ends at 50). - // getTotalSize() = 400 (lane-max), getMaxScrollOffset() = 400 - 200 = 200. - // The fix uses getMaxScrollOffset() - paddingEnd = 200 - 0 = 200. - // This preserves the #1001 behavior where lane-max absorbs DOM extras. +test('#1001: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lane-max', () => { + // 2 lanes: lane 0 = [0] @300px (ends at 300), lane 1 = [1..3] @50px + // (last item ends at 150). getTotalSize() = 300 (lane-max), + // getMaxScrollOffset() = 300 - 200 = 100. + // The fix uses getMaxScrollOffset() - paddingEnd + scrollPaddingEnd = 100 - 0 + 0 = 100. + // Aligning to the last item's own end would give max(150 - 200, 0) = 0. const mockScrollElement = { scrollTop: 0, scrollLeft: 0, scrollWidth: 200, - scrollHeight: 400, + scrollHeight: 300, clientWidth: 200, clientHeight: 200, offsetWidth: 200, @@ -4341,8 +4344,8 @@ test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lan const scrollToFn = vi.fn() const virtualizer = new Virtualizer({ - count: 5, - estimateSize: (index) => (index === 4 ? 50 : 100), + count: 4, + estimateSize: (index) => (index === 0 ? 300 : 50), lanes: 2, getScrollElement: () => mockScrollElement, scrollToFn, @@ -4359,11 +4362,13 @@ test('#1263: scrollToIndex(last) in a shorter lane uses getMaxScrollOffset() lan virtualizer._willUpdate() scrollToFn.mockClear() - virtualizer.scrollToIndex(4, { align: 'end' }) + expect(virtualizer.measurementsCache[3]).toMatchObject({ lane: 1, end: 150 }) + + virtualizer.scrollToIndex(3, { align: 'end' }) - // getMaxScrollOffset() = 200; minus paddingEnd(0) = 200 + // getMaxScrollOffset() = 100; minus paddingEnd(0) = 100 expect(scrollToFn).toHaveBeenCalledWith( - 200, + 100, expect.any(Object), expect.any(Object), ) @@ -4413,3 +4418,111 @@ test('#1257: scrollToIndex(last) with paddingEnd=0 uses getMaxScrollOffset() unc expect.any(Object), ) }) + +// ─── #1257: scrollPaddingEnd still applies to the last item ───────────── +// A sticky footer is usually reserved with paddingEnd and kept clear with +// scrollPaddingEnd. The last item must end above the footer, like any other +// end-aligned item. +test('#1257: scrollToIndex(last) with paddingEnd and scrollPaddingEnd keeps the last item above the footer', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 310, // 250 (content) + 60 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 60, + scrollPaddingEnd: 40, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(4, { align: 'end' }) + + // item.end(250) + scrollPaddingEnd(40) - viewport(200) = 90, below + // getMaxScrollOffset() = 110 (main used 110, paddingEnd alone gives 50) + expect(scrollToFn).toHaveBeenCalledWith( + 90, + expect.any(Object), + expect.any(Object), + ) + + scrollToFn.mockClear() + virtualizer.scrollToIndex(3, { align: 'end' }) + + // The item before it lands the same way: 200 + 40 - 200 = 40 + expect(scrollToFn).toHaveBeenCalledWith( + 40, + expect.any(Object), + expect.any(Object), + ) +}) + +// ─── #1257: scrollToEnd() still reaches the DOM bottom ───────────── +// scrollToEnd() must include paddingEnd so isAtEnd() holds afterwards +// (followOnAppend and "jump to latest" UI rely on it), even though +// scrollToIndex(last, 'end') now stops at the last item. +test('#1257: scrollToEnd() with paddingEnd scrolls to getMaxScrollOffset()', () => { + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 200, + scrollHeight: 310, // 250 (content) + 60 (paddingEnd) + clientWidth: 200, + clientHeight: 200, + offsetWidth: 200, + offsetHeight: 200, + ownerDocument: { defaultView: globalThis }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + const virtualizer = new Virtualizer({ + count: 5, + estimateSize: () => 50, + paddingEnd: 60, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 200, height: 200 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToEnd() + + // getMaxScrollOffset() = 310 - 200 = 110, paddingEnd included + expect(scrollToFn).toHaveBeenCalledWith( + 110, + expect.any(Object), + expect.any(Object), + ) +})