Skip to content

Commit 10db36b

Browse files
committed
perf(virtual-core): skip sync DOM reads during normal scrolling
1 parent 54d771a commit 10db36b

File tree

2 files changed

+35
-55
lines changed

2 files changed

+35
-55
lines changed

packages/react-virtual/tests/index.test.tsx

Lines changed: 1 addition & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { beforeEach, test, expect, vi } from 'vitest'
22
import * as React from 'react'
3-
import { render, screen, fireEvent } from '@testing-library/react'
3+
import { render, screen } from '@testing-library/react'
44

55
import { useVirtualizer, Range } from '../src/index'
66

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

141-
test('should render given dynamic size after scroll', () => {
142-
render(<List itemSize={100} dynamic />)
143-
144-
expect(screen.queryByText('Row 0')).toBeInTheDocument()
145-
expect(screen.queryByText('Row 1')).toBeInTheDocument()
146-
expect(screen.queryByText('Row 2')).toBeInTheDocument()
147-
expect(screen.queryByText('Row 3')).not.toBeInTheDocument()
148-
149-
expect(renderer).toHaveBeenCalledTimes(3)
150-
renderer.mockReset()
151-
152-
fireEvent.scroll(screen.getByTestId('scroller'), {
153-
target: { scrollTop: 400 },
154-
})
155-
156-
expect(screen.queryByText('Row 2')).not.toBeInTheDocument()
157-
expect(screen.queryByText('Row 3')).toBeInTheDocument()
158-
expect(screen.queryByText('Row 6')).toBeInTheDocument()
159-
expect(screen.queryByText('Row 7')).not.toBeInTheDocument()
160-
161-
expect(renderer).toHaveBeenCalledTimes(2)
162-
})
163-
164141
test('should use rangeExtractor', () => {
165142
render(<List rangeExtractor={() => [0, 1]} />)
166143

packages/virtual-core/src/index.ts

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,21 @@ export class Virtualizer<
409409
return (_ro = new this.targetWindow.ResizeObserver((entries) => {
410410
entries.forEach((entry) => {
411411
const run = () => {
412-
this._measureElement(entry.target as TItemElement, entry)
412+
const node = entry.target as TItemElement
413+
const index = this.indexFromElement(node)
414+
415+
if (!node.isConnected) {
416+
this.observer.unobserve(node)
417+
this.elementsCache.delete(this.options.getItemKey(index))
418+
return
419+
}
420+
421+
if (this.shouldMeasureDuringScroll(index)) {
422+
this.resizeItem(
423+
index,
424+
this.options.measureElement(node, entry, this),
425+
)
426+
}
413427
}
414428
this.options.useAnimationFrameWithResizeObserver
415429
? requestAnimationFrame(run)
@@ -984,21 +998,19 @@ export class Virtualizer<
984998
return true
985999
}
9861000

987-
private _measureElement = (
988-
node: TItemElement,
989-
entry: ResizeObserverEntry | undefined,
990-
) => {
991-
if (!node.isConnected) {
992-
this.observer.unobserve(node)
1001+
measureElement = (node: TItemElement | null) => {
1002+
if (!node) {
1003+
this.elementsCache.forEach((cached, key) => {
1004+
if (!cached.isConnected) {
1005+
this.observer.unobserve(cached)
1006+
this.elementsCache.delete(key)
1007+
}
1008+
})
9931009
return
9941010
}
9951011

9961012
const index = this.indexFromElement(node)
997-
const item = this.measurementsCache[index]
998-
if (!item) {
999-
return
1000-
}
1001-
const key = item.key
1013+
const key = this.options.getItemKey(index)
10021014
const prevNode = this.elementsCache.get(key)
10031015

10041016
if (prevNode !== node) {
@@ -1009,16 +1021,21 @@ export class Virtualizer<
10091021
this.elementsCache.set(key, node)
10101022
}
10111023

1012-
if (this.shouldMeasureDuringScroll(index)) {
1013-
this.resizeItem(index, this.options.measureElement(node, entry, this))
1024+
// Sync-measure when idle (initial render) or during programmatic scrolling
1025+
// (scrollToIndex/scrollToOffset) where reconcileScroll needs sizes in the same frame.
1026+
// During normal user scrolling, skip sync measurement — the RO callback handles it async.
1027+
if (
1028+
(!this.isScrolling || this.scrollState) &&
1029+
this.shouldMeasureDuringScroll(index)
1030+
) {
1031+
this.resizeItem(index, this.options.measureElement(node, undefined, this))
10141032
}
10151033
}
10161034

10171035
resizeItem = (index: number, size: number) => {
10181036
const item = this.measurementsCache[index]
1019-
if (!item) {
1020-
return
1021-
}
1037+
if (!item) return
1038+
10221039
const itemSize = this.itemSizeCache.get(item.key) ?? item.size
10231040
const delta = size - itemSize
10241041

@@ -1045,20 +1062,6 @@ export class Virtualizer<
10451062
}
10461063
}
10471064

1048-
measureElement = (node: TItemElement | null | undefined) => {
1049-
if (!node) {
1050-
this.elementsCache.forEach((cached, key) => {
1051-
if (!cached.isConnected) {
1052-
this.observer.unobserve(cached)
1053-
this.elementsCache.delete(key)
1054-
}
1055-
})
1056-
return
1057-
}
1058-
1059-
this._measureElement(node, undefined)
1060-
}
1061-
10621065
getVirtualItems = memo(
10631066
() => [this.getVirtualIndexes(), this.getMeasurements()],
10641067
(indexes, measurements) => {

0 commit comments

Comments
 (0)