Skip to content
Open
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/hungry-donkeys-applaud.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/react-virtual': minor
---

Add `useVirtualizerSnapshot` and `useWindowVirtualizerSnapshot`: variants of the existing hooks that return `virtualItems` / `totalSize` as immutable snapshot data via `useSyncExternalStore`, with the stable `virtualizer` instance alongside for imperative APIs. Components consuming the snapshot hooks are compatible with React Compiler — the compiler skips components calling `useVirtualizer` as a known-incompatible API (#736, #743, #1119), while snapshot consumers compile and memoize correctly.
100 changes: 100 additions & 0 deletions docs/framework/react/react-virtual.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,106 @@ function useWindowVirtualizer<TItemElement = unknown>(

This function returns a window-based `Virtualizer` instance configured to work with the window as the scrollElement.

## `useVirtualizerSnapshot`

```tsx
function useVirtualizerSnapshot<
TScrollElement extends Element,
TItemElement extends Element,
>(
options: PartialKeys<
ReactVirtualizerSnapshotOptions<TScrollElement, TItemElement>,
'observeElementRect' | 'observeElementOffset' | 'scrollToFn'
>,
): VirtualizerSnapshot<TScrollElement, TItemElement>
```

Like `useVirtualizer`, but returns the render-facing values as immutable
snapshot data instead of methods to call during render:

```tsx
interface VirtualizerSnapshot<
TScrollElement extends Element | Window,
TItemElement extends Element,
> {
readonly virtualItems: ReadonlyArray<Readonly<VirtualItem>>
readonly totalSize: number
readonly virtualizer: Virtualizer<TScrollElement, TItemElement>
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

### Why: React Compiler

`useVirtualizer` returns a stable instance whose `getVirtualItems()` /
`getTotalSize()` read mutable internal state during render. Memoizing
compilers cache those reads keyed on the stable instance and serve the first
result forever, so the list never updates
([#736](https://github.com/TanStack/virtual/issues/736)) — which is why React
Compiler skips any component calling `useVirtualizer` as a
known-incompatible API, leaving that component entirely un-memoized.

With `useVirtualizerSnapshot`, positional data arrives as reactive values
(via `useSyncExternalStore`): `virtualItems` and `totalSize` change identity
exactly when the computed geometry changes. Components consuming the
snapshot hook are compiled — and memoized correctly — by React Compiler.

- Read `virtualItems` / `totalSize` from the snapshot during render.
- Use `snapshot.virtualizer` for imperative APIs only — `measureElement` (as
a ref), `scrollToIndex`, `scrollToOffset`, `measure`. Its identity is
stable for the lifetime of the component. Do not read positional data from
it during render.

```tsx
const { virtualItems, totalSize, virtualizer } = useVirtualizerSnapshot({
count: rows.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 35,
})

return (
<div ref={parentRef} style={{ height: 400, overflow: 'auto' }}>
<div style={{ height: totalSize, position: 'relative' }}>
{virtualItems.map((item) => (
<div
key={item.key}
ref={virtualizer.measureElement}
data-index={item.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${item.start}px)`,
}}
>
Row {item.index}
</div>
))}
</div>
</div>
)
```

The snapshot hooks do not support `directDomUpdates` — that option is an
alternative strategy that bypasses React rendering for scroll updates, while
the snapshot hooks make React rendering itself compiler-safe.

## `useWindowVirtualizerSnapshot`

```tsx
function useWindowVirtualizerSnapshot<TItemElement extends Element>(
options: PartialKeys<
ReactVirtualizerSnapshotOptions<Window, TItemElement>,
| 'getScrollElement'
| 'observeElementRect'
| 'observeElementOffset'
| 'scrollToFn'
>,
): VirtualizerSnapshot<Window, TItemElement>
```

Window-scrolling variant of `useVirtualizerSnapshot`.

## React-Specific Options

### `useFlushSync`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineConfig({
rollupOptions: {
input: {
'react-compiler': path.resolve(root, 'react-compiler/index.html'),
'snapshot-hook': path.resolve(root, 'snapshot-hook/index.html'),
},
},
},
Expand Down
12 changes: 12 additions & 0 deletions packages/react-virtual/e2e/app/snapshot-hook/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>useVirtualizerSnapshot + React Compiler</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./main.tsx"></script>
</body>
</html>
83 changes: 83 additions & 0 deletions packages/react-virtual/e2e/app/snapshot-hook/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import { useVirtualizerSnapshot } from '@tanstack/react-virtual'

const ITEM_SIZE = 40
const COUNT = 1000

/**
* Regression page for https://github.com/TanStack/virtual/issues/736 using
* `useVirtualizerSnapshot` under React Compiler.
*
* Unlike `useVirtualizer` — which the compiler skips as a known-incompatible
* API — components calling the snapshot hook ARE compiled. The snapshot's
* identity changes whenever the computed items change, so the compiler's
* memoized output stays fresh: item-500 must appear after scrolling. This
* page must stay free of patterns that make the compiler bail (no ref
* mutation during render, etc.), or it silently stops guarding the compiled
* path.
*/
const App = () => {
const parentRef = React.useRef<HTMLDivElement>(null)

const { virtualItems, totalSize, virtualizer } = useVirtualizerSnapshot({
count: COUNT,
getScrollElement: () => parentRef.current,
estimateSize: () => ITEM_SIZE,
overscan: 2,
})

// Commit counter for the spec, kept outside React-managed content so the
// component stays compiler-clean (no ref/global mutation during render).
const commitCountRef = React.useRef(0)
React.useEffect(() => {
commitCountRef.current += 1
const el = document.getElementById('commit-count')
if (el) el.textContent = String(commitCountRef.current)
})

return (
<div>
<div id="commit-count" data-testid="commit-count" />
<button id="scroll-to-500" onClick={() => virtualizer.scrollToIndex(500)}>
Scroll to 500
</button>

<div
ref={parentRef}
id="scroll-container"
style={{ height: 400, overflow: 'auto' }}
>
<div
id="inner"
style={{
position: 'relative',
width: '100%',
height: totalSize,
}}
>
{virtualItems.map((v) => (
<div
key={v.key}
data-testid={`item-${v.index}`}
ref={virtualizer.measureElement}
data-index={v.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: ITEM_SIZE,
transform: `translateY(${v.start}px)`,
}}
>
Row {v.index}
</div>
))}
</div>
</div>
</div>
)
}

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

const ITEM_SIZE = 40
const COUNT = 1000

test.describe('useVirtualizerSnapshot under React Compiler', () => {
test('renders items on initial load', async ({ page }) => {
await page.goto('/snapshot-hook/')

await expect(page.locator('[data-testid="item-0"]')).toBeVisible()
await expect(page.locator('[data-testid="item-0"]')).toContainText('Row 0')

// The sizer height comes from the snapshot's totalSize.
const inner = page.locator('#inner')
await expect(inner).toHaveAttribute(
'style',
new RegExp(`height:\\s*${COUNT * ITEM_SIZE}px`),
)
})

test('items update after scrolling — the #736 regression, compiled', async ({
page,
}) => {
await page.goto('/snapshot-hook/')

await expect(page.locator('[data-testid="item-0"]')).toBeVisible()

await page.click('#scroll-to-500')

// With `useVirtualizer` a compiled consumer would keep serving the
// initial items forever. The snapshot hook publishes a new identity, so
// the compiled component re-derives its output.
await expect(page.locator('[data-testid="item-500"]')).toBeVisible({
timeout: 5000,
})
const style =
(await page.locator('[data-testid="item-500"]').getAttribute('style')) ??
''
expect(style).toMatch(/translateY\(20000px\)/)

// And the initial row is no longer rendered.
await expect(page.locator('[data-testid="item-0"]')).toHaveCount(0)
})

test('re-renders are driven by snapshot changes', async ({ page }) => {
await page.goto('/snapshot-hook/')
await expect(page.locator('[data-testid="item-0"]')).toBeVisible()

const before = Number(
await page.locator('[data-testid="commit-count"]').textContent(),
)
expect(before).toBeGreaterThan(0)

await page.click('#scroll-to-500')
await expect(page.locator('[data-testid="item-500"]')).toBeVisible()

const after = Number(
await page.locator('[data-testid="commit-count"]').textContent(),
)
expect(after).toBeGreaterThan(before)
})
})
5 changes: 4 additions & 1 deletion packages/react-virtual/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@
"src"
],
"dependencies": {
"@tanstack/virtual-core": "workspace:*"
"@tanstack/virtual-core": "workspace:*",
"use-sync-external-store": "^1.6.0"
},
"devDependencies": {
"@babel/core": "^7.26.0",
"@testing-library/react": "^16.3.0",
"@types/react": "^19.2.16",
"@types/react-dom": "^19.2.3",
"@types/use-sync-external-store": "^1.5.0",
"@vitejs/plugin-react": "^4.5.2",
"babel-plugin-react-compiler": "^1.0.0",
"react": "^19.2.7",
Expand Down
Loading