From 320f97cc12249fec972e6e59a3aac782c5e5c503 Mon Sep 17 00:00:00 2001 From: Damian Pieczynski Date: Tue, 2 Jun 2026 08:40:24 +0200 Subject: [PATCH] feat(virtual-core): add `useCachedMeasurements` option to preserve sizes when list is hidden --- .changeset/use-cached-measurements.md | 5 ++ docs/api/virtualizer.md | 16 +++++ .../e2e/app/cached-measurements/index.html | 10 +++ .../e2e/app/cached-measurements/main.tsx | 70 +++++++++++++++++++ .../e2e/app/test/cached-measurements.spec.ts | 41 +++++++++++ packages/react-virtual/e2e/app/vite.config.ts | 4 ++ packages/virtual-core/src/index.ts | 12 ++++ 7 files changed, 158 insertions(+) create mode 100644 .changeset/use-cached-measurements.md create mode 100644 packages/react-virtual/e2e/app/cached-measurements/index.html create mode 100644 packages/react-virtual/e2e/app/cached-measurements/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/cached-measurements.spec.ts diff --git a/.changeset/use-cached-measurements.md b/.changeset/use-cached-measurements.md new file mode 100644 index 00000000..8eef1f39 --- /dev/null +++ b/.changeset/use-cached-measurements.md @@ -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. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index d884804b..0c7c549f 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -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: diff --git a/packages/react-virtual/e2e/app/cached-measurements/index.html b/packages/react-virtual/e2e/app/cached-measurements/index.html new file mode 100644 index 00000000..56f418f6 --- /dev/null +++ b/packages/react-virtual/e2e/app/cached-measurements/index.html @@ -0,0 +1,10 @@ + + + + + + +
+ + + diff --git a/packages/react-virtual/e2e/app/cached-measurements/main.tsx b/packages/react-virtual/e2e/app/cached-measurements/main.tsx new file mode 100644 index 00000000..57c02899 --- /dev/null +++ b/packages/react-virtual/e2e/app/cached-measurements/main.tsx @@ -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(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 ( +
+ + +
{rowVirtualizer.getTotalSize()}
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/cached-measurements.spec.ts b/packages/react-virtual/e2e/app/test/cached-measurements.spec.ts new file mode 100644 index 00000000..26eb4e62 --- /dev/null +++ b/packages/react-virtual/e2e/app/test/cached-measurements.spec.ts @@ -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)) +}) diff --git a/packages/react-virtual/e2e/app/vite.config.ts b/packages/react-virtual/e2e/app/vite.config.ts index 70a1602f..693eca23 100644 --- a/packages/react-virtual/e2e/app/vite.config.ts +++ b/packages/react-virtual/e2e/app/vite.config.ts @@ -20,6 +20,10 @@ export default defineConfig({ __dirname, 'direct-dom-updates/index.html', ), + 'cached-measurements': path.resolve( + __dirname, + 'cached-measurements/index.html', + ), }, }, }, diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 261efaa9..d35b3e06 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -242,6 +242,16 @@ export const measureElement = ( entry: ResizeObserverEntry | undefined, instance: Virtualizer, ) => { + // 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) { @@ -358,6 +368,7 @@ export interface VirtualizerOptions< isRtl?: boolean useAnimationFrameWithResizeObserver?: boolean laneAssignmentMode?: LaneAssignmentMode + useCachedMeasurements?: boolean } type ScrollState = { @@ -539,6 +550,7 @@ export class Virtualizer< useScrollendEvent: false, useAnimationFrameWithResizeObserver: false, laneAssignmentMode: 'estimate', + useCachedMeasurements: false, } as unknown as Required> for (const key in opts) {