diff --git a/.changeset/use-virtualizer-state.md b/.changeset/use-virtualizer-state.md new file mode 100644 index 000000000..514bdfdf0 --- /dev/null +++ b/.changeset/use-virtualizer-state.md @@ -0,0 +1,10 @@ +--- +'@tanstack/virtual-core': minor +'@tanstack/react-virtual': minor +--- + +Add a store interface to the `Virtualizer` and a `useVirtualizerState` hook for React. + +- `virtualizer.subscribe(listener)` registers any number of change listeners, and `virtualizer.getState()` returns an immutable `{ virtualItems, totalSize, range, isScrolling, scrollDirection }` snapshot that keeps its identity until a field changes. +- `useVirtualizerState(virtualizer, selector?, isEqual?)` subscribes to that state through `useSyncExternalStore`. Values read through it stay live under the React Compiler, which can otherwise memoise `virtualizer.getVirtualItems()` on the stable instance. It works with both `useVirtualizer` and `useWindowVirtualizer`. +- While a listener is subscribed, an option change that moves the visible range (such as a new `count`) fires `onChange` when it is committed, rather than at the next scroll event. diff --git a/docs/api/virtualizer.md b/docs/api/virtualizer.md index fcbc4cf5a..c9305543d 100644 --- a/docs/api/virtualizer.md +++ b/docs/api/virtualizer.md @@ -546,6 +546,32 @@ Measures the element using your configured `measureElement` virtualizer option. By default the `measureElement` virtualizer option is configured to measure elements with `getBoundingClientRect()`. +### `subscribe` + +```tsx +subscribe: (listener: () => void) => () => void +``` + +Registers a listener that runs whenever [`getState`](#getstate) returns a new snapshot, and returns an unsubscribe function. That covers every change in the snapshot, including ones that do not fire [`onChange`](#onchange), such as a scroll direction flip within the same range, or a committed `count` change that alters the total size but not the visible range. Unlike `onChange`, any number of listeners can be registered. Listeners receive no `sync` flag; use `onChange` when an update must be flushed synchronously. Pair it with [`getState`](#getstate) to build a store subscription, such as React's `useSyncExternalStore`. + +### `getState` + +```tsx +getState: () => VirtualizerState + +interface VirtualizerState { + virtualItems: VirtualItem[] + totalSize: number + range: { startIndex: number; endIndex: number } | null + isScrolling: boolean + scrollDirection: 'forward' | 'backward' | null +} +``` + +Returns the render-relevant state of the virtualizer. The returned object keeps its identity until one of its fields changes, so it can be used directly as a store snapshot. + +Read it during render, or from a [`subscribe`](#subscribe) listener or [`onChange`](#onchange). Like `getVirtualItems`, it computes the current range and counts it as seen, so a range change that other code reads first, before the virtualizer has notified, does not fire `onChange`. + ### `resizeItem` ```tsx diff --git a/docs/config.json b/docs/config.json index 04b62a7dc..ec0fd2c6b 100644 --- a/docs/config.json +++ b/docs/config.json @@ -200,6 +200,10 @@ { "to": "framework/react/examples/window", "label": "Window" + }, + { + "to": "framework/react/examples/react-compiler", + "label": "React Compiler" } ] }, diff --git a/docs/framework/react/react-virtual.md b/docs/framework/react/react-virtual.md index 02d544e05..b828343a7 100644 --- a/docs/framework/react/react-virtual.md +++ b/docs/framework/react/react-virtual.md @@ -33,6 +33,67 @@ function useWindowVirtualizer( This function returns a window-based `Virtualizer` instance configured to work with the window as the scrollElement. +## `useVirtualizerState` + +```tsx +function useVirtualizerState( + virtualizer: Virtualizer, +): VirtualizerState + +function useVirtualizerState( + virtualizer: Virtualizer, + selector: (state: VirtualizerState) => TSelected, + isEqual?: (a: TSelected, b: TSelected) => boolean, +): TSelected +``` + +Subscribes to the virtualizer's render-relevant state ([`VirtualizerState`](../../api/virtualizer.md#getstate)) through `useSyncExternalStore`. Works with both `useVirtualizer` and `useWindowVirtualizer`. + +Use it for values you read during render (`virtualItems`, `totalSize`, `isScrolling`, …), and keep using the `Virtualizer` instance for imperative calls such as `scrollToIndex`, `measure` or `resizeItem`: + +```tsx +const virtualizer = useVirtualizer({ + count: 10000, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, +}) +const { virtualItems, totalSize } = useVirtualizerState(virtualizer) + +return ( +
+
+ {virtualItems.map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+) +``` + +Without a selector, the component re-renders whenever any field of the state changes. That includes `isScrolling` and `scrollDirection`, which update while scrolling even when the visible rows stay the same. + +Pass a `selector` to re-render only when the selected value changes, such as `virtualItems` for a component that only renders rows. When the selector returns a new object each time, also pass `isEqual`: + +```tsx +const isScrolling = useVirtualizerState(virtualizer, (s) => s.isScrolling) +``` + +### React Compiler + +The `Virtualizer` instance is stable across renders, so the [React Compiler](https://react.dev/learn/react-compiler) can memoise reads such as `virtualizer.getVirtualItems()` on it and render stale items. The compiler skips components that call `useVirtualizer` itself, but it still compiles components the virtualizer is passed to, and components that call `useWindowVirtualizer`. Read render values through `useVirtualizerState` there. + ## React-Specific Options ### `useFlushSync` diff --git a/examples/react/react-compiler/.gitignore b/examples/react/react-compiler/.gitignore new file mode 100644 index 000000000..d451ff16c --- /dev/null +++ b/examples/react/react-compiler/.gitignore @@ -0,0 +1,5 @@ +node_modules +.DS_Store +dist +dist-ssr +*.local diff --git a/examples/react/react-compiler/README.md b/examples/react/react-compiler/README.md new file mode 100644 index 000000000..3ac3f1a9b --- /dev/null +++ b/examples/react/react-compiler/README.md @@ -0,0 +1,6 @@ +# Example + +To run this example: + +- `npm install` or `yarn` +- `npm run dev` or `yarn dev` diff --git a/examples/react/react-compiler/index.html b/examples/react/react-compiler/index.html new file mode 100644 index 000000000..84b7eb685 --- /dev/null +++ b/examples/react/react-compiler/index.html @@ -0,0 +1,11 @@ + + + + + + + +
+ + + diff --git a/examples/react/react-compiler/package.json b/examples/react/react-compiler/package.json new file mode 100644 index 000000000..497475ddd --- /dev/null +++ b/examples/react/react-compiler/package.json @@ -0,0 +1,25 @@ +{ + "name": "tanstack-react-virtual-example-react-compiler", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc && vite build", + "serve": "vite preview" + }, + "dependencies": { + "@faker-js/faker": "^8.4.1", + "@tanstack/react-virtual": "^3.14.13", + "react": "^19.2.7", + "react-dom": "^19.2.7" + }, + "devDependencies": { + "@types/node": "^24.5.2", + "@types/react": "^19.2.16", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.5.2", + "babel-plugin-react-compiler": "^1.0.0", + "typescript": "5.9.3", + "vite": "^6.4.2" + } +} diff --git a/examples/react/react-compiler/src/index.css b/examples/react/react-compiler/src/index.css new file mode 100644 index 000000000..a9fb5eef7 --- /dev/null +++ b/examples/react/react-compiler/src/index.css @@ -0,0 +1,35 @@ +*, +*:before, +*:after { + box-sizing: border-box; +} + +html { + font-family: sans-serif; + font-size: 14px; +} + +body { + padding: 1rem; +} + +.List { + border: 1px solid #e6e4dc; + max-width: 100%; +} + +.ListItemEven { + background-color: #e6e4dc; +} + +.Toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.Status { + color: #555; + font-variant-numeric: tabular-nums; +} diff --git a/examples/react/react-compiler/src/main.tsx b/examples/react/react-compiler/src/main.tsx new file mode 100644 index 000000000..489eb62c7 --- /dev/null +++ b/examples/react/react-compiler/src/main.tsx @@ -0,0 +1,158 @@ +import * as React from 'react' +import { createRoot } from 'react-dom/client' +import { faker } from '@faker-js/faker' + +import { useVirtualizer, useVirtualizerState } from '@tanstack/react-virtual' +import type { ReactVirtualizer, VirtualItem } from '@tanstack/react-virtual' + +import './index.css' + +type Row = { id: string; text: string } + +let nextId = 0 +const createRows = (count: number): Array => + Array.from({ length: count }, () => ({ + id: String(nextId++), + text: faker.lorem.sentence(faker.number.int({ min: 5, max: 60 })), + })) + +type ListVirtualizer = ReactVirtualizer + +// This example is built with the React Compiler (see vite.config.js). +// +// The `Virtualizer` instance is stable across renders, so the compiler can +// memoise reads like `virtualizer.getVirtualItems()` on it and render stale +// rows. Values used during render are read through `useVirtualizerState` +// instead, and the instance is kept for imperative calls (`scrollToIndex`, +// `measure`, ...). +function App() { + const parentRef = React.useRef(null) + const [rows, setRows] = React.useState(() => createRows(10000)) + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 45, + // Stable keys keep each row's measured size when rows are prepended or + // reordered. + getItemKey: React.useCallback((index: number) => rows[index]!.id, [rows]), + // Row positions and the list height are written straight to the DOM, so + // scrolling and re-measuring rows do not re-render React. + directDomUpdates: true, + }) + + return ( +
+
+ + + + + +
+

+ +

+
+ +
+
+ ) +} + +// With `directDomUpdates` the virtualizer positions the rows itself, so this +// component only cares about which rows are visible — not where they are. +// Comparing keys and indexes skips re-renders when rows are only re-measured. +const sameRows = (a: Array, b: Array) => + a.length === b.length && + a.every((item, i) => item.key === b[i]!.key && item.index === b[i]!.index) + +function Rows({ + virtualizer, + rows, +}: { + virtualizer: ListVirtualizer + rows: Array +}) { + const virtualItems = useVirtualizerState( + virtualizer, + (state) => state.virtualItems, + sameRows, + ) + + return ( + // The virtualizer sets this container's height through `containerRef`. +
+ {virtualItems.map((item) => ( +
+
+
+ Row {item.index} (id {rows[item.index]?.id}) +
+
{rows[item.index]?.text}
+
+
+ ))} +
+ ) +} + +// Selectors re-render only when the selected value changes: this component +// ignores size changes and only follows the visible range and scrolling. +function ScrollStatus({ virtualizer }: { virtualizer: ListVirtualizer }) { + const range = useVirtualizerState(virtualizer, (state) => state.range) + const isScrolling = useVirtualizerState( + virtualizer, + (state) => state.isScrolling, + ) + + return ( + <> + Visible rows {range ? `${range.startIndex}–${range.endIndex}` : '–'} + {isScrolling ? ' · scrolling…' : ''} + + ) +} + +const container = document.getElementById('root')! +const root = createRoot(container) +root.render( + + + , +) diff --git a/examples/react/react-compiler/tsconfig.json b/examples/react/react-compiler/tsconfig.json new file mode 100644 index 000000000..87318025a --- /dev/null +++ b/examples/react/react-compiler/tsconfig.json @@ -0,0 +1,25 @@ +{ + "composite": true, + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/examples/react/react-compiler/vite.config.js b/examples/react/react-compiler/vite.config.js new file mode 100644 index 000000000..da6d35ead --- /dev/null +++ b/examples/react/react-compiler/vite.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react({ + babel: { + plugins: [['babel-plugin-react-compiler', { target: '19' }]], + }, + }), + ], +}) diff --git a/packages/react-virtual/e2e/app/react-compiler/main.tsx b/packages/react-virtual/e2e/app/react-compiler/main.tsx index 3c8f7d3bc..558acc394 100644 --- a/packages/react-virtual/e2e/app/react-compiler/main.tsx +++ b/packages/react-virtual/e2e/app/react-compiler/main.tsx @@ -1,6 +1,6 @@ import React from 'react' import ReactDOM from 'react-dom/client' -import { useVirtualizer } from '@tanstack/react-virtual' +import { useVirtualizer, useVirtualizerState } from '@tanstack/react-virtual' const ITEM_SIZE = 40 const COUNT = 1000 @@ -86,4 +86,108 @@ const App = () => { ) } -ReactDOM.createRoot(document.getElementById('root')!).render() +/** + * React Compiler skips any component that calls `useVirtualizer` (it is on the + * compiler's list of known-incompatible APIs), but it does compile components + * the virtualizer is passed to. `Rows` is such a component: reading + * `virtualizer.getVirtualItems()` there is memoised on the stable instance + * and goes stale (`?api=instance`), while `useVirtualizerState` stays live + * (`?api=state`). + */ +type RowsProps = { + virtualizer: ReturnType> +} + +const StateRows = ({ virtualizer }: RowsProps) => { + const { virtualItems, totalSize } = useVirtualizerState(virtualizer) + return ( + + ) +} + +const InstanceRows = ({ virtualizer }: RowsProps) => { + const virtualItems = virtualizer.getVirtualItems() + const totalSize = virtualizer.getTotalSize() + return ( + + ) +} + +const RowList = ({ + virtualizer, + items, + totalSize, +}: RowsProps & { + items: ReturnType + totalSize: number +}) => ( +
+ {items.map((v) => ( +
+ Row {v.index} +
+ ))} +
+) + +const StateApp = ({ api }: { api: 'state' | 'instance' }) => { + const parentRef = React.useRef(null) + + const rowVirtualizer = useVirtualizer({ + count: COUNT, + getScrollElement: () => parentRef.current, + estimateSize: () => ITEM_SIZE, + overscan: 2, + }) + + const Rows = api === 'state' ? StateRows : InstanceRows + + return ( +
+ + +
+ +
+
+ ) +} + +const api = new URLSearchParams(window.location.search).get('api') + +ReactDOM.createRoot(document.getElementById('root')!).render( + api === 'state' || api === 'instance' ? : , +) diff --git a/packages/react-virtual/e2e/app/test/react-compiler.spec.ts b/packages/react-virtual/e2e/app/test/react-compiler.spec.ts index 4d861174c..cd11cec6b 100644 --- a/packages/react-virtual/e2e/app/test/react-compiler.spec.ts +++ b/packages/react-virtual/e2e/app/test/react-compiler.spec.ts @@ -82,3 +82,62 @@ for (const mode of ['position', 'transform'] as const) { }) }) } + +// `useVirtualizer` itself is skipped by the compiler, but a component the +// virtualizer is passed to is compiled. These cover reads made there. +test.describe('react-compiler useVirtualizerState', () => { + test('a compiled child reading useVirtualizerState follows scrolling', async ({ + page, + }) => { + await page.goto('/react-compiler/?api=state') + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + await expect(page.locator('#inner')).toHaveAttribute( + 'style', + new RegExp(`height:\\s*${1000 * ITEM_SIZE}px`), + ) + + await page.click('#scroll-to-500') + + await expect(page.locator('[data-testid="item-500"]')).toBeVisible({ + timeout: 5000, + }) + await expect(page.locator('[data-testid="item-500"]')).toHaveAttribute( + 'style', + /translateY\(20000px\)/, + ) + }) + + test('a compiled child reading useVirtualizerState renders newly visible items', async ({ + page, + }) => { + await page.goto('/react-compiler/?api=state') + + await expect(page.locator('[data-testid="item-0"]')).toBeVisible() + + await page.locator('#scroll-container').evaluate((el, by) => { + el.scrollTop = by + }, ITEM_SIZE * 20) + + await expect(page.locator('[data-testid="item-25"]')).toBeVisible() + await expect(page.locator('[data-testid="item-0"]')).toHaveCount(0) + }) + + // Control: the same child reading the instance is memoised on the stable + // virtualizer, so it keeps its first render — taken before the scroll + // element was attached, with no items — which is what the state hook fixes. + test('a compiled child reading the instance goes stale', async ({ page }) => { + await page.goto('/react-compiler/?api=instance') + + await expect(page.locator('#scroll-container')).toBeVisible() + await page.waitForTimeout(300) + await expect(page.locator('[data-testid="item-0"]')).toHaveCount(0) + + await page.locator('#scroll-container').evaluate((el, by) => { + el.scrollTop = by + }, ITEM_SIZE * 20) + + await page.waitForTimeout(300) + await expect(page.locator('[data-testid="item-25"]')).toHaveCount(0) + }) +}) diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index b4c5246e8..03c33dca2 100644 --- a/packages/react-virtual/package.json +++ b/packages/react-virtual/package.json @@ -55,12 +55,14 @@ "src" ], "dependencies": { - "@tanstack/virtual-core": "workspace:*" + "@tanstack/virtual-core": "workspace:*", + "use-sync-external-store": "^1.6.0" }, "devDependencies": { "@testing-library/react": "^16.3.0", "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", + "@types/use-sync-external-store": "^1.7.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 0cd07a7df..49bc1d5d2 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 { useSyncExternalStoreWithSelector } from 'use-sync-external-store/shim/with-selector' import { Virtualizer, elementScroll, @@ -9,7 +10,12 @@ import { observeWindowRect, windowScroll, } from '@tanstack/virtual-core' -import type { PartialKeys, VirtualizerOptions } from '@tanstack/virtual-core' +import type { + PartialKeys, + VirtualItem, + VirtualizerOptions, + VirtualizerState, +} from '@tanstack/virtual-core' export * from '@tanstack/virtual-core' @@ -94,18 +100,18 @@ function useVirtualizerBase< // node — e.g. when `enabled` is toggled off then on) is treated as fresh // and gets its style written. lastPositions: new WeakMap(), - prevRange: null as { - startIndex: number - endIndex: number - isScrolling: boolean - } | null, + // The `range` / `isScrolling` last rendered. `undefined` until the first + // notify so that one always renders. + renderedRange: undefined as VirtualizerState['range'] | undefined, + renderedIsScrolling: false, }) directRef.current.enabled = directDomUpdates directRef.current.mode = directDomUpdatesMode - // Set while the virtualizer measures an item through `measureElement`, which is - // passed as a ref and therefore runs while React is committing. - const measuringFromRef = React.useRef(false) + // Set while React is committing and the virtualizer may notify: inside + // `measureElement`, which is passed as a ref, and inside `_willUpdate`, + // which runs in a layout effect. `flushSync` cannot flush in that window. + const committingRef = React.useRef(false) // Writes the size container's total extent to the DOM. Idempotent — guarded // by lastSize. Split out from applyDirectStyles so it can run *before* the @@ -129,6 +135,27 @@ function useVirtualizerBase< } } + // Writes one item's main-axis position to its element. Idempotent — guarded + // by lastPositions. + const applyItemPosition = ( + instance: Virtualizer, + item: VirtualItem, + el: HTMLElement, + ) => { + const state = directRef.current + const horizontal = !!instance.options.horizontal + const next = item.start - instance.options.scrollMargin + if (state.lastPositions.get(el) === next) return + state.lastPositions.set(el, next) + if (state.mode === 'transform') { + el.style.transform = horizontal + ? `translate3d(${next}px, 0, 0)` + : `translate3d(0, ${next}px, 0)` + } else { + el.style[horizontal ? 'left' : 'top'] = `${next}px` + } + } + // Writes container size + item positions to the DOM. Idempotent — guarded // by lastSize / lastPositions. Called from onChange (covers scroll-driven // updates) and from a layout effect (covers post-render commits when refs @@ -141,24 +168,9 @@ function useVirtualizerBase< applyContainerSize(instance) - const horizontal = !!instance.options.horizontal - const useTransform = state.mode === 'transform' - const posAxis = horizontal ? 'left' : 'top' - const scrollMargin = instance.options.scrollMargin - const items = instance.getVirtualItems() - for (const item of items) { - const next = item.start - scrollMargin + for (const item of instance.getVirtualItems()) { const el = instance.elementsCache.get(item.key) as HTMLElement | undefined - if (!el) continue - if (state.lastPositions.get(el) === next) continue - state.lastPositions.set(el, next) - if (useTransform) { - el.style.transform = horizontal - ? `translate3d(${next}px, 0, 0)` - : `translate3d(0, ${next}px, 0)` - } else { - el.style[posAxis] = `${next}px` - } + if (el) applyItemPosition(instance, item, el) } } @@ -171,35 +183,30 @@ function useVirtualizerBase< if (state.enabled) { applyDirectStyles(instance) - // Only re-render on range / isScrolling changes - const range = instance.range - const prev = state.prevRange + // Only re-render on range / isScrolling changes. The snapshot keeps + // `range` referentially stable while its indexes are unchanged. + const { range, isScrolling } = instance.getState() shouldRerender = - !prev || - prev.isScrolling !== instance.isScrolling || - prev.startIndex !== range?.startIndex || - prev.endIndex !== range?.endIndex + range !== state.renderedRange || + isScrolling !== state.renderedIsScrolling if (shouldRerender) { - state.prevRange = range - ? { - startIndex: range.startIndex, - endIndex: range.endIndex, - isScrolling: instance.isScrolling, - } - : null + state.renderedRange = range + state.renderedIsScrolling = isScrolling } } if (shouldRerender) { - // A sync notify raised from `measureElement` reaches us while React is - // committing, because `measureElement` is a ref callback. `flushSync` - // cannot flush there: React still runs the callback at sync priority, but - // it skips the flush and warns in development. The commit phase already - // runs at discrete (sync) priority, so leaving `flushSync` out for that - // window keeps the same lane and the same flush point — without the - // warning. Every other sync notify (ResizeObserver re-measures, scroll - // adjustments) still flushes synchronously. - if (useFlushSync && sync && !measuringFromRef.current) { + // A sync notify can reach us while React is committing: from + // `measureElement`, a ref callback, or from `_willUpdate`, a layout + // effect, when it publishes an option change that moved the range + // mid-scroll. `flushSync` cannot flush there: React still runs the + // callback at sync priority, but it skips the flush and warns in + // development. The commit phase already runs at discrete (sync) + // priority, so leaving `flushSync` out for that window keeps the same + // lane and the same flush point — without the warning. Every other + // sync notify (ResizeObserver re-measures, scroll adjustments) still + // flushes synchronously. + if (useFlushSync && sync && !committingRef.current) { flushSync(rerender) } else { rerender() @@ -214,11 +221,20 @@ function useVirtualizerBase< const v = new Virtualizer(resolvedOptions) const measureElement = v.measureElement v.measureElement = (node: TItemElement | null) => { - measuringFromRef.current = true + committingRef.current = true try { measureElement(node) } finally { - measuringFromRef.current = false + committingRef.current = false + } + // A row can mount in a commit that does not include this component — a + // memoised child re-rendered through `useVirtualizerState` — which the + // `applyDirectStyles` effect below never sees. Position it as it + // registers; a row the effect does cover is skipped by `lastPositions`. + const state = directRef.current + if (node !== null && state.enabled && state.container) { + const item = v.measurementsCache[v.indexFromElement(node)] + if (item) applyItemPosition(v, item, node as unknown as HTMLElement) } } return Object.assign(v, { @@ -226,11 +242,11 @@ function useVirtualizerBase< const state = directRef.current state.container = node state.lastSize = null - if (node && state.enabled) { - const total = v.getTotalSize() - state.lastSize = total - const axis = v.options.horizontal ? 'width' : 'height' - node.style[axis] = `${total}px` + // Sizes the container, and positions the rows that mounted with it: + // React attaches children's refs before their parent's, so they have + // already registered but found no container to be positioned in. + if (node !== null) { + applyDirectStyles(v) } }, }) @@ -250,7 +266,12 @@ function useVirtualizerBase< // top until the next scroll. Positions are written afterwards by the // applyDirectStyles effect below. applyContainerSize(instance) - return instance._willUpdate() + committingRef.current = true + try { + return instance._willUpdate() + } finally { + committingRef.current = false + } }) // After every render commit, newly mounted item refs have registered in @@ -298,3 +319,48 @@ export function useWindowVirtualizer( ...options, }) } + +const selectState = (state: VirtualizerState) => state + +/** + * Subscribes to a virtualizer's render-relevant state (`virtualItems`, + * `totalSize`, `range`, `isScrolling`, `scrollDirection`) through + * `useSyncExternalStore`. Values read here are safe under the React Compiler, + * unlike calling `virtualizer.getVirtualItems()` during render — the instance + * is stable, so the compiler may memoise such calls. + * + * Pass a `selector` to re-render only when the selected value changes, and + * `isEqual` when the selector builds a new object each time. + */ +export function useVirtualizerState< + TScrollElement extends Element | Window, + TItemElement extends Element, +>(virtualizer: Virtualizer): VirtualizerState +export function useVirtualizerState< + TScrollElement extends Element | Window, + TItemElement extends Element, + TSelected, +>( + virtualizer: Virtualizer, + selector: (state: VirtualizerState) => TSelected, + isEqual?: (a: TSelected, b: TSelected) => boolean, +): TSelected +export function useVirtualizerState< + TScrollElement extends Element | Window, + TItemElement extends Element, + TSelected, +>( + virtualizer: Virtualizer, + selector: (state: VirtualizerState) => TSelected = selectState as ( + state: VirtualizerState, + ) => TSelected, + isEqual?: (a: TSelected, b: TSelected) => boolean, +): TSelected { + return useSyncExternalStoreWithSelector( + virtualizer.subscribe, + virtualizer.getState, + virtualizer.getState, + selector, + isEqual, + ) +} diff --git a/packages/react-virtual/tests/state.test.tsx b/packages/react-virtual/tests/state.test.tsx new file mode 100644 index 000000000..3ce579061 --- /dev/null +++ b/packages/react-virtual/tests/state.test.tsx @@ -0,0 +1,428 @@ +import { test, expect, vi } from 'vitest' +import * as React from 'react' +import { act, render, screen } from '@testing-library/react' + +import { + useVirtualizer, + useVirtualizerState, + useWindowVirtualizer, +} from '../src/index' +import type { ReactVirtualizer, ReactVirtualizerOptions } from '../src/index' + +type TestVirtualizer = ReactVirtualizer +type OffsetListener = (offset: number, isScrolling: boolean) => void + +// jsdom fires no scroll events. The driver stands in for `observeElementOffset` +// and lets a test emit them: `scrollTo(offset)` while scrolling, and +// `scrollTo(offset, false)` for the settled event that ends `isScrolling`. +function createScrollDriver() { + let listener: OffsetListener = () => {} + return { + observeElementOffset: (_: unknown, cb: OffsetListener) => { + listener = cb + cb(0, false) + }, + scrollTo: (offset: number, isScrolling = true) => + listener(offset, isScrolling), + } +} + +// 50px rows in a 200px viewport without overscan: rows 0–3 are visible. +function useTestVirtualizer( + count: number, + options: Partial> = {}, +) { + const parentRef = React.useRef(null) + const virtualizer = useVirtualizer({ + count, + getScrollElement: () => parentRef.current, + estimateSize: () => 50, + overscan: 0, + // jsdom has no layout, so a real measurement would collapse every row. + measureElement: () => 50, + observeElementRect: (_, cb) => { + cb({ height: 200, width: 200 }) + }, + ...options, + }) + return { parentRef, virtualizer } +} + +function StateList({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count) + const { virtualItems, totalSize } = useVirtualizerState(virtualizer) + + return ( +
+
+ {virtualItems.map((item) => ( +
+ Row {item.index} +
+ ))} +
+
+ ) +} + +// Rows the virtualizer positions itself (`directDomUpdates`), read through the +// state hook. Memoised, so a parent re-render alone does not reach them: they +// mount in their own commit, after the parent's layout effects have run. With +// `empty` set, the container only mounts once there are rows. +const DirectRows = React.memo(function DirectRows({ + virtualizer, + empty, +}: { + virtualizer: TestVirtualizer + empty?: string +}) { + const virtualItems = useVirtualizerState(virtualizer, (s) => s.virtualItems) + if (virtualItems.length === 0 && empty !== undefined) { + return

{empty}

+ } + return ( +
+ {virtualItems.map((item) => ( +
+ ))} +
+ ) +}) + +const transformOf = (testId: string) => + screen.getByTestId(testId).style.transform + +test('useVirtualizerState renders the visible items and total size', () => { + render() + + expect(screen.getByText('Row 0')).toBeInTheDocument() + expect(screen.getByText('Row 3')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).not.toBeInTheDocument() + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '5000px' }) +}) + +test('useVirtualizerState reflects a count change in the same render', () => { + const { rerender } = render() + + rerender() + + expect(screen.getByText('Row 1')).toBeInTheDocument() + expect(screen.queryByText('Row 2')).not.toBeInTheDocument() + expect(screen.getByTestId('sizer')).toHaveStyle({ height: '100px' }) +}) + +test('useVirtualizerState with a selector re-renders only when the selection changes', () => { + const renders = vi.fn() + let instance: TestVirtualizer | null = null + + function TotalSize({ virtualizer }: { virtualizer: TestVirtualizer }) { + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + renders(totalSize) + return
{totalSize}
+ } + + const Memoized = React.memo(TotalSize) + + function App() { + const { parentRef, virtualizer } = useTestVirtualizer(100) + instance = virtualizer + return ( +
+ +
+ ) + } + + render() + expect(screen.getByTestId('total')).toHaveTextContent('5000') + const rendersBefore = renders.mock.calls.length + + // A notify that leaves totalSize untouched must not re-render the child. + act(() => instance!.measure()) + expect(renders.mock.calls.length).toBe(rendersBefore) + + act(() => instance!.resizeItem(0, 150)) + expect(screen.getByTestId('total')).toHaveTextContent('5100') +}) + +test('useVirtualizerState updates a memoised child when the parent changes count', () => { + function TotalSize({ virtualizer }: { virtualizer: TestVirtualizer }) { + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + return
{totalSize}
+ } + + const Memoized = React.memo(TotalSize) + + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count) + return ( +
+ +
+ ) + } + + const { rerender } = render() + expect(screen.getByTestId('total')).toHaveTextContent('5000') + + rerender() + expect(screen.getByTestId('total')).toHaveTextContent('100') +}) + +test('useVirtualizerState updates a memoised child on scroll', () => { + const scroll = createScrollDriver() + + function Rows({ virtualizer }: { virtualizer: TestVirtualizer }) { + const { virtualItems } = useVirtualizerState(virtualizer) + return ( +
+ {virtualItems.map((item) => ( +
+ ))} +
+ ) + } + + const MemoRows = React.memo(Rows) + + function App() { + const { parentRef, virtualizer } = useTestVirtualizer(100, { + observeElementOffset: scroll.observeElementOffset, + }) + return ( +
+ +
+ ) + } + + render() + expect(screen.getByTestId('row-0')).toBeInTheDocument() + + act(() => scroll.scrollTo(500)) + + expect(screen.getByTestId('row-10')).toBeInTheDocument() + expect(screen.getByTestId('row-13')).toBeInTheDocument() + expect(screen.queryByTestId('row-0')).not.toBeInTheDocument() +}) + +test('useVirtualizerState follows isScrolling through a selector', () => { + const scroll = createScrollDriver() + + function Status({ virtualizer }: { virtualizer: TestVirtualizer }) { + const isScrolling = useVirtualizerState(virtualizer, (s) => s.isScrolling) + return
{String(isScrolling)}
+ } + + const MemoStatus = React.memo(Status) + + function App() { + const { parentRef, virtualizer } = useTestVirtualizer(100, { + observeElementOffset: scroll.observeElementOffset, + }) + return ( +
+ +
+ ) + } + + render() + expect(screen.getByTestId('scrolling')).toHaveTextContent('false') + + act(() => scroll.scrollTo(10)) + expect(screen.getByTestId('scrolling')).toHaveTextContent('true') + + act(() => scroll.scrollTo(10, false)) + expect(screen.getByTestId('scrolling')).toHaveTextContent('false') +}) + +test('a count change still fires onChange while a subscriber is attached', () => { + const onChange = vi.fn() + + function TotalSize({ virtualizer }: { virtualizer: TestVirtualizer }) { + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + return
{totalSize}
+ } + + const Memoized = React.memo(TotalSize) + + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count, { onChange }) + return ( +
+ +
+ ) + } + + const { rerender } = render() + onChange.mockClear() + + // Nothing reads the new range during render, so the publish from + // `_willUpdate` has to go through `onChange` — exactly once. + rerender() + + expect(screen.getByTestId('total')).toHaveTextContent('100') + expect(onChange).toHaveBeenCalledTimes(1) +}) + +test('useVirtualizerState works with useWindowVirtualizer', () => { + const scroll = createScrollDriver() + + function App() { + const virtualizer = useWindowVirtualizer({ + count: 100, + estimateSize: () => 50, + overscan: 0, + measureElement: () => 50, + observeElementRect: (_, cb) => { + cb({ height: 200, width: 200 }) + }, + observeElementOffset: scroll.observeElementOffset, + // jsdom does not implement `window.scrollTo`. + scrollToFn: () => {}, + }) + const { virtualItems } = useVirtualizerState(virtualizer) + return ( +
+ {virtualItems.map((item) => ( +
+ ))} +
+ ) + } + + render() + expect(screen.getByTestId('row-0')).toBeInTheDocument() + + act(() => scroll.scrollTo(500)) + + expect(screen.getByTestId('row-10')).toBeInTheDocument() + expect(screen.queryByTestId('row-0')).not.toBeInTheDocument() +}) + +test('directDomUpdates positions rows a memoised child mounts after a count change', () => { + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count, { + directDomUpdates: true, + }) + return ( +
+ +
+ ) + } + + const { rerender } = render() + expect(screen.queryByTestId('row-2')).not.toBeInTheDocument() + + rerender() + + expect(transformOf('row-2')).toBe('translate3d(0, 100px, 0)') + expect(transformOf('row-3')).toBe('translate3d(0, 150px, 0)') +}) + +test('directDomUpdates positions rows a memoised child mounts when the owner read the state', () => { + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count, { + directDomUpdates: true, + }) + // Reading the state here marks the new range as seen, so `_willUpdate` + // reaches the child without an `onChange` and the owner does not render + // again: its layout effect has run before the child's rows mount. + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + return ( +
+
{totalSize}
+ +
+ ) + } + + const { rerender } = render() + rerender() + + expect(screen.getByTestId('total')).toHaveTextContent('5000') + expect(transformOf('row-2')).toBe('translate3d(0, 100px, 0)') + expect(transformOf('row-3')).toBe('translate3d(0, 150px, 0)') +}) + +test('directDomUpdates positions rows a memoised child mounts together with the container', () => { + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count, { + directDomUpdates: true, + }) + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + return ( +
+
{totalSize}
+ +
+ ) + } + + const { rerender } = render() + expect(screen.getByText('No rows')).toBeInTheDocument() + + // The rows' refs run before the container's, so `containerRef` has to + // position the rows that registered ahead of it. + rerender() + + expect(screen.getByTestId('total')).toHaveTextContent('5000') + expect(screen.getByTestId('row-1').parentElement).toHaveStyle({ + height: '5000px', + }) + expect(transformOf('row-1')).toBe('translate3d(0, 50px, 0)') + expect(transformOf('row-3')).toBe('translate3d(0, 150px, 0)') +}) + +test('an option change committed mid-scroll notifies a subscriber without flushSync', () => { + const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) + const scroll = createScrollDriver() + const onChange = vi.fn() + + function TotalSize({ virtualizer }: { virtualizer: TestVirtualizer }) { + const totalSize = useVirtualizerState(virtualizer, (s) => s.totalSize) + return
{totalSize}
+ } + + const Memoized = React.memo(TotalSize) + + function App({ count }: { count: number }) { + const { parentRef, virtualizer } = useTestVirtualizer(count, { + observeElementOffset: scroll.observeElementOffset, + onChange, + }) + return ( +
+ +
+ ) + } + + const { rerender } = render() + act(() => scroll.scrollTo(10)) + onChange.mockClear() + errors.mockClear() + + // `isScrolling` is true, so the range change `_willUpdate` publishes is a + // sync notify raised inside a layout effect, where React cannot flushSync. + rerender() + + expect(screen.getByTestId('total')).toHaveTextContent('100') + expect(onChange).toHaveBeenCalledWith(expect.anything(), true) + expect(errors).not.toHaveBeenCalled() + errors.mockRestore() +}) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index 38b7e426c..a162c80e9 100644 --- a/packages/virtual-core/src/index.ts +++ b/packages/virtual-core/src/index.ts @@ -69,6 +69,20 @@ export interface VirtualItem { lane: number } +/** + * The render-relevant state of a `Virtualizer`, as returned by + * `getState()`. The object is immutable and keeps its identity until one of + * its fields changes, so it can back a `useSyncExternalStore`-style + * subscription (see `subscribe`). + */ +export interface VirtualizerState { + virtualItems: Array + totalSize: number + range: { startIndex: number; endIndex: number } | null + isScrolling: boolean + scrollDirection: ScrollDirection | null +} + export interface Rect { width: number height: number @@ -501,6 +515,11 @@ export class Virtualizer< instance: Virtualizer, ) => boolean) elementsCache = new Map() + private listeners = new Set<() => void>() + private state: VirtualizerState | null = null + // The snapshot listeners were last told about. Listeners run only when + // `getState()` moves away from it. + private publishedState: VirtualizerState | null = null private now = () => this.targetWindow?.performance?.now?.() ?? Date.now() private observer = (() => { let _ro: ResizeObserver | null = null @@ -736,6 +755,91 @@ export class Virtualizer< private notify = (sync: boolean) => { this.options.onChange?.(this, sync) + this.emitState() + } + + // Calls listeners when `getState()` has moved since they were last called. + private emitState = () => { + if (this.listeners.size === 0) return + const state = this.getState() + if (state === this.publishedState) return + this.publishedState = state + this.listeners.forEach((listener) => listener()) + } + + // Publishes snapshot changes that happen without a `notify`: a scroll event + // that only flips `scrollDirection`, and `_willUpdate`, which picks up + // options set during render (e.g. a new `count`) once they are committed. + private publishState = () => { + if (this.listeners.size === 0) return + // Reading the snapshot marks the current range as seen for `maybeNotify` + // (via `getVirtualIndexes`). Outside render, nothing else may have read it + // yet, so let a range change go through `notify` (and `onChange`) first; + // otherwise the read in `emitState` would swallow it. When that notifies, + // it has already emitted, and the call below is a no-op. + this.maybeNotify() + this.emitState() + } + + /** + * Registers a listener that runs whenever `getState()` returns a new + * snapshot. Returns an unsubscribe function. Pair with `getState()` for a + * `useSyncExternalStore`-style subscription. Use `onChange` instead when an + * update must be flushed synchronously. + */ + subscribe = (listener: () => void) => { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** + * Returns the render-relevant state. The returned object keeps its identity + * for as long as none of its fields change, so it is safe to use as a + * `useSyncExternalStore` snapshot. It is derived from the current options, + * so it also reflects a `setOptions` that has not been followed by a notify + * yet (e.g. a new `count` during render). + */ + getState = (): VirtualizerState => { + const virtualItems = this.getVirtualItems() + const totalSize = this.getTotalSize() + const prev = this.state + // `calculateRange` allocates a new object on every scroll offset change, + // so the snapshot keeps its own copy, reused for as long as the indexes + // match — `range` stays referentially stable across snapshots that only + // differ in other fields. + const range = + prev !== null && + (prev.range === this.range || + (prev.range !== null && + this.range !== null && + prev.range.startIndex === this.range.startIndex && + prev.range.endIndex === this.range.endIndex)) + ? prev.range + : this.range && { + startIndex: this.range.startIndex, + endIndex: this.range.endIndex, + } + + if ( + prev !== null && + prev.virtualItems === virtualItems && + prev.totalSize === totalSize && + prev.range === range && + prev.isScrolling === this.isScrolling && + prev.scrollDirection === this.scrollDirection + ) { + return prev + } + + return (this.state = { + virtualItems, + totalSize, + range, + isScrolling: this.isScrolling, + scrollDirection: this.scrollDirection, + }) } // Returns `true` when it performed a synchronous `scrollTop` write this @@ -873,6 +977,7 @@ export class Virtualizer< if (!scrollElement) { this.maybeNotify() + this.publishState() return } @@ -963,6 +1068,8 @@ export class Virtualizer< this.scheduleScrollReconcile() } this.maybeNotify() + // A direction flip within the same range does not notify. + this.publishState() }), ) @@ -1076,6 +1183,11 @@ export class Virtualizer< // The consumer has committed the new total size by now, so a clamped // compensation write may have room (#1258). this._retryClampedAdjustment() + + // Options set during render change the snapshot without a notify, and a + // subscriber that did not re-render with its parent (e.g. a memoised + // child) would keep the old one. + this.publishState() } // Re-issue a compensation write the browser clamped because the sizer had diff --git a/packages/virtual-core/tests/index.test.ts b/packages/virtual-core/tests/index.test.ts index a6ce4021d..5b63f57bb 100644 --- a/packages/virtual-core/tests/index.test.ts +++ b/packages/virtual-core/tests/index.test.ts @@ -4255,3 +4255,140 @@ test('a prepend right after a smooth scroll landed still syncs the anchor', () = // The reader's position is preserved: the DOM is synced to the shifted offset. expect(scrollToFn.mock.calls.at(-1)?.[0]).toBe(target + 250) }) + +function createStoreVirtualizer( + overrides: Partial[0]> = {}, +) { + const scrollElement = document.createElement('div') + return new Virtualizer({ + count: 100, + estimateSize: () => 50, + getScrollElement: () => scrollElement, + scrollToFn: vi.fn(), + observeElementRect: (_, cb) => cb({ width: 200, height: 200 }), + observeElementOffset: (_, cb) => cb(0, false), + ...(overrides as object), + }) +} + +test('getState keeps its identity until a field changes', () => { + const virtualizer = createStoreVirtualizer() + virtualizer._willUpdate() + + const first = virtualizer.getState() + expect(virtualizer.getState()).toBe(first) + expect(first.range).toEqual({ startIndex: 0, endIndex: 3 }) + expect(first.virtualItems.map((item) => item.index)).toEqual([0, 1, 2, 3, 4]) + expect(first.totalSize).toBe(5000) + expect(first.isScrolling).toBe(false) + + virtualizer.setOptions({ ...virtualizer.options, count: 10 }) + const next = virtualizer.getState() + expect(next).not.toBe(first) + expect(next.totalSize).toBe(500) + expect(virtualizer.getState()).toBe(next) +}) + +test('getState keeps range referentially stable while its indexes match', () => { + const virtualizer = createStoreVirtualizer() + virtualizer._willUpdate() + + const first = virtualizer.getState() + virtualizer.resizeItem(0, 80) + const next = virtualizer.getState() + + expect(next).not.toBe(first) + expect(next.totalSize).not.toBe(first.totalSize) + expect(next.range).toBe(first.range) +}) + +test('subscribe notifies listeners when the state changes and unsubscribes', () => { + const virtualizer = createStoreVirtualizer() + virtualizer._willUpdate() + + const listener = vi.fn() + const unsubscribe = virtualizer.subscribe(listener) + + virtualizer.resizeItem(0, 80) + expect(listener).toHaveBeenCalledTimes(1) + expect(virtualizer.getState().totalSize).toBe(5030) + + // A publish that leaves the snapshot untouched does not wake listeners. + virtualizer._willUpdate() + virtualizer._willUpdate() + expect(listener).toHaveBeenCalledTimes(1) + + unsubscribe() + virtualizer.resizeItem(0, 120) + expect(listener).toHaveBeenCalledTimes(1) +}) + +test('subscribe publishes a scroll direction flip within the same range', () => { + let emitOffset: (offset: number, isScrolling: boolean) => void = () => {} + const virtualizer = createStoreVirtualizer({ + observeElementOffset: (_, cb) => { + emitOffset = cb + cb(0, false) + }, + }) + virtualizer._willUpdate() + + emitOffset(20, true) + expect(virtualizer.getState().scrollDirection).toBe('forward') + + const listener = vi.fn() + virtualizer.subscribe(listener) + const range = virtualizer.getState().range + + emitOffset(15, true) + expect(virtualizer.getState().range).toEqual(range) + expect(virtualizer.getState().scrollDirection).toBe('backward') + expect(listener).toHaveBeenCalledTimes(1) +}) + +test('_willUpdate publishes options set since the last notify', () => { + const onChange = vi.fn() + const virtualizer = createStoreVirtualizer({ onChange }) + virtualizer._willUpdate() + + const listener = vi.fn() + virtualizer.subscribe(listener) + virtualizer._willUpdate() + listener.mockClear() + onChange.mockClear() + + virtualizer.setOptions({ ...virtualizer.options, count: 2 }) + expect(listener).not.toHaveBeenCalled() + + // The range moved from 0–3 to 0–1 and nothing read it in between, so the + // publish goes through `notify`: `onChange` first, then the listener. + virtualizer._willUpdate() + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith(virtualizer, false) + expect(listener).toHaveBeenCalledTimes(1) + expect(onChange.mock.invocationCallOrder[0]).toBeLessThan( + listener.mock.invocationCallOrder[0]!, + ) + expect(virtualizer.getState().totalSize).toBe(100) +}) + +test('_willUpdate publishes a change that keeps the range without an onChange', () => { + const onChange = vi.fn() + const virtualizer = createStoreVirtualizer({ onChange }) + virtualizer._willUpdate() + + const listener = vi.fn() + virtualizer.subscribe(listener) + virtualizer._willUpdate() + listener.mockClear() + onChange.mockClear() + + // Half the rows: the total size changes, the visible range 0–3 does not. + virtualizer.setOptions({ ...virtualizer.options, count: 50 }) + virtualizer._willUpdate() + + expect(listener).toHaveBeenCalledTimes(1) + expect(onChange).not.toHaveBeenCalled() + expect(virtualizer.getState().totalSize).toBe(2500) + expect(virtualizer.getState().range).toEqual({ startIndex: 0, endIndex: 3 }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index adfc973a2..fc3e07393 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1364,6 +1364,43 @@ importers: specifier: ^6.4.2 version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) + examples/react/react-compiler: + dependencies: + '@faker-js/faker': + specifier: ^8.4.1 + version: 8.4.1 + '@tanstack/react-virtual': + specifier: ^3.14.13 + version: link:../../../packages/react-virtual + react: + specifier: ^19.2.7 + version: 19.2.7 + react-dom: + specifier: ^19.2.7 + version: 19.2.7(react@19.2.7) + devDependencies: + '@types/node': + specifier: ^24.5.2 + version: 24.9.2 + '@types/react': + specifier: ^19.2.16 + version: 19.2.16 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.16) + '@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)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)) + babel-plugin-react-compiler: + specifier: ^1.0.0 + version: 1.0.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vite: + specifier: ^6.4.2 + version: 6.4.2(@types/node@24.9.2)(jiti@2.6.1)(less@4.4.0)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1) + examples/react/scroll-padding: dependencies: '@react-hookz/web': @@ -2078,6 +2115,9 @@ 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: '@testing-library/react': specifier: ^16.3.0 @@ -2088,6 +2128,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.7.0 + version: 1.7.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)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)) @@ -2560,10 +2603,6 @@ packages: resolution: {integrity: sha512-oy5V7pD+UvfkEATUKvIjvIAH/xCzfsFVw7ygW2SI6NClZzquT+mwdTfgfdbUiceh6iQO0CHtCPsyze/MZ2YbAA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.27.1': - resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.29.7': resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} engines: {node: '>=6.9.0'} @@ -3025,10 +3064,6 @@ packages: resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} engines: {node: '>=6.9.0'} - '@babel/types@7.28.5': - resolution: {integrity: sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==} - engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} engines: {node: '>=6.9.0'} @@ -4139,12 +4174,6 @@ packages: '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} - peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 - '@ngtools/webpack@20.3.32': resolution: {integrity: sha512-a3KWNfv3NEyNHdGusIP/+lfLtjH7L5rcqgouBm6v3t1vHQ4zg2sNgoYIQIJCAJnFI2b080YrP9jh2FqKTCJlug==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'} @@ -5035,9 +5064,6 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} - '@tybys/wasm-util@0.10.3': - resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} - '@tybys/wasm-util@0.9.0': resolution: {integrity: sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw==} @@ -5214,6 +5240,9 @@ packages: '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} + '@types/use-sync-external-store@1.7.0': + resolution: {integrity: sha512-v8435kzWm3FuxyUa4r8o4sG6b0D+r+uOrzF5MXHxZKW4Haoo6txu/lq56Vio45e2i1nkmFPA+R7myWcmV7Z7Bg==} + '@types/web-bluetooth@0.0.21': resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} @@ -10310,7 +10339,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5(supports-color@10.2.2) - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -10330,7 +10359,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/traverse': 7.28.5(supports-color@10.2.2) - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@10.2.2) @@ -10445,7 +10474,7 @@ 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/types': 7.29.7 transitivePeerDependencies: - supports-color @@ -10520,8 +10549,6 @@ snapshots: dependencies: '@babel/types': 7.29.7 - '@babel/helper-string-parser@7.27.1': {} - '@babel/helper-string-parser@7.29.7': {} '@babel/helper-validator-identifier@7.28.5': {} @@ -10543,7 +10570,7 @@ snapshots: '@babel/helpers@7.28.4': dependencies: '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/helpers@7.29.7': dependencies: @@ -10552,7 +10579,7 @@ snapshots: '@babel/parser@7.28.5': dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/parser@7.29.7': dependencies: @@ -11074,7 +11101,7 @@ snapshots: dependencies: '@babel/code-frame': 7.29.0 '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@babel/template@7.29.7': dependencies: @@ -11089,7 +11116,7 @@ snapshots: '@babel/helper-globals': 7.28.0 '@babel/parser': 7.28.5 '@babel/template': 7.27.2 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 debug: 4.4.3(supports-color@10.2.2) transitivePeerDependencies: - supports-color @@ -11106,11 +11133,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/types@7.28.5': - dependencies: - '@babel/helper-string-parser': 7.27.1 - '@babel/helper-validator-identifier': 7.28.5 - '@babel/types@7.29.7': dependencies: '@babel/helper-string-parser': 7.29.7 @@ -12184,13 +12206,6 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': - dependencies: - '@emnapi/core': 1.11.1 - '@emnapi/runtime': 1.11.1 - '@tybys/wasm-util': 0.10.3 - optional: true - '@ngtools/webpack@20.3.32(@angular/compiler-cli@20.3.26(@angular/compiler@20.3.26)(typescript@5.9.3))(typescript@5.9.3)(webpack@5.105.0(esbuild@0.28.1))': dependencies: '@angular/compiler-cli': 20.3.26(@angular/compiler@20.3.26)(typescript@5.9.3) @@ -12632,7 +12647,6 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -13011,11 +13025,6 @@ snapshots: tslib: 2.8.1 optional: true - '@tybys/wasm-util@0.10.3': - dependencies: - tslib: 2.8.1 - optional: true - '@tybys/wasm-util@0.9.0': dependencies: tslib: 2.8.1 @@ -13033,23 +13042,23 @@ snapshots: '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.28.5 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 '@types/babel__generator': 7.27.0 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.28.0 '@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/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: @@ -13238,6 +13247,8 @@ snapshots: '@types/trusted-types@2.0.7': {} + '@types/use-sync-external-store@1.7.0': {} + '@types/web-bluetooth@0.0.21': {} '@types/ws@7.4.7': @@ -13975,7 +13986,7 @@ snapshots: '@babel/core': 7.28.5 '@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/types': 7.29.7 html-entities: 2.3.3 parse5: 7.3.0 @@ -14005,7 +14016,7 @@ snapshots: babel-plugin-react-compiler@1.0.0: dependencies: - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 babel-preset-solid@1.9.10(@babel/core@7.28.5)(solid-js@1.9.10): dependencies: @@ -17362,7 +17373,7 @@ snapshots: dependencies: '@babel/generator': 7.29.7 '@babel/helper-module-imports': 7.27.1 - '@babel/types': 7.28.5 + '@babel/types': 7.29.7 solid-js: 1.9.10 transitivePeerDependencies: - supports-color