diff --git a/.changeset/little-wolves-lay.md b/.changeset/little-wolves-lay.md new file mode 100644 index 00000000..f1a78609 --- /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 diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index 930a2f6b..cee8b2d1 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 diff --git a/examples/react/dynamic/src/main.tsx b/examples/react/dynamic/src/main.tsx index dd8aa451..1a0fe0ae 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 00000000..565d48c3 --- /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 c1d8de06..b47a2483 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 00000000..d8650db9 --- /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 b4fc9c77..005ecd9c 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -13,6 +13,7 @@ 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 42843fb3..1a17e31a 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,74 @@ export class Virtualizer< } } + private rafId: number | null = null + private scheduleScrollReconcile() { + if (!this.targetWindow) { + this.scrollState = null + 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 +952,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 +1009,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 +1024,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 +1143,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 +1176,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 cea9d8c0..7f8fc862 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() +})