From b38b92e57dd4cb3437d753b9891e366aaac0dbe6 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 25 Jul 2026 03:10:00 +0200 Subject: [PATCH 1/4] feat(react-virtual): add useVirtualizerSnapshot for React Compiler compatibility React Compiler skips any component calling useVirtualizer as a known-incompatible API: the hook returns a stable instance whose getVirtualItems()/getTotalSize() read mutable internal state during render, so a memoizing compiler caches the first result keyed on the stable instance and the list never updates (#736, #743). The skip keeps apps correct but leaves every consuming component un-memoized (#1119). useVirtualizerSnapshot (and useWindowVirtualizerSnapshot) return the render-facing values as immutable snapshot data instead, via useSyncExternalStore (official shim, preserving the React >=16.8 peer range): virtualItems and totalSize change identity exactly when the computed geometry changes, while the stable virtualizer instance rides along for imperative APIs (measureElement, scrollToIndex, ...). Because the compiler's skip is keyed on the imported property name, components consuming the snapshot hooks compile - and memoize correctly - today. - snapshot cache rebuilt lazily on onChange, with part-comparison so notifications that change nothing visible (isScrolling flips) publish the same reference and skip the re-render - user onChange still chained; useFlushSync semantics preserved - unit tests pin the identity contract (stable across unrelated re-renders, fresh on geometry change), StrictMode, and SSR via getServerSnapshot - compiler-contract tests run babel-plugin-react-compiler in CI: snapshot consumers emit the _c() memo cache, useVirtualizer consumers stay skipped - e2e: a compiled snapshot-hook page under the existing react-compiler build (#1187 infra) proving the #736 scenario now passes with the consumer compiled --- .changeset/hungry-donkeys-applaud.md | 5 + docs/framework/react/react-virtual.md | 94 +++++++++ .../e2e/app/react-compiler-vite.config.ts | 1 + .../e2e/app/snapshot-hook/index.html | 12 ++ .../e2e/app/snapshot-hook/main.tsx | 83 ++++++++ .../e2e/app/test/snapshot-hook.spec.ts | 62 ++++++ packages/react-virtual/package.json | 5 +- packages/react-virtual/src/index.tsx | 198 +++++++++++++++++- packages/react-virtual/tests/compiler.test.ts | 75 +++++++ .../react-virtual/tests/snapshot.test.tsx | 185 ++++++++++++++++ pnpm-lock.yaml | 72 ++++--- 11 files changed, 761 insertions(+), 31 deletions(-) create mode 100644 .changeset/hungry-donkeys-applaud.md create mode 100644 packages/react-virtual/e2e/app/snapshot-hook/index.html create mode 100644 packages/react-virtual/e2e/app/snapshot-hook/main.tsx create mode 100644 packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts create mode 100644 packages/react-virtual/tests/compiler.test.ts create mode 100644 packages/react-virtual/tests/snapshot.test.tsx diff --git a/.changeset/hungry-donkeys-applaud.md b/.changeset/hungry-donkeys-applaud.md new file mode 100644 index 000000000..845c00bbb --- /dev/null +++ b/.changeset/hungry-donkeys-applaud.md @@ -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. diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 02d544e05..0d317c3ac 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -33,6 +33,100 @@ function useWindowVirtualizer( This function returns a window-based `Virtualizer` instance configured to work with the window as the scrollElement. +## `useVirtualizerSnapshot` + +```tsx +function useVirtualizerSnapshot( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): VirtualizerSnapshot +``` + +Like `useVirtualizer`, but returns the render-facing values as immutable +snapshot data instead of methods to call during render: + +```tsx +interface VirtualizerSnapshot { + virtualItems: Array + totalSize: number + virtualizer: Virtualizer +} +``` + +### 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 ( +
+
+ {virtualItems.map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+) +``` + +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( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + | 'getScrollElement' + | 'observeElementRect' + | 'observeElementOffset' + | 'scrollToFn' + >, +): VirtualizerSnapshot +``` + +Window-scrolling variant of `useVirtualizerSnapshot`. + ## React-Specific Options ### `useFlushSync` diff --git a/packages/react-virtual/e2e/app/react-compiler-vite.config.ts b/packages/react-virtual/e2e/app/react-compiler-vite.config.ts index 59ec35a04..28039d2e9 100644 --- a/packages/react-virtual/e2e/app/react-compiler-vite.config.ts +++ b/packages/react-virtual/e2e/app/react-compiler-vite.config.ts @@ -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'), }, }, }, diff --git a/packages/react-virtual/e2e/app/snapshot-hook/index.html b/packages/react-virtual/e2e/app/snapshot-hook/index.html new file mode 100644 index 000000000..822a1ab3e --- /dev/null +++ b/packages/react-virtual/e2e/app/snapshot-hook/index.html @@ -0,0 +1,12 @@ + + + + + + useVirtualizerSnapshot + React Compiler + + +
+ + + diff --git a/packages/react-virtual/e2e/app/snapshot-hook/main.tsx b/packages/react-virtual/e2e/app/snapshot-hook/main.tsx new file mode 100644 index 000000000..e982b0c7c --- /dev/null +++ b/packages/react-virtual/e2e/app/snapshot-hook/main.tsx @@ -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(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 ( +
+
+ + +
+
+ {virtualItems.map((v) => ( +
+ Row {v.index} +
+ ))} +
+
+
+ ) +} + +ReactDOM.createRoot(document.getElementById('root')!).render() diff --git a/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts b/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts new file mode 100644 index 000000000..95e71c27d --- /dev/null +++ b/packages/react-virtual/e2e/app/test/snapshot-hook.spec.ts @@ -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) + }) +}) diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index e2af702d6..14a83a3bc 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -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", diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index 737ad53fc..6f1fef6ae 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -1,5 +1,6 @@ import * as React from 'react' import { flushSync } from 'react-dom' +import { useSyncExternalStore } from 'use-sync-external-store/shim' import { Virtualizer, elementScroll, @@ -9,7 +10,11 @@ import { observeWindowRect, windowScroll, } from '@tanstack/virtual-core' -import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' +import type { + PartialKeys, + VirtualItem, + VirtualizerOptions, +} from '@tanstack/virtual-core' export * from '@tanstack/virtual-core' @@ -277,3 +282,194 @@ export function useWindowVirtualizer( ...options, }) } + +export type ReactVirtualizerSnapshotOptions< + TScrollElement extends Element | Window, + TItemElement extends Element, +> = VirtualizerOptions & { + useFlushSync?: boolean +} + +export interface VirtualizerSnapshot< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + /** + * The virtual items for the current visible range, as immutable snapshot + * data. The array reference changes when — and only when — the computed + * items change, so memoization keyed on it (manual or via React Compiler) + * stays correct. + */ + virtualItems: Array + /** + * Total size of the virtualized extent, captured in the same snapshot as + * `virtualItems`. + */ + totalSize: number + /** + * The underlying virtualizer instance, 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 (`getVirtualItems()`, `getTotalSize()`, + * `range`, `scrollOffset`, …) from it during render — those reads are what + * memoizing compilers cache stale (#736). Use the snapshot fields instead. + */ + virtualizer: Virtualizer +} + +function useVirtualizerSnapshotBase< + TScrollElement extends Element | Window, + TItemElement extends Element, +>({ + useFlushSync = true, + ...options +}: ReactVirtualizerSnapshotOptions< + TScrollElement, + TItemElement +>): VirtualizerSnapshot { + const [store] = React.useState(() => { + const listeners = new Set<() => void>() + let snapshot: { + virtualItems: Array + totalSize: number + } | null = null + let dirty = true + + const state = { + instance: null as Virtualizer | null, + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + markDirty: () => { + dirty = true + }, + notify: () => { + listeners.forEach((listener) => listener()) + }, + // Must return a cached value until the store actually changes + // (React warns and loops otherwise). `getVirtualItems` is memoized in + // virtual-core, so comparing the parts keeps the snapshot reference + // stable across notifications that didn't change anything visible + // (e.g. `isScrolling` flips). + getSnapshot: () => { + const instance = state.instance + if (instance === null) { + throw new Error('virtualizer read before initialization') + } + if (dirty || snapshot === null) { + dirty = false + const virtualItems = instance.getVirtualItems() + const totalSize = instance.getTotalSize() + if ( + snapshot === null || + snapshot.virtualItems !== virtualItems || + snapshot.totalSize !== totalSize + ) { + snapshot = { virtualItems, totalSize } + } + } + return snapshot + }, + } + return state + }) + + const resolvedOptions: VirtualizerOptions = { + ...options, + onChange: (instance, sync) => { + store.markDirty() + if (useFlushSync && sync) { + flushSync(store.notify) + } else { + store.notify() + } + options.onChange?.(instance, sync) + }, + } + + const [instance] = React.useState( + () => new Virtualizer(resolvedOptions), + ) + store.instance = instance + instance.setOptions(resolvedOptions) + + useIsomorphicLayoutEffect(() => { + return instance._didMount() + }, []) + + useIsomorphicLayoutEffect(() => { + return instance._willUpdate() + }) + + const snapshot = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ) + + return React.useMemo( + () => ({ + virtualItems: snapshot.virtualItems, + totalSize: snapshot.totalSize, + virtualizer: instance, + }), + [snapshot, instance], + ) +} + +/** + * Like `useVirtualizer`, but returns the render-facing values as immutable + * snapshot data (via `useSyncExternalStore`) instead of methods to call + * during render. + * + * `useVirtualizer` returns a stable instance whose `getVirtualItems()` / + * `getTotalSize()` read mutable internal state. Memoizing compilers (React + * Compiler) cache such reads keyed on the stable instance and serve the + * first result forever (#736, #743) — which is why the compiler skips + * components calling `useVirtualizer` (#1119). With this hook the data + * arrives as reactive values whose identities change when the geometry + * changes, so consuming components compile — and memoize — correctly. + */ +export function useVirtualizerSnapshot< + TScrollElement extends Element, + TItemElement extends Element, +>( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' + >, +): VirtualizerSnapshot { + return useVirtualizerSnapshotBase({ + observeElementRect: observeElementRect, + observeElementOffset: observeElementOffset, + scrollToFn: elementScroll, + ...options, + }) +} + +/** + * Window-scrolling variant of `useVirtualizerSnapshot`. See its docs for the + * motivation (React Compiler compatibility). + */ +export function useWindowVirtualizerSnapshot( + options: PartialKeys< + ReactVirtualizerSnapshotOptions, + | 'getScrollElement' + | 'observeElementRect' + | 'observeElementOffset' + | 'scrollToFn' + >, +): VirtualizerSnapshot { + return useVirtualizerSnapshotBase({ + getScrollElement: () => (typeof document !== 'undefined' ? window : null), + observeElementRect: observeWindowRect, + observeElementOffset: observeWindowOffset, + scrollToFn: windowScroll, + initialOffset: () => (typeof document !== 'undefined' ? window.scrollY : 0), + ...options, + }) +} diff --git a/packages/react-virtual/tests/compiler.test.ts b/packages/react-virtual/tests/compiler.test.ts new file mode 100644 index 000000000..25335b6c4 --- /dev/null +++ b/packages/react-virtual/tests/compiler.test.ts @@ -0,0 +1,75 @@ +import { expect, test } from 'vitest' +import { transformSync } from '@babel/core' + +/** + * Pins the React Compiler contract this package's snapshot hooks exist for. + * + * babel-plugin-react-compiler ships a hardcoded type entry marking + * `useVirtualizer` from '@tanstack/react-virtual' as known-incompatible + * ("returns functions that cannot be memoized safely"), so components + * calling it are skipped — never memoized. That skip is keyed on the + * imported property name, so components calling `useVirtualizerSnapshot` + * compile normally, and the snapshot's identity semantics (see + * snapshot.test.tsx) are what keep the compiled output correct. + * + * If either half of this test starts failing after a compiler upgrade, the + * ecosystem contract changed: re-evaluate the guidance in the docs. + */ +function compile(source: string): string { + const result = transformSync(source, { + configFile: false, + babelrc: false, + filename: 'consumer.tsx', + parserOpts: { plugins: ['typescript', 'jsx'] }, + plugins: [['babel-plugin-react-compiler', { panicThreshold: 'none' }]], + }) + if (result?.code == null) throw new Error('babel produced no output') + return result.code +} + +const consumerOf = (hookCall: string) => ` + import * as React from 'react' + import { useVirtualizer, useVirtualizerSnapshot } from '@tanstack/react-virtual' + + export function List() { + const parentRef = React.useRef(null) + ${hookCall} + return ( +
+ {items.map((item) => ( +
{item.index}
+ ))} +
+ ) + } +` + +test('components calling useVirtualizerSnapshot are compiled', () => { + const code = compile( + consumerOf(` + const { virtualItems: items } = useVirtualizerSnapshot({ + count: 100, + getScrollElement: () => parentRef.current, + estimateSize: () => 40, + }) + `), + ) + + // The memo cache is the signature of a compiled component. + expect(code).toContain('_c(') +}) + +test('components calling useVirtualizer are skipped as known-incompatible', () => { + const code = compile( + consumerOf(` + const virtualizer = useVirtualizer({ + count: 100, + getScrollElement: () => parentRef.current, + estimateSize: () => 40, + }) + const items = virtualizer.getVirtualItems() + `), + ) + + expect(code).not.toContain('_c(') +}) diff --git a/packages/react-virtual/tests/snapshot.test.tsx b/packages/react-virtual/tests/snapshot.test.tsx new file mode 100644 index 000000000..29d31be51 --- /dev/null +++ b/packages/react-virtual/tests/snapshot.test.tsx @@ -0,0 +1,185 @@ +import { beforeEach, expect, test, vi } from 'vitest' +import * as React from 'react' +import { act, render, screen } from '@testing-library/react' +import { renderToString } from 'react-dom/server' + +import { + useVirtualizerSnapshot, + useWindowVirtualizerSnapshot, +} from '../src/index' +import type { VirtualizerSnapshot } from '../src/index' + +let renderer: ReturnType + +const captured: { + results: Array> + offsetCb: ((offset: number, isScrolling: boolean) => void) | null +} = { results: [], offsetCb: null } + +beforeEach(() => { + renderer = vi.fn(() => undefined) + captured.results = [] + captured.offsetCb = null +}) + +interface SnapshotListProps { + count?: number + height?: number + width?: number + label?: string +} + +function SnapshotList({ + count = 200, + height = 200, + width = 200, + label = '', +}: SnapshotListProps) { + renderer() + + const parentRef = React.useRef(null) + + const result = useVirtualizerSnapshot({ + count, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + observeElementRect: (_, cb) => { + cb({ height, width }) + }, + observeElementOffset: (_, cb) => { + cb(0, false) + captured.offsetCb = cb + }, + }) + captured.results.push(result) + + const { virtualItems, totalSize } = result + + return ( +
+
+ {virtualItems.map((virtualRow) => ( +
+ Row {virtualRow.index} + {label} +
+ ))} +
+
+ ) +} + +test('renders the initial range from the snapshot', () => { + render() + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() + expect(screen.queryByText('Row 5')).not.toBeInTheDocument() +}) + +test('keeps snapshot and result referentially stable across unrelated re-renders', () => { + const { rerender } = render() + const afterMount = captured.results[captured.results.length - 1]! + + rerender() + const afterRerender = captured.results[captured.results.length - 1]! + + // The compiler contract: no geometry change -> identical references, so + // memoization keyed on these values stays correct. + expect(afterRerender.virtualItems).toBe(afterMount.virtualItems) + expect(afterRerender.totalSize).toBe(afterMount.totalSize) + expect(afterRerender.virtualizer).toBe(afterMount.virtualizer) + expect(afterRerender).toBe(afterMount) +}) + +test('publishes a new snapshot when the scroll offset changes the range', () => { + render() + const before = captured.results[captured.results.length - 1]! + + act(() => { + captured.offsetCb!(500, true) + }) + + const after = captured.results[captured.results.length - 1]! + + expect(screen.queryByText('Row 10')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + expect(after.virtualItems).not.toBe(before.virtualItems) + expect(after.virtualizer).toBe(before.virtualizer) +}) + +test('renders under StrictMode', () => { + render( + + + , + ) + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() +}) + +test('renders the initial range on the server via getServerSnapshot', () => { + function ServerList() { + const { virtualItems, totalSize } = useVirtualizerSnapshot< + HTMLDivElement, + HTMLDivElement + >({ + count: 200, + getScrollElement: () => null, + estimateSize: () => 50, + initialRect: { height: 200, width: 200 }, + }) + return ( +
+ {virtualItems.map((virtualRow) => ( +
Row {virtualRow.index}
+ ))} +
+ ) + } + + const html = renderToString() + + // renderToString separates adjacent text nodes with comment markers, so + // match the index text node itself rather than the joined string. + expect(html).toMatch(/>045 { + function WindowList() { + const { virtualItems } = useWindowVirtualizerSnapshot({ + count: 50, + estimateSize: () => 50, + }) + return ( +
+ {virtualItems.map((virtualRow) => ( +
Row {virtualRow.index}
+ ))} +
+ ) + } + + render() + + expect(screen.queryByText('Row 0')).toBeInTheDocument() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc2c15494..86c75791c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1696,7 +1696,13 @@ importers: '@tanstack/virtual-core': specifier: workspace:* version: link:../virtual-core + use-sync-external-store: + specifier: ^1.6.0 + version: 1.6.0(react@19.2.7) devDependencies: + '@babel/core': + specifier: ^7.26.0 + version: 7.29.7 '@testing-library/react': specifier: ^16.3.0 version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.16))(@types/react@19.2.16)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1706,6 +1712,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.16) + '@types/use-sync-external-store': + specifier: ^1.5.0 + version: 1.5.0 '@vitejs/plugin-react': specifier: ^4.5.2 version: 4.7.0(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)) @@ -4716,6 +4725,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@1.5.0': + resolution: {integrity: sha512-5dyB8nLC/qogMrlCizZnYWQTA4lnb/v+It+sqNl5YnSRAPMlIqY/X0Xn+gZw8vOL+TgTTr28VEbn3uf8fUtAkw==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -9815,8 +9827,8 @@ snapshots: '@babel/helper-module-imports@7.27.1': dependencies: - '@babel/traverse': 7.28.5(supports-color@10.2.2) - '@babel/types': 7.28.5 + '@babel/traverse': 7.29.7 + '@babel/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -9832,7 +9844,7 @@ snapshots: '@babel/core': 7.28.5(supports-color@10.2.2) '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -9841,7 +9853,7 @@ snapshots: '@babel/core': 7.28.5 '@babel/helper-module-imports': 7.27.1 '@babel/helper-validator-identifier': 7.28.5 - '@babel/traverse': 7.28.5(supports-color@10.2.2) + '@babel/traverse': 7.29.7 transitivePeerDependencies: - supports-color @@ -9913,8 +9925,8 @@ snapshots: '@babel/helpers@7.28.4': dependencies: - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 '@babel/helpers@7.29.7': dependencies: @@ -9923,7 +9935,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -9978,9 +9990,9 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 - '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.28.5)': + '@babel/plugin-syntax-jsx@7.27.1(@babel/core@7.29.7)': dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@babel/helper-plugin-utils': 7.29.7 '@babel/plugin-syntax-unicode-sets-regex@7.18.6(@babel/core@7.29.7)': @@ -10443,9 +10455,9 @@ snapshots: '@babel/template@7.27.2': dependencies: - '@babel/code-frame': 7.29.0 - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/code-frame': 7.29.7 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -10455,12 +10467,12 @@ snapshots: '@babel/traverse@7.28.5(supports-color@10.2.2)': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-globals': 7.28.0 - '@babel/parser': 7.28.5 - '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/template': 7.29.7 + '@babel/types': 7.29.7 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -12351,16 +12363,16 @@ snapshots: '@types/babel__generator@7.27.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/parser': 7.29.7 + '@babel/types': 7.29.7 '@types/babel__traverse@7.28.0': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/body-parser@1.19.6': dependencies: @@ -12549,6 +12561,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@1.5.0': {} + '@types/web-bluetooth@0.0.21': {} '@types/ws@7.4.7': @@ -13268,12 +13282,12 @@ snapshots: find-up: 5.0.0 webpack: 5.105.0(esbuild@0.28.1) - babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.28.5): + babel-plugin-jsx-dom-expressions@0.40.3(@babel/core@7.29.7): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.18.6 - '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) - '@babel/types': 7.28.5 + '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.29.7) + '@babel/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 @@ -13305,10 +13319,10 @@ snapshots: dependencies: '@babel/types': 7.28.5 - babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.10): + babel-preset-solid@1.9.10(@babel/core@7.29.7)(solid-js@1.9.10): dependencies: - '@babel/core': 7.28.5 - babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.28.5) + '@babel/core': 7.29.7 + babel-plugin-jsx-dom-expressions: 0.40.3(@babel/core@7.29.7) optionalDependencies: solid-js: 1.9.10 @@ -17051,9 +17065,9 @@ snapshots: vite-plugin-solid@2.11.10(@testing-library/jest-dom@6.9.1)(solid-js@1.9.10)(vite@6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)): dependencies: - '@babel/core': 7.28.5 + '@babel/core': 7.29.7 '@types/babel__core': 7.20.5 - babel-preset-solid: 1.9.10(@babel/core@7.28.5)(solid-js@1.9.10) + babel-preset-solid: 1.9.10(@babel/core@7.29.7)(solid-js@1.9.10) merge-anything: 5.1.7 solid-js: 1.9.10 solid-refresh: 0.6.3(solid-js@1.9.10) From 420a87187bb4e376377a6fde39112144d05cc91b Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sat, 25 Jul 2026 03:40:51 +0200 Subject: [PATCH 2/4] fix(react-virtual): type snapshot fields as readonly; align docs signatures Review follow-up: VirtualizerSnapshot fields are now readonly and virtualItems is a ReadonlyArray, making the immutability contract explicit at the type level (the array reference is shared with the core, matching getVirtualItems()). Docs signature blocks now show the exported generic constraints instead of the loose legacy style. --- docs/framework/react/react-virtual.md | 18 ++++++++++++------ packages/react-virtual/src/index.tsx | 11 ++++++----- 2 files changed, 18 insertions(+), 11 deletions(-) diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 0d317c3ac..4a1ffa909 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -36,7 +36,10 @@ This function returns a window-based `Virtualizer` instance configured to work w ## `useVirtualizerSnapshot` ```tsx -function useVirtualizerSnapshot( +function useVirtualizerSnapshot< + TScrollElement extends Element, + TItemElement extends Element, +>( options: PartialKeys< ReactVirtualizerSnapshotOptions, 'observeElementRect' | 'observeElementOffset' | 'scrollToFn' @@ -48,10 +51,13 @@ Like `useVirtualizer`, but returns the render-facing values as immutable snapshot data instead of methods to call during render: ```tsx -interface VirtualizerSnapshot { - virtualItems: Array - totalSize: number - virtualizer: Virtualizer +interface VirtualizerSnapshot< + TScrollElement extends Element | Window, + TItemElement extends Element, +> { + readonly virtualItems: ReadonlyArray + readonly totalSize: number + readonly virtualizer: Virtualizer } ``` @@ -114,7 +120,7 @@ the snapshot hooks make React rendering itself compiler-safe. ## `useWindowVirtualizerSnapshot` ```tsx -function useWindowVirtualizerSnapshot( +function useWindowVirtualizerSnapshot( options: PartialKeys< ReactVirtualizerSnapshotOptions, | 'getScrollElement' diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index 6f1fef6ae..4b5d3a0b7 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -298,14 +298,15 @@ export interface VirtualizerSnapshot< * The virtual items for the current visible range, as immutable snapshot * data. The array reference changes when — and only when — the computed * items change, so memoization keyed on it (manual or via React Compiler) - * stays correct. + * stays correct. Treat it as read-only: the reference is shared with the + * core (like `getVirtualItems()`), so mutating it corrupts later reads. */ - virtualItems: Array + readonly virtualItems: ReadonlyArray /** * Total size of the virtualized extent, captured in the same snapshot as * `virtualItems`. */ - totalSize: number + readonly totalSize: number /** * The underlying virtualizer instance, for imperative APIs only: * `measureElement` (as a ref), `scrollToIndex`, `scrollToOffset`, @@ -315,7 +316,7 @@ export interface VirtualizerSnapshot< * `range`, `scrollOffset`, …) from it during render — those reads are what * memoizing compilers cache stale (#736). Use the snapshot fields instead. */ - virtualizer: Virtualizer + readonly virtualizer: Virtualizer } function useVirtualizerSnapshotBase< @@ -331,7 +332,7 @@ function useVirtualizerSnapshotBase< const [store] = React.useState(() => { const listeners = new Set<() => void>() let snapshot: { - virtualItems: Array + virtualItems: ReadonlyArray totalSize: number } | null = null let dirty = true From e7fc53561d58a7eab20a28976d372362b86c331f Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sun, 26 Jul 2026 01:00:47 +0200 Subject: [PATCH 3/4] fix(react-virtual): refresh snapshot after option changes --- docs/framework/react/react-virtual.md | 2 +- packages/react-virtual/src/index.tsx | 9 +++-- .../react-virtual/tests/snapshot.test.tsx | 33 ++++++++++++++++++- 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 4a1ffa909..3318fce62 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -55,7 +55,7 @@ interface VirtualizerSnapshot< TScrollElement extends Element | Window, TItemElement extends Element, > { - readonly virtualItems: ReadonlyArray + readonly virtualItems: ReadonlyArray> readonly totalSize: number readonly virtualizer: Virtualizer } diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index 4b5d3a0b7..0fead05ef 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -301,7 +301,7 @@ export interface VirtualizerSnapshot< * stays correct. Treat it as read-only: the reference is shared with the * core (like `getVirtualItems()`), so mutating it corrupts later reads. */ - readonly virtualItems: ReadonlyArray + readonly virtualItems: ReadonlyArray> /** * Total size of the virtualized extent, captured in the same snapshot as * `virtualItems`. @@ -332,7 +332,7 @@ function useVirtualizerSnapshotBase< const [store] = React.useState(() => { const listeners = new Set<() => void>() let snapshot: { - virtualItems: ReadonlyArray + virtualItems: ReadonlyArray> totalSize: number } | null = null let dirty = true @@ -397,6 +397,11 @@ function useVirtualizerSnapshotBase< ) store.instance = instance instance.setOptions(resolvedOptions) + // setOptions can synchronously change render-facing geometry (count, gap, + // lanes, keys, enabled, …) without notifying when the visible range stays + // the same. Re-read the memoized core values during this render; the store + // keeps the public snapshot reference stable when neither value changed. + store.markDirty() useIsomorphicLayoutEffect(() => { return instance._didMount() diff --git a/packages/react-virtual/tests/snapshot.test.tsx b/packages/react-virtual/tests/snapshot.test.tsx index 29d31be51..2bcbbb061 100644 --- a/packages/react-virtual/tests/snapshot.test.tsx +++ b/packages/react-virtual/tests/snapshot.test.tsx @@ -24,6 +24,7 @@ beforeEach(() => { interface SnapshotListProps { count?: number + gap?: number height?: number width?: number label?: string @@ -31,6 +32,7 @@ interface SnapshotListProps { function SnapshotList({ count = 200, + gap, height = 200, width = 200, label = '', @@ -43,6 +45,7 @@ function SnapshotList({ count, getScrollElement: () => parentRef.current, estimateSize: () => 50, + gap, observeElementRect: (_, cb) => { cb({ height, width }) }, @@ -61,7 +64,10 @@ function SnapshotList({ style={{ height, width, overflow: 'auto' }} data-testid="scroller" > -
+
{virtualItems.map((virtualRow) => (
{ + const { rerender } = render() + const before = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '10000px' }) + + // Growing the count does not change the visible range, so virtual-core does + // not emit onChange. The hook must still refresh during the parent render. + rerender() + const afterCountChange = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '15000px' }) + expect(afterCountChange.virtualItems).not.toBe(before.virtualItems) + expect(afterCountChange.virtualizer).toBe(before.virtualizer) + + rerender() + const afterGapChange = captured.results[captured.results.length - 1]! + + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '17990px' }) + expect(screen.getByTestId('item-1')).toHaveStyle({ + transform: 'translateY(60px)', + }) + expect(afterGapChange.virtualItems).not.toBe(afterCountChange.virtualItems) +}) + test('publishes a new snapshot when the scroll offset changes the range', () => { render() const before = captured.results[captured.results.length - 1]! From 945c9de3a1440bdf4cf2a15ca580bfd224961625 Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Sun, 26 Jul 2026 10:14:58 +0200 Subject: [PATCH 4/4] test(react-virtual): cover window snapshot updates --- .../react-virtual/tests/snapshot.test.tsx | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/react-virtual/tests/snapshot.test.tsx b/packages/react-virtual/tests/snapshot.test.tsx index 2bcbbb061..a0999e080 100644 --- a/packages/react-virtual/tests/snapshot.test.tsx +++ b/packages/react-virtual/tests/snapshot.test.tsx @@ -137,6 +137,7 @@ test('publishes a new snapshot when render-time options change geometry', () => transform: 'translateY(60px)', }) expect(afterGapChange.virtualItems).not.toBe(afterCountChange.virtualItems) + expect(afterGapChange.virtualizer).toBe(afterCountChange.virtualizer) }) test('publishes a new snapshot when the scroll offset changes the range', () => { @@ -195,7 +196,14 @@ test('renders the initial range on the server via getServerSnapshot', () => { expect(html).not.toMatch(/>5 { +test('useWindowVirtualizerSnapshot updates items after scrolling', () => { + const originalScrollY = Object.getOwnPropertyDescriptor(window, 'scrollY') + let scrollY = 0 + Object.defineProperty(window, 'scrollY', { + configurable: true, + get: () => scrollY, + }) + function WindowList() { const { virtualItems } = useWindowVirtualizerSnapshot({ count: 50, @@ -210,7 +218,23 @@ test('useWindowVirtualizerSnapshot renders items', () => { ) } - render() + try { + render() - expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).toBeInTheDocument() + + act(() => { + scrollY = 1000 + window.dispatchEvent(new window.Event('scroll')) + }) + + expect(screen.queryByText('Row 20')).toBeInTheDocument() + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + } finally { + if (originalScrollY) { + Object.defineProperty(window, 'scrollY', originalScrollY) + } else { + Reflect.deleteProperty(window, 'scrollY') + } + } })