diff --git a/packages/react-virtual/e2e/app/scroll/main.tsx b/packages/react-virtual/e2e/app/scroll/main.tsx index 99c655077..1a2f9eacb 100644 --- a/packages/react-virtual/e2e/app/scroll/main.tsx +++ b/packages/react-virtual/e2e/app/scroll/main.tsx @@ -21,10 +21,14 @@ const randomHeight = (() => { const App = () => { const parentRef = React.useRef(null) + const initialOffset = Number( + new URLSearchParams(window.location.search).get('initialOffset') ?? 0, + ) const rowVirtualizer = useVirtualizer({ count: 1002, getScrollElement: () => parentRef.current, estimateSize: () => 50, + initialOffset, debug: true, }) diff --git a/packages/react-virtual/e2e/app/test/scroll.spec.ts b/packages/react-virtual/e2e/app/test/scroll.spec.ts index b47a2483c..d7d1710ea 100644 --- a/packages/react-virtual/e2e/app/test/scroll.spec.ts +++ b/packages/react-virtual/e2e/app/test/scroll.spec.ts @@ -51,6 +51,66 @@ test('scrolls to last item', async ({ page }) => { expect(atBottom).toBeLessThan(1.01) }) +test('renders correctly with initialOffset and user scroll up', async ({ + page, +}) => { + // Start at offset 5000 (no programmatic scrollToIndex) + await page.goto('/scroll/?initialOffset=5000') + await page.waitForTimeout(500) + + // Items around offset 5000 should be visible + const visibleIndex = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + const items = container.querySelectorAll('[data-index]') + const indices = Array.from(items).map((el) => + Number(el.getAttribute('data-index')), + ) + return Math.min(...indices) + }) + expect(visibleIndex).toBeGreaterThan(0) + + // Scroll up by 2000px (user scroll, not programmatic) + await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + container.scrollTop -= 2000 + }) + await page.waitForTimeout(500) + + // After scroll up, items should be properly measured and positioned + // (no gaps, no overlaps) — verify consecutive items are contiguous + const layout = await page.evaluate(() => { + const container = document.querySelector('#scroll-container') + if (!container) throw new Error('Container not found') + const items = Array.from(container.querySelectorAll('[data-index]')) + .map((el) => { + const rect = el.getBoundingClientRect() + return { + index: Number(el.getAttribute('data-index')), + top: rect.top, + bottom: rect.bottom, + height: rect.height, + } + }) + .sort((a, b) => a.index - b.index) + + // Check that each item's top matches the previous item's bottom (within tolerance) + let maxGap = 0 + for (let i = 1; i < items.length; i++) { + const gap = Math.abs(items[i].top - items[i - 1].bottom) + maxGap = Math.max(maxGap, gap) + } + + return { items, maxGap } + }) + + expect(layout.items.length > 0).toBe(true) + expect(layout.items.length).toBeGreaterThan(3) + // Items should be contiguous — no gaps between consecutive items + expect(layout.maxGap).toBeLessThan(2) +}) + test('scrolls to index 0', async ({ page }) => { await page.goto('/scroll/') diff --git a/packages/react-virtual/tests/index.test.tsx b/packages/react-virtual/tests/index.test.tsx index c7348dc20..7b9e6e7cd 100644 --- a/packages/react-virtual/tests/index.test.tsx +++ b/packages/react-virtual/tests/index.test.tsx @@ -1,6 +1,6 @@ import { beforeEach, test, expect, vi } from 'vitest' import * as React from 'react' -import { render, screen, fireEvent } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import { useVirtualizer, Range } from '../src/index' @@ -138,29 +138,6 @@ test('should render given dynamic size', async () => { expect(renderer).toHaveBeenCalledTimes(3) }) -test('should render given dynamic size after scroll', () => { - render() - - expect(screen.queryByText('Row 0')).toBeInTheDocument() - expect(screen.queryByText('Row 1')).toBeInTheDocument() - expect(screen.queryByText('Row 2')).toBeInTheDocument() - expect(screen.queryByText('Row 3')).not.toBeInTheDocument() - - expect(renderer).toHaveBeenCalledTimes(3) - renderer.mockReset() - - fireEvent.scroll(screen.getByTestId('scroller'), { - target: { scrollTop: 400 }, - }) - - expect(screen.queryByText('Row 2')).not.toBeInTheDocument() - expect(screen.queryByText('Row 3')).toBeInTheDocument() - expect(screen.queryByText('Row 6')).toBeInTheDocument() - expect(screen.queryByText('Row 7')).not.toBeInTheDocument() - - expect(renderer).toHaveBeenCalledTimes(2) -}) - test('should use rangeExtractor', () => { render( [0, 1]} />) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bc966a6ce..8cd53a814 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -409,7 +409,21 @@ export class Virtualizer< return (_ro = new this.targetWindow.ResizeObserver((entries) => { entries.forEach((entry) => { const run = () => { - this._measureElement(entry.target as TItemElement, entry) + const node = entry.target as TItemElement + const index = this.indexFromElement(node) + + if (!node.isConnected) { + this.observer.unobserve(node) + this.elementsCache.delete(this.options.getItemKey(index)) + return + } + + if (this.shouldMeasureDuringScroll(index)) { + this.resizeItem( + index, + this.options.measureElement(node, entry, this), + ) + } } this.options.useAnimationFrameWithResizeObserver ? requestAnimationFrame(run) @@ -984,21 +998,19 @@ export class Virtualizer< return true } - private _measureElement = ( - node: TItemElement, - entry: ResizeObserverEntry | undefined, - ) => { - if (!node.isConnected) { - this.observer.unobserve(node) + measureElement = (node: TItemElement | null) => { + if (!node) { + this.elementsCache.forEach((cached, key) => { + if (!cached.isConnected) { + this.observer.unobserve(cached) + this.elementsCache.delete(key) + } + }) return } const index = this.indexFromElement(node) - const item = this.measurementsCache[index] - if (!item) { - return - } - const key = item.key + const key = this.options.getItemKey(index) const prevNode = this.elementsCache.get(key) if (prevNode !== node) { @@ -1009,16 +1021,21 @@ export class Virtualizer< this.elementsCache.set(key, node) } - if (this.shouldMeasureDuringScroll(index)) { - this.resizeItem(index, this.options.measureElement(node, entry, this)) + // Sync-measure when idle (initial render) or during programmatic scrolling + // (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame. + // During normal user scrolling, skip sync measurement — the RO callback handles it async. + if ( + (!this.isScrolling || this.scrollState) && + this.shouldMeasureDuringScroll(index) + ) { + this.resizeItem(index, this.options.measureElement(node, undefined, this)) } } resizeItem = (index: number, size: number) => { const item = this.measurementsCache[index] - if (!item) { - return - } + if (!item) return + const itemSize = this.itemSizeCache.get(item.key) ?? item.size const delta = size - itemSize @@ -1045,20 +1062,6 @@ export class Virtualizer< } } - measureElement = (node: TItemElement | null | undefined) => { - if (!node) { - this.elementsCache.forEach((cached, key) => { - if (!cached.isConnected) { - this.observer.unobserve(cached) - this.elementsCache.delete(key) - } - }) - return - } - - this._measureElement(node, undefined) - } - getVirtualItems = memo( () => [this.getVirtualIndexes(), this.getMeasurements()], (indexes, measurements) => {