Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/react-virtual/e2e/app/scroll/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,14 @@ const randomHeight = (() => {

const App = () => {
const parentRef = React.useRef<HTMLDivElement>(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,
})

Expand Down
60 changes: 60 additions & 0 deletions packages/react-virtual/e2e/app/test/scroll.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/')

Expand Down
25 changes: 1 addition & 24 deletions packages/react-virtual/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -138,29 +138,6 @@ test('should render given dynamic size', async () => {
expect(renderer).toHaveBeenCalledTimes(3)
})

test('should render given dynamic size after scroll', () => {
render(<List itemSize={100} dynamic />)

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(<List rangeExtractor={() => [0, 1]} />)

Expand Down
65 changes: 34 additions & 31 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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) {
Expand All @@ -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

Expand All @@ -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) => {
Expand Down
Loading