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
5 changes: 5 additions & 0 deletions .changeset/use-cached-measurements.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/virtual-core': minor
---

Add `useCachedMeasurements` option to skip DOM measurement when the list is hidden (e.g. `display: none`). When enabled, the default `measureElement` returns the cached size or `estimateSize` fallback instead of reading the DOM, preventing ResizeObserver from resetting measurements to zero.
16 changes: 16 additions & 0 deletions docs/api/virtualizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,22 @@ When enabled, defers ResizeObserver measurement processing to the next animation

Only enable this option if you have a specific reason and have measured that it improves your use case.

### `useCachedMeasurements`

```tsx
useCachedMeasurements?: boolean
```

**Default:** `false`

When enabled, the default `measureElement` implementation skips DOM measurement and returns the previously cached size for each item (falling back to `estimateSize` if no cached size exists).

This is useful when the virtualized list is temporarily hidden (e.g. via `display: none` on a parent element). Without this option, the ResizeObserver fires with size `0` for all items when hidden, resetting all measurements. When the list becomes visible again, items may need to be re-measured, which can cause layout shifts.

**Usage:** Toggle this option to `true` before hiding the list and back to `false` when showing it. The ResizeObserver remains attached, so real measurements resume automatically when the flag is turned off and elements become visible again.

> ⚠️ This option only affects the default `measureElement`. If you provide a custom `measureElement`, you are responsible for handling this case yourself.

## Virtualizer Instance

The following properties and methods are available on the virtualizer instance:
Expand Down
10 changes: 10 additions & 0 deletions packages/react-virtual/e2e/app/cached-measurements/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
70 changes: 70 additions & 0 deletions packages/react-virtual/e2e/app/cached-measurements/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizer } from '@tanstack/react-virtual'

const items = Array.from({ length: 20 }, (_, i) => ({
id: `item-${i}`,
label: `Item ${i}`,
height: 30 + (i % 3) * 20, // variable heights: 30, 50, 70
}))

const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)
const [hidden, setHidden] = React.useState(false)

const rowVirtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: (i) => items[i].height,
getItemKey: (i) => items[i].id,
useCachedMeasurements: hidden,
directDomUpdates: true,
})

return (
<div>
<button data-testid="toggle" onClick={() => setHidden((h) => !h)}>
{hidden ? 'Show' : 'Hide'}
</button>
<div
data-testid="list-wrapper"
style={{ display: hidden ? 'none' : 'block' }}
>
<div
ref={parentRef}
id="scroll-container"
style={{ height: 200, overflow: 'auto' }}
>
<div
ref={rowVirtualizer.containerRef}
style={{ position: 'relative', width: '100%' }}
>
{rowVirtualizer.getVirtualItems().map((v) => {
const item = items[v.index]
return (
<div
key={item.id}
data-testid={item.id}
ref={rowVirtualizer.measureElement}
data-index={v.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: item.height,
}}
>
{item.label}
</div>
)
})}
</div>
</div>
</div>
<div data-testid="total-size">{rowVirtualizer.getTotalSize()}</div>
</div>
)
}

ReactDOM.createRoot(document.getElementById('root')!).render(<App />)
41 changes: 41 additions & 0 deletions packages/react-virtual/e2e/app/test/cached-measurements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { expect, test } from '@playwright/test'

test('preserves item sizes when list is hidden with useCachedMeasurements', async ({
page,
}) => {
await page.goto('/cached-measurements/')

// Wait for initial render and measurements
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
await page.waitForTimeout(200)

// Capture totalSize before hiding
const sizeBefore = await page
.locator('[data-testid="total-size"]')
.textContent()
expect(Number(sizeBefore)).toBeGreaterThan(0)

// Hide the list
await page.click('[data-testid="toggle"]')
await expect(page.locator('[data-testid="list-wrapper"]')).toBeHidden()

// Wait for RO callbacks to fire
await page.waitForTimeout(300)

// totalSize should be preserved (not reset to estimate-only values)
const sizeWhileHidden = await page
.locator('[data-testid="total-size"]')
.textContent()
expect(Number(sizeWhileHidden)).toBe(Number(sizeBefore))

// Show the list again
await page.click('[data-testid="toggle"]')
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
await page.waitForTimeout(200)

// totalSize should still match
const sizeAfterShow = await page
.locator('[data-testid="total-size"]')
.textContent()
expect(Number(sizeAfterShow)).toBe(Number(sizeBefore))
})
4 changes: 4 additions & 0 deletions packages/react-virtual/e2e/app/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ export default defineConfig({
__dirname,
'direct-dom-updates/index.html',
),
'cached-measurements': path.resolve(
__dirname,
'cached-measurements/index.html',
),
},
},
},
Expand Down
12 changes: 12 additions & 0 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,16 @@ export const measureElement = <TItemElement extends Element>(
entry: ResizeObserverEntry | undefined,
instance: Virtualizer<any, TItemElement>,
) => {
// When useCachedMeasurements is enabled, return the cached size
// (or estimateSize as fallback) instead of measuring the DOM.
if (instance.options.useCachedMeasurements) {
const index = instance.indexFromElement(element)
const key = instance.options.getItemKey(index)
return (
instance.itemSizeCache.get(key) ?? instance.options.estimateSize(index)
)
}

if (entry?.borderBoxSize) {
const box = entry.borderBoxSize[0]
if (box) {
Expand Down Expand Up @@ -358,6 +368,7 @@ export interface VirtualizerOptions<
isRtl?: boolean
useAnimationFrameWithResizeObserver?: boolean
laneAssignmentMode?: LaneAssignmentMode
useCachedMeasurements?: boolean
}

type ScrollState = {
Expand Down Expand Up @@ -539,6 +550,7 @@ export class Virtualizer<
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
laneAssignmentMode: 'estimate',
useCachedMeasurements: false,
} as unknown as Required<VirtualizerOptions<TScrollElement, TItemElement>>

for (const key in opts) {
Expand Down
Loading