From ead78b2db920d3736a2092180ac694b70e2c7e4e Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Sun, 4 Jan 2026 23:04:00 +0100 Subject: [PATCH 1/5] fix(virtual-core): smooth scrolling for dynamic item sizes --- examples/react/dynamic/src/main.tsx | 6 +- .../react-virtual/e2e/app/scroll/main.tsx | 12 + .../e2e/app/smooth-scroll/index.html | 10 + .../e2e/app/smooth-scroll/main.tsx | 126 ++++++++ .../react-virtual/e2e/app/test/scroll.spec.ts | 41 ++- .../e2e/app/test/smooth-scroll.spec.ts | 143 +++++++++ packages/react-virtual/e2e/app/vite.config.ts | 4 + packages/virtual-core/src/index.ts | 278 ++++++++++++------ packages/virtual-core/tests/index.test.ts | 172 +++++++++++ 9 files changed, 694 insertions(+), 98 deletions(-) create mode 100644 packages/react-virtual/e2e/app/smooth-scroll/index.html create mode 100644 packages/react-virtual/e2e/app/smooth-scroll/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts diff --git a/examples/react/dynamic/src/main.tsx b/examples/react/dynamic/src/main.tsx index dd8aa4511..1a0fe0ae0 100644 --- a/examples/react/dynamic/src/main.tsx +++ b/examples/react/dynamic/src/main.tsx @@ -26,6 +26,10 @@ function RowVirtualizerDynamic() { enabled, }) + React.useEffect(() => { + virtualizer.scrollToIndex(count - 1, { align: 'end' }) + }, []) + const items = virtualizer.getVirtualItems() return ( @@ -40,7 +44,7 @@ function RowVirtualizerDynamic() { + +
+ + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/smooth-scroll/main.tsx b/packages/react-virtual/e2e/app/smooth-scroll/main.tsx new file mode 100644 index 000000000..565d48c36 --- /dev/null +++ b/packages/react-virtual/e2e/app/smooth-scroll/main.tsx @@ -0,0 +1,126 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { useVirtualizer } from '@tanstack/react-virtual' + +function getRandomInt(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1)) + min +} + +const randomHeight = (() => { + const cache = new Map() + return (id: string) => { + const value = cache.get(id) + if (value !== undefined) { + return value + } + const v = getRandomInt(25, 100) + cache.set(id, v) + return v + } +})() + +const App = () => { + const parentRef = React.useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: 1002, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + }) + + return ( +
+
+ + + + + + +
+ +
+
+ {rowVirtualizer.getVirtualItems().map((v) => ( +
+
+ Row {v.index} +
+
+ ))} +
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index c1d8de06f..b47a2483c 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -28,6 +28,45 @@ test('scrolls to index 1000', async ({ page }) => { await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() const delta = await page.evaluate(check) - console.log('bootom element detla', delta) expect(delta).toBeLessThan(1.01) }) + +test('scrolls to last item', async ({ page }) => { + await page.goto('/scroll/') + await page.click('#scroll-to-last') + + await page.waitForTimeout(1000) + + // Last item (index 1001) should be visible + await expect(page.locator('[data-testid="item-1001"]')).toBeVisible() + + // Container should be scrolled to the very bottom + const atBottom = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + return Math.abs( + container.scrollTop + container.clientHeight - container.scrollHeight, + ) + }) + expect(atBottom).toBeLessThan(1.01) +}) + +test('scrolls to index 0', async ({ page }) => { + await page.goto('/scroll/') + + // First scroll down + await page.click('#scroll-to-1000') + await page.waitForTimeout(1000) + + // Then scroll to first item + await page.click('#scroll-to-0') + await page.waitForTimeout(1000) + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + + const scrollTop = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + return container?.scrollTop ?? -1 + }) + expect(scrollTop).toBeLessThan(1.01) +}) diff --git a/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts b/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts new file mode 100644 index 000000000..d8650db91 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/smooth-scroll.spec.ts @@ -0,0 +1,143 @@ +import { expect, test } from '@playwright/test' + +test('smooth scrolls to index 1000', async ({ page }) => { + await page.goto('/smooth-scroll/') + await page.click('#scroll-to-1000') + + // Smooth scroll animation is 500ms + reconciliation time + await page.waitForTimeout(2000) + + await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() + + const delta = await page.evaluate(() => { + const item = document.querySelector('[data-testid="item-1000"]') + const container = document.querySelector('#scroll-container') + if (!item || !container) throw new Error('Elements not found') + + const itemRect = item.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const scrollTop = container.scrollTop + const top = itemRect.top + scrollTop - containerRect.top + const bottom = top + itemRect.height + const containerBottom = scrollTop + container.clientHeight + return Math.abs(bottom - containerBottom) + }) + expect(delta).toBeLessThan(1.01) +}) + +test('smooth scrolls to index 100', async ({ page }) => { + await page.goto('/smooth-scroll/') + await page.click('#scroll-to-100') + + await page.waitForTimeout(2000) + + await expect(page.locator('[data-testid="item-100"]')).toBeVisible() +}) + +test('smooth scrolls to index 0 after scrolling away', async ({ page }) => { + await page.goto('/smooth-scroll/') + + // First scroll down + await page.click('#scroll-to-500') + await page.waitForTimeout(2000) + await expect(page.locator('[data-testid="item-500"]')).toBeVisible() + + // Then smooth scroll back to top + await page.click('#scroll-to-0') + await page.waitForTimeout(2000) + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + + const scrollTop = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + return container?.scrollTop ?? -1 + }) + expect(scrollTop).toBeLessThan(1.01) +}) + +test('smooth scrolls to index 500 with start alignment', async ({ page }) => { + await page.goto('/smooth-scroll/') + await page.click('#scroll-to-500-start') + + await page.waitForTimeout(2000) + + await expect(page.locator('[data-testid="item-500"]')).toBeVisible() + + const delta = await page.evaluate( + ([idx, align]) => { + const item = document.querySelector(`[data-testid="item-${idx}"]`) + const container = document.querySelector('#scroll-container') + if (!item || !container) throw new Error('Elements not found') + const itemRect = item.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + if (align === 'start') { + return Math.abs(itemRect.top - containerRect.top) + } + return 0 + }, + [500, 'start'] as const, + ) + expect(delta).toBeLessThan(1.01) +}) + +test('smooth scrolls to index 500 with center alignment', async ({ page }) => { + await page.goto('/smooth-scroll/') + await page.click('#scroll-to-500-center') + + await page.waitForTimeout(2000) + + await expect(page.locator('[data-testid="item-500"]')).toBeVisible() + + const delta = await page.evaluate( + ([idx]) => { + const item = document.querySelector(`[data-testid="item-${idx}"]`) + const container = document.querySelector('#scroll-container') + if (!item || !container) throw new Error('Elements not found') + const itemRect = item.getBoundingClientRect() + const containerRect = container.getBoundingClientRect() + const containerCenter = containerRect.top + containerRect.height / 2 + const itemCenter = itemRect.top + itemRect.height / 2 + return Math.abs(itemCenter - containerCenter) + }, + [500] as const, + ) + // Center alignment has slightly more tolerance due to rounding + expect(delta).toBeLessThan(50) +}) + +test('smooth scrolls sequentially to multiple targets', async ({ page }) => { + await page.goto('/smooth-scroll/') + + // Scroll to 100 first + await page.click('#scroll-to-100') + await page.waitForTimeout(2000) + await expect(page.locator('[data-testid="item-100"]')).toBeVisible() + + // Then scroll to 500 + await page.click('#scroll-to-500') + await page.waitForTimeout(2000) + await expect(page.locator('[data-testid="item-500"]')).toBeVisible() + + // Then scroll to 1000 + await page.click('#scroll-to-1000') + await page.waitForTimeout(2000) + await expect(page.locator('[data-testid="item-1000"]')).toBeVisible() +}) + +test('interrupting smooth scroll with another smooth scroll', async ({ + page, +}) => { + await page.goto('/smooth-scroll/') + + // Start scrolling to 1000 + await page.click('#scroll-to-1000') + // Interrupt mid-animation (before the 500ms animation completes) + await page.waitForTimeout(200) + await page.click('#scroll-to-100') + + // Wait for the second scroll to complete + await page.waitForTimeout(2000) + + // Should have ended at 100, not 1000 + await expect(page.locator('[data-testid="item-100"]')).toBeVisible() +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index b4fc9c771..287752367 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -13,6 +13,10 @@ export default defineConfig({ __dirname, 'measure-element/index.html', ), + 'smooth-scroll': path.resolve( + __dirname, + 'smooth-scroll/index.html', + ), }, }, }, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 42843fb37..76f71571a 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -348,6 +348,22 @@ export interface VirtualizerOptions< useAnimationFrameWithResizeObserver?: boolean } +type ScrollState = { + // what we want + index: number | null + align: ScrollAlignment + behavior: ScrollBehavior + + // lifecycle + startedAt: number + + // target tracking + lastTargetOffset: number + + // settling + stableFrames: number +} + export class Virtualizer< TScrollElement extends Element | Window, TItemElement extends Element, @@ -357,7 +373,7 @@ export class Virtualizer< scrollElement: TScrollElement | null = null targetWindow: (Window & typeof globalThis) | null = null isScrolling = false - private currentScrollToIndex: number | null = null + private scrollState: ScrollState | null = null measurementsCache: Array = [] private itemSizeCache = new Map() private laneAssignments = new Map() // index → lane cache @@ -377,6 +393,7 @@ export class Virtualizer< instance: Virtualizer, ) => boolean) elementsCache = new Map() + private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() private observer = (() => { let _ro: ResizeObserver | null = null @@ -482,6 +499,11 @@ export class Virtualizer< this.unsubs.filter(Boolean).forEach((d) => d!()) this.unsubs = [] this.observer.disconnect() + if (this.rafId != null && this.targetWindow) { + this.targetWindow.cancelAnimationFrame(this.rafId) + this.rafId = null + } + this.scrollState = null this.scrollElement = null this.targetWindow = null } @@ -535,6 +557,9 @@ export class Virtualizer< this.scrollOffset = offset this.isScrolling = isScrolling + if (this.scrollState) { + this.scheduleScrollReconcile() + } this.maybeNotify() }), ) @@ -546,6 +571,71 @@ export class Virtualizer< } } + private rafId: number | null = null + private scheduleScrollReconcile() { + if (!this.targetWindow) return + if (this.rafId != null) return + this.rafId = this.targetWindow.requestAnimationFrame(() => { + this.rafId = null + this.reconcileScroll() + }) + } + private reconcileScroll() { + if (!this.scrollState) return + + const el = this.scrollElement + if (!el) return + + // Safety valve: bail out if reconciliation has been running too long + const MAX_RECONCILE_MS = 5000 + if (this.now() - this.scrollState.startedAt > MAX_RECONCILE_MS) { + this.scrollState = null + return + } + + const offsetInfo = + this.scrollState.index != null + ? this.getOffsetForIndex(this.scrollState.index, this.scrollState.align) + : undefined + const targetOffset = offsetInfo + ? offsetInfo[0] + : this.scrollState.lastTargetOffset + + // Require one stable frame where target matches scroll offset. + // approxEqual() already tolerates minor fluctuations, so one frame is sufficient + // to confirm scroll has reached its target without premature cleanup. + const STABLE_FRAMES = 1 + + const targetChanged = targetOffset !== this.scrollState.lastTargetOffset + + if (!targetChanged && approxEqual(targetOffset, this.getScrollOffset())) { + this.scrollState.stableFrames++ + if (this.scrollState.stableFrames >= STABLE_FRAMES) { + this.scrollState = null + return + } + } else { + this.scrollState.stableFrames = 0 + + if (targetChanged) { + this.scrollState.lastTargetOffset = targetOffset + // Switch to 'auto' behavior once measurements cause target to change + // We want to jump directly to the correct position, not smoothly animate to it + this.scrollState.behavior = 'auto' + + this._scrollToOffset(targetOffset, { + adjustments: undefined, + behavior: 'auto', + }) + } + } + + // Always reschedule while scrollState is active to guarantee + // the safety valve timeout runs even if no scroll events fire + // (e.g. no-op scrollToFn, detached element) + this.scheduleScrollReconcile() + } + private getSize = () => { if (!this.options.enabled) { this.scrollRect = null @@ -859,6 +949,38 @@ export class Virtualizer< return parseInt(indexStr, 10) } + /** + * Determines if an item at the given index should be measured during smooth scroll. + * During smooth scroll, only items within a buffer range around the target are measured + * to prevent items far from the target from pushing it away. + */ + private shouldMeasureDuringScroll = (index: number): boolean => { + // No scroll state or not smooth scroll - always allow measurements + if (!this.scrollState || this.scrollState.behavior !== 'smooth') { + return true + } + + const scrollIndex = + this.scrollState.index ?? + this.getVirtualItemForOffset(this.scrollState.lastTargetOffset)?.index + + if (scrollIndex !== undefined && this.range) { + // Allow measurements within a buffer range around the scroll target + const bufferSize = Math.max( + this.options.overscan, + Math.ceil((this.range.endIndex - this.range.startIndex) / 2), + ) + const minIndex = Math.max(0, scrollIndex - bufferSize) + const maxIndex = Math.min( + this.options.count - 1, + scrollIndex + bufferSize, + ) + return index >= minIndex && index <= maxIndex + } + + return true + } + private _measureElement = ( node: TItemElement, entry: ResizeObserverEntry | undefined, @@ -884,7 +1006,9 @@ export class Virtualizer< this.elementsCache.set(key, node) } - this.resizeItem(index, this.options.measureElement(node, entry, this)) + if (this.shouldMeasureDuringScroll(index)) { + this.resizeItem(index, this.options.measureElement(node, entry, this)) + } } resizeItem = (index: number, size: number) => { @@ -897,14 +1021,14 @@ export class Virtualizer< if (delta !== 0) { if ( - this.shouldAdjustScrollPositionOnItemSizeChange !== undefined + this.scrollState?.behavior !== 'smooth' && + (this.shouldAdjustScrollPositionOnItemSizeChange !== undefined ? this.shouldAdjustScrollPositionOnItemSizeChange(item, delta, this) - : item.start < this.getScrollOffset() + this.scrollAdjustments + : item.start < this.getScrollOffset() + this.scrollAdjustments) ) { if (process.env.NODE_ENV !== 'production' && this.options.debug) { console.info('correction', delta) } - this._scrollToOffset(this.getScrollOffset(), { adjustments: (this.scrollAdjustments += delta), behavior: undefined, @@ -1016,14 +1140,12 @@ export class Virtualizer< getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => { index = Math.max(0, Math.min(index, this.options.count - 1)) - const item = this.measurementsCache[index] - if (!item) { - return undefined - } - const size = this.getSize() const scrollOffset = this.getScrollOffset() + const item = this.measurementsCache[index] + if (!item) return + if (align === 'auto') { if (item.end >= scrollOffset + size - this.options.scrollPaddingEnd) { align = 'end' @@ -1051,112 +1173,76 @@ export class Virtualizer< ] as const } - private isDynamicMode = () => this.elementsCache.size > 0 - scrollToOffset = ( toOffset: number, - { align = 'start', behavior }: ScrollToOffsetOptions = {}, + { align = 'start', behavior = 'auto' }: ScrollToOffsetOptions = {}, ) => { - if (behavior === 'smooth' && this.isDynamicMode()) { - console.warn( - 'The `smooth` scroll behavior is not fully supported with dynamic size.', - ) - } + const offset = this.getOffsetForAlignment(toOffset, align) - this._scrollToOffset(this.getOffsetForAlignment(toOffset, align), { - adjustments: undefined, + const now = this.now() + this.scrollState = { + index: null, + align, behavior, - }) + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() } scrollToIndex = ( index: number, - { align: initialAlign = 'auto', behavior }: ScrollToIndexOptions = {}, + { + align: initialAlign = 'auto', + behavior = 'auto', + }: ScrollToIndexOptions = {}, ) => { - if (behavior === 'smooth' && this.isDynamicMode()) { - console.warn( - 'The `smooth` scroll behavior is not fully supported with dynamic size.', - ) - } - index = Math.max(0, Math.min(index, this.options.count - 1)) - this.currentScrollToIndex = index - - let attempts = 0 - const maxAttempts = 10 - - const tryScroll = (currentAlign: ScrollAlignment) => { - if (!this.targetWindow) return - - const offsetInfo = this.getOffsetForIndex(index, currentAlign) - if (!offsetInfo) { - console.warn('Failed to get offset for index:', index) - return - } - const [offset, align] = offsetInfo - this._scrollToOffset(offset, { adjustments: undefined, behavior }) - - this.targetWindow.requestAnimationFrame(() => { - if (!this.targetWindow) return - - const verify = () => { - // Abort if a new scrollToIndex was called with a different index - if (this.currentScrollToIndex !== index) return - - const currentOffset = this.getScrollOffset() - const afterInfo = this.getOffsetForIndex(index, align) - if (!afterInfo) { - console.warn('Failed to get offset for index:', index) - return - } - - if (!approxEqual(afterInfo[0], currentOffset)) { - scheduleRetry(align) - } - } - // In dynamic mode, wait an extra frame for ResizeObserver to measure newly visible elements - if (this.isDynamicMode()) { - this.targetWindow.requestAnimationFrame(verify) - } else { - verify() - } - }) + const offsetInfo = this.getOffsetForIndex(index, initialAlign) + if (!offsetInfo) { + return } + const [offset, align] = offsetInfo - const scheduleRetry = (align: ScrollAlignment) => { - if (!this.targetWindow) return - - // Abort if a new scrollToIndex was called with a different index - if (this.currentScrollToIndex !== index) return - - attempts++ - if (attempts < maxAttempts) { - if (process.env.NODE_ENV !== 'production' && this.options.debug) { - console.info('Schedule retry', attempts, maxAttempts) - } - this.targetWindow.requestAnimationFrame(() => tryScroll(align)) - } else { - console.warn( - `Failed to scroll to index ${index} after ${maxAttempts} attempts.`, - ) - } + const now = this.now() + this.scrollState = { + index, + align, + behavior, + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, } - tryScroll(initialAlign) + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() } - scrollBy = (delta: number, { behavior }: ScrollToOffsetOptions = {}) => { - if (behavior === 'smooth' && this.isDynamicMode()) { - console.warn( - 'The `smooth` scroll behavior is not fully supported with dynamic size.', - ) - } + scrollBy = ( + delta: number, + { behavior = 'auto' }: ScrollToOffsetOptions = {}, + ) => { + const offset = this.getScrollOffset() + delta + const now = this.now() - this._scrollToOffset(this.getScrollOffset() + delta, { - adjustments: undefined, + this.scrollState = { + index: null, + align: 'start', behavior, - }) + startedAt: now, + lastTargetOffset: offset, + stableFrames: 0, + } + + this._scrollToOffset(offset, { adjustments: undefined, behavior }) + + this.scheduleScrollReconcile() } getTotalSize = () => { diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index cea9d8c08..7f8fc8626 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -169,6 +169,7 @@ test('should not throw when component unmounts during scrollToIndex rAF loop', ( const mockWindow = { requestAnimationFrame: mockRaf, + cancelAnimationFrame: vi.fn(), ResizeObserver: vi.fn(() => ({ observe: vi.fn(), unobserve: vi.fn(), @@ -231,3 +232,174 @@ test('should not throw when component unmounts during scrollToIndex rAF loop', ( rafCallbacks.forEach((cb) => cb(0)) }).not.toThrow() }) + +function createMockEnvironment() { + const rafCallbacks: Array = [] + let rafIdCounter = 0 + const mockRaf = vi.fn((cb: FrameRequestCallback) => { + rafCallbacks.push(cb) + return ++rafIdCounter + }) + const mockCancelRaf = vi.fn() + + const mockWindow = { + requestAnimationFrame: mockRaf, + cancelAnimationFrame: mockCancelRaf, + performance: { now: () => Date.now() }, + ResizeObserver: vi.fn(() => ({ + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + })), + } + + const mockScrollElement = { + scrollTop: 0, + scrollLeft: 0, + scrollWidth: 1000, + scrollHeight: 5000, + clientWidth: 400, + clientHeight: 600, + offsetWidth: 400, + offsetHeight: 600, + ownerDocument: { + defaultView: mockWindow, + }, + scrollTo: vi.fn(), + } as unknown as HTMLDivElement + + const scrollToFn = vi.fn() + + return { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } +} + +function createVirtualizer( + mockScrollElement: HTMLDivElement, + scrollToFn: ReturnType, +) { + return new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => mockScrollElement, + scrollToFn, + observeElementRect: (_instance, cb) => { + cb({ width: 400, height: 600 }) + return () => {} + }, + observeElementOffset: (_instance, cb) => { + cb(0, false) + return () => {} + }, + }) +} + +test('scrollToIndex(0) should reconcile correctly', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToIndex(0) + + // scrollToFn should have been called with offset for index 0 + expect(scrollToFn).toHaveBeenCalled() + const calledOffset = scrollToFn.mock.calls[0]![0] + expect(calledOffset).toBe(0) + + // Flush rAF — reconcileScroll should run and not bail + // It should eventually clear scrollState (settle) + rafCallbacks.forEach((cb) => cb(0)) + + // scrollState should be cleared after settling + expect(virtualizer['scrollState']).toBeNull() +}) + +test('scrollToOffset should reconcile and clear scrollState', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollToOffset(200) + + expect(scrollToFn).toHaveBeenCalled() + + // scrollState should be set with index: null + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['scrollState']!.index).toBeNull() + + // Simulate the scroll offset reaching the target + virtualizer.scrollOffset = 200 + + // Flush rAF — reconciliation should settle and clear scrollState + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('scrollBy should reconcile and clear scrollState', () => { + const { rafCallbacks, mockScrollElement, scrollToFn } = + createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + scrollToFn.mockClear() + + virtualizer.scrollBy(100) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['scrollState']!.index).toBeNull() + + // Simulate scroll offset reaching the target + virtualizer.scrollOffset = 100 + + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('reconcileScroll should bail out after timeout', () => { + const { rafCallbacks, mockWindow, mockScrollElement, scrollToFn } = + createMockEnvironment() + + // Make performance.now() return a controllable value + let fakeTime = 1000 + mockWindow.performance.now = () => fakeTime + + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + virtualizer._willUpdate() + + virtualizer.scrollToIndex(50) + + expect(virtualizer['scrollState']).not.toBeNull() + + // Advance time past the 5s safety valve + fakeTime = 7000 + + // Flush rAF — should trigger timeout bailout + rafCallbacks.forEach((cb) => cb(0)) + + expect(virtualizer['scrollState']).toBeNull() +}) + +test('cleanup should cancel pending RAF and clear scrollState', () => { + const { mockWindow, mockScrollElement, scrollToFn } = createMockEnvironment() + const virtualizer = createVirtualizer(mockScrollElement, scrollToFn) + + virtualizer._willUpdate() + virtualizer.scrollToIndex(50) + + expect(virtualizer['scrollState']).not.toBeNull() + expect(virtualizer['rafId']).not.toBeNull() + + const unmount = virtualizer._didMount() + unmount() + + expect(virtualizer['scrollState']).toBeNull() + expect(virtualizer['rafId']).toBeNull() + expect(mockWindow.cancelAnimationFrame).toHaveBeenCalled() +}) From ddd66c5fab896663bbcca347580d2626f3d7e569 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 5 Mar 2026 21:54:58 +0000 Subject: [PATCH 2/5] ci: apply automated fixes --- packages/react-virtual/e2e/app/scroll/main.tsx | 5 +---- packages/react-virtual/e2e/app/vite.config.ts | 5 +---- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/react-virtual/e2e/app/scroll/main.tsx b/packages/react-virtual/e2e/app/scroll/main.tsx index 82fc8eb79..99c655077 100644 --- a/packages/react-virtual/e2e/app/scroll/main.tsx +++ b/packages/react-virtual/e2e/app/scroll/main.tsx @@ -42,10 +42,7 @@ const App = () => { > Scroll to last - diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 287752367..005ecd9c0 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -13,10 +13,7 @@ export default defineConfig({ __dirname, 'measure-element/index.html', ), - 'smooth-scroll': path.resolve( - __dirname, - 'smooth-scroll/index.html', - ), + 'smooth-scroll': path.resolve(__dirname, 'smooth-scroll/index.html'), }, }, }, From 28296e896e4e8ab47378f97f60d9195b6385da63 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 6 Mar 2026 05:44:16 +0100 Subject: [PATCH 3/5] Add changeset --- .changeset/little-wolves-lay.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/little-wolves-lay.md diff --git a/.changeset/little-wolves-lay.md b/.changeset/little-wolves-lay.md new file mode 100644 index 000000000..f1a786094 --- /dev/null +++ b/.changeset/little-wolves-lay.md @@ -0,0 +1,5 @@ +--- +'@tanstack/virtual-core': patch +--- + +fix(virtual-core): smooth scrolling for dynamic item sizes From c06c1a1390cf630b28dc23356cfe6292f5245f73 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 6 Mar 2026 05:58:23 +0100 Subject: [PATCH 4/5] Review comments --- packages/virtual-core/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 76f71571a..1a17e31ac 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -573,7 +573,10 @@ export class Virtualizer< private rafId: number | null = null private scheduleScrollReconcile() { - if (!this.targetWindow) return + if (!this.targetWindow) { + this.scrollState = null + return + } if (this.rafId != null) return this.rafId = this.targetWindow.requestAnimationFrame(() => { this.rafId = null From 132ea98b993dbfb9b8a8d9cd083b0d209fe074e3 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Fri, 6 Mar 2026 07:25:12 +0100 Subject: [PATCH 5/5] update docs --- docs/api/virtualizer.md | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 930a2f6b8..cee8b2d19 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -34,7 +34,7 @@ A function that returns the scrollable element for the virtualizer. It may retur estimateSize: (index: number) => number ``` -> 🧠 If you are dynamically measuring your elements, it's recommended to estimate the largest possible size (width/height, within comfort) of your items. This will ensure features like smooth-scrolling will have a better chance at working correctly. +> 🧠 If you are dynamically measuring your elements, it's recommended to estimate the largest possible size (width/height, within comfort) of your items. This will help the virtualizer calculate more accurate initial positions. This function is passed the index of each item and should return the actual size (or estimated size if you will be dynamically measuring items with `virtualItem.measureElement`) for each item. This measurement should return either the width or height depending on the orientation of your virtualizer. @@ -166,8 +166,6 @@ An optional function that (if provided) should implement the scrolling behavior Note that built-in scroll implementations are exported as `elementScroll` and `windowScroll`, which are automatically configured by the framework adapter functions like `useVirtualizer` or `useWindowVirtualizer`. -> ⚠️ Attempting to use smoothScroll with dynamically measured elements will not work. - ### `observeElementRect` ```tsx @@ -349,6 +347,23 @@ scrollToIndex: ( Scrolls the virtualizer to the items of the index provided. You can optionally pass an alignment mode to anchor the scroll to a specific part of the scrollElement. +> 🧠 During smooth scrolling, the virtualizer only measures items within a buffer range around the scroll target. Items far from the target are skipped to prevent their size changes from shifting the target position and breaking the smooth animation. +> +> Because of this, the preferred layout strategy for smooth scrolling is **block translation** — translate the entire rendered block using the first item's `start` offset, rather than positioning each item independently with absolute positioning. This ensures items stay correctly positioned relative to each other even when some measurements are skipped. + +### `scrollBy` + +```tsx +scrollBy: ( + delta: number, + options?: { + behavior?: 'auto' | 'smooth' + } +) => void +``` + +Scrolls the virtualizer by the specified number of pixels relative to the current scroll position. + ### `getTotalSize` ```tsx