Skip to content

Commit

Permalink
fix: performance issues with measureElement (#806)
Browse files Browse the repository at this point in the history
* refactor: memoize notify

* fix: remove measureElement from VirtualItem

* fix: get item from cache in resizeItem

* docs: update measureElement signature
  • Loading branch information
piecyk authored Aug 22, 2024
1 parent eebc3e7 commit 0ad4e6c
Show file tree
Hide file tree
Showing 5 changed files with 62 additions and 48 deletions.
3 changes: 1 addition & 2 deletions docs/api/virtual-item.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,12 @@ title: VirtualItem
The `VirtualItem` object represents a single item returned by the virtualizer. It contains information you need to render the item in the coordinate space within your virtualizer's scrollElement and other helpful properties/functions.

```tsx
export interface VirtualItem<TItemElement extends Element> {
export interface VirtualItem {
key: string | number
index: number
start: number
end: number
size: number
measureElement: (node: TItemElement | null | undefined) => void
}
```

Expand Down
7 changes: 4 additions & 3 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,12 +184,13 @@ An optional function that if provided is called when the scrollElement changes a

```tsx
measureElement?: (
el: TItemElement,
instance: Virtualizer<TScrollElement, TItemElement>
element: TItemElement,
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<TScrollElement, TItemElement>,
) => number
```

This optional function is called when the virtualizer needs to dynamically measure the size (width or height) of an item when `virtualItem.measureElement` is called. It's passed the element given when you call `virtualItem.measureElement(TItemElement)` and the virtualizer instance. It should return the size of the element as a `number`.
This optional function is called when the virtualizer needs to dynamically measure the size (width or height) of an item.

> 🧠 You can use `instance.options.horizontal` to determine if the width or height of the item should be measured.
Expand Down
2 changes: 1 addition & 1 deletion examples/lit/dynamic/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ class ColumnVirtualizerDynamic extends LitElement {
<div
data-index="${virtualColumn.index}"
style="position:absolute;top:0;left:0;height:100%;transform:translateX(${virtualColumn.start}px)"
${ref(virtualColumn.measureElement)}
${ref(virtualizer.measureElement)}
class="${virtualColumn.index % 2 === 0
? 'list-item-even'
: 'list-item-odd'}"
Expand Down
22 changes: 14 additions & 8 deletions examples/react/padding/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,9 @@ function RowVirtualizerDynamic({ rows }: { rows: Array<number> }) {
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.index}
ref={virtualRow.measureElement}
key={virtualRow.key}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
className={virtualRow.index % 2 ? 'ListItemOdd' : 'ListItemEven'}
style={{
position: 'absolute',
Expand Down Expand Up @@ -123,8 +124,9 @@ function ColumnVirtualizerDynamic({ columns }: { columns: Array<number> }) {
>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.index}
ref={virtualColumn.measureElement}
key={virtualColumn.key}
data-index={virtualColumn.index}
ref={columnVirtualizer.measureElement}
className={
virtualColumn.index % 2 ? 'ListItemOdd' : 'ListItemEven'
}
Expand Down Expand Up @@ -161,6 +163,7 @@ function GridVirtualizerDynamic({
estimateSize: () => 50,
paddingStart: 200,
paddingEnd: 200,
indexAttribute: 'data-row-index',
})

const columnVirtualizer = useVirtualizer({
Expand All @@ -170,6 +173,7 @@ function GridVirtualizerDynamic({
estimateSize: () => 50,
paddingStart: 200,
paddingEnd: 200,
indexAttribute: 'data-column-index',
})

const [show, setShow] = React.useState(true)
Expand Down Expand Up @@ -203,13 +207,15 @@ function GridVirtualizerDynamic({
}}
>
{rowVirtualizer.getVirtualItems().map((virtualRow) => (
<React.Fragment key={virtualRow.index}>
<React.Fragment key={virtualRow.key}>
{columnVirtualizer.getVirtualItems().map((virtualColumn) => (
<div
key={virtualColumn.index}
key={virtualColumn.key}
data-row-index={virtualRow.index}
data-column-index={virtualColumn.index}
ref={(el) => {
virtualRow.measureElement(el)
virtualColumn.measureElement(el)
rowVirtualizer.measureElement(el)
columnVirtualizer.measureElement(el)
}}
className={
virtualColumn.index % 2
Expand Down
76 changes: 42 additions & 34 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,14 +28,13 @@ export interface Range {

type Key = number | string

export interface VirtualItem<TItemElement extends Element> {
export interface VirtualItem {
key: Key
index: number
start: number
end: number
size: number
lane: number
measureElement: (node: TItemElement | null | undefined) => void
}

export interface Rect {
Expand Down Expand Up @@ -319,7 +318,7 @@ export interface VirtualizerOptions<
scrollMargin?: number
gap?: number
indexAttribute?: string
initialMeasurementsCache?: Array<VirtualItem<TItemElement>>
initialMeasurementsCache?: Array<VirtualItem>
lanes?: number
isScrollingResetDelay?: number
enabled?: boolean
Expand All @@ -336,7 +335,7 @@ export class Virtualizer<
targetWindow: (Window & typeof globalThis) | null = null
isScrolling = false
private scrollToIndexTimeoutId: number | null = null
measurementsCache: Array<VirtualItem<TItemElement>> = []
measurementsCache: Array<VirtualItem> = []
private itemSizeCache = new Map<Key, number>()
private pendingMeasuredCacheIndexes: Array<number> = []
scrollRect: Rect | null = null
Expand All @@ -346,7 +345,7 @@ export class Virtualizer<
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
item: VirtualItem<TItemElement>,
item: VirtualItem,
delta: number,
instance: Virtualizer<TScrollElement, TItemElement>,
) => boolean)
Expand Down Expand Up @@ -414,22 +413,34 @@ export class Virtualizer<
}
}

private notify = (force: boolean, sync: boolean) => {
const { startIndex, endIndex } = this.range ?? {
startIndex: undefined,
endIndex: undefined,
}
const range = this.calculateRange()

if (
force ||
startIndex !== range?.startIndex ||
endIndex !== range?.endIndex
) {
this.options.onChange?.(this, sync)
}
private notify = (sync: boolean) => {
this.options.onChange?.(this, sync)
}

private maybeNotify = memo(
() => {
this.calculateRange()

return [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
]
},
(isScrolling) => {
this.notify(isScrolling)
},
{
key: process.env.NODE_ENV !== 'production' && 'maybeNotify',
debug: () => this.options.debug,
initialDeps: [
this.isScrolling,
this.range ? this.range.startIndex : null,
this.range ? this.range.endIndex : null,
] as [boolean, number | null, number | null],
},
)

private cleanup = () => {
this.unsubs.filter(Boolean).forEach((d) => d!())
this.unsubs = []
Expand All @@ -454,7 +465,7 @@ export class Virtualizer<
this.cleanup()

if (!scrollElement) {
this.notify(false, false)
this.maybeNotify()
return
}

Expand All @@ -474,7 +485,7 @@ export class Virtualizer<
this.unsubs.push(
this.options.observeElementRect(this, (rect) => {
this.scrollRect = rect
this.notify(false, false)
this.maybeNotify()
}),
)

Expand All @@ -487,11 +498,9 @@ export class Virtualizer<
: 'backward'
: null
this.scrollOffset = offset

const prevIsScrolling = this.isScrolling
this.isScrolling = isScrolling

this.notify(prevIsScrolling !== isScrolling, isScrolling)
this.maybeNotify()
}),
)
}
Expand Down Expand Up @@ -524,11 +533,11 @@ export class Virtualizer<
}

private getFurthestMeasurement = (
measurements: Array<VirtualItem<TItemElement>>,
measurements: Array<VirtualItem>,
index: number,
) => {
const furthestMeasurementsFound = new Map<number, true>()
const furthestMeasurements = new Map<number, VirtualItem<TItemElement>>()
const furthestMeasurements = new Map<number, VirtualItem>()
for (let m = index - 1; m >= 0; m--) {
const measurement = measurements[m]!

Expand Down Expand Up @@ -645,7 +654,6 @@ export class Virtualizer<
end,
key,
lane,
measureElement: this.measureElement,
}
}

Expand Down Expand Up @@ -737,7 +745,7 @@ export class Virtualizer<
}

resizeItem = (index: number, size: number) => {
const item = this.getMeasurements()[index]
const item = this.measurementsCache[index]
if (!item) {
return
}
Expand All @@ -763,7 +771,7 @@ export class Virtualizer<
this.pendingMeasuredCacheIndexes.push(item.index)
this.itemSizeCache = new Map(this.itemSizeCache.set(item.key, size))

this.notify(true, false)
this.notify(false)
}
}

Expand All @@ -784,7 +792,7 @@ export class Virtualizer<
getVirtualItems = memo(
() => [this.getIndexes(), this.getMeasurements()],
(indexes, measurements) => {
const virtualItems: Array<VirtualItem<TItemElement>> = []
const virtualItems: Array<VirtualItem> = []

for (let k = 0, len = indexes.length; k < len; k++) {
const i = indexes[k]!
Expand Down Expand Up @@ -857,7 +865,7 @@ export class Virtualizer<
getOffsetForIndex = (index: number, align: ScrollAlignment = 'auto') => {
index = Math.max(0, Math.min(index, this.options.count - 1))

const item = this.getMeasurements()[index]
const item = this.measurementsCache[index]
if (!item) {
return undefined
}
Expand Down Expand Up @@ -1004,7 +1012,7 @@ export class Virtualizer<

measure = () => {
this.itemSizeCache = new Map()
this.options.onChange?.(this, false)
this.notify(false)
}
}

Expand Down Expand Up @@ -1034,12 +1042,12 @@ const findNearestBinarySearch = (
}
}

function calculateRange<TItemElement extends Element>({
function calculateRange({
measurements,
outerSize,
scrollOffset,
}: {
measurements: Array<VirtualItem<TItemElement>>
measurements: Array<VirtualItem>
outerSize: number
scrollOffset: number
}) {
Expand Down

0 comments on commit 0ad4e6c

Please sign in to comment.