From 83cdb85737201b4c96ae63b7794e7e438bdaeabf Mon Sep 17 00:00:00 2001 From: Clement Wong Date: Mon, 24 Aug 2026 23:24:26 +0800 Subject: [PATCH] refactor(react-virtual): subscribe to virtualizer updates with useSyncExternalStore Replace the reducer bump in useVirtualizerBase with a small external store consumed through useSyncExternalStore (official use-sync-external-store shim, so the >=16.8 peer range is unchanged). useVirtualizer / useWindowVirtualizer keep the same API and identity semantics; consumers still read getVirtualItems() / getTotalSize() from the instance. The store snapshot is a version counter bumped by every notification the adapter decides to render, so under concurrent rendering React can detect a mid-render store change and re-render synchronously instead of committing a torn range. useFlushSync keeps its meaning: the sync scroll path notifies inside flushSync. useSyncExternalStore subscribes in a passive effect, so notifications raised while React is committing (initial rect/offset measurement in _willUpdate, scroll-element swaps, measureElement refs) would only surface after paint. A final layout effect compares the store version with the rendered one and dispatches a reducer so those still re-render before paint, exactly as the reducer-only implementation did; mount/measure render counts are unchanged. Tests cover scroll notifications through the store, useFlushSync sync vs scheduled commits outside act, SSR via getServerSnapshot, StrictMode, and post-unmount notifications. Co-Authored-By: Claude Fable 5 --- .changeset/quiet-stores-listen.md | 5 + packages/react-virtual/package.json | 4 +- packages/react-virtual/src/index.tsx | 61 ++++++++- packages/react-virtual/tests/index.test.tsx | 139 +++++++++++++++++++- pnpm-lock.yaml | 11 ++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 .changeset/quiet-stores-listen.md diff --git a/.changeset/quiet-stores-listen.md b/.changeset/quiet-stores-listen.md new file mode 100644 index 000000000..c0fa35d4a --- /dev/null +++ b/.changeset/quiet-stores-listen.md @@ -0,0 +1,5 @@ +--- +'@tanstack/react-virtual': patch +--- + +Subscribe to virtualizer updates with `useSyncExternalStore` (via the official `use-sync-external-store` shim, keeping the `>=16.8` React peer range) instead of bumping a reducer. `useVirtualizer` and `useWindowVirtualizer` keep the same API and the same render timing — synchronous re-render before paint on mount and inside `useFlushSync` scroll handlers — but React now tracks the virtualizer as an external store: when it changes during a concurrent render (transitions, Suspense) React re-renders synchronously instead of committing a torn, stale range. diff --git a/packages/react-virtual/package.json b/packages/react-virtual/package.json index 9b04d45fa..534d856ba 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.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..6c5c1f77c 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, @@ -16,6 +17,35 @@ export * from '@tanstack/virtual-core' const useIsomorphicLayoutEffect = typeof document !== 'undefined' ? React.useLayoutEffect : React.useEffect +/** + * Bridges the virtualizer's `onChange` notifications to + * `useSyncExternalStore`. The snapshot is a version counter: every + * notification the adapter decides to render bumps it. Consumers keep reading + * render-facing values (`getVirtualItems()`, `getTotalSize()`, …) straight + * from the instance — the counter only tells React *that* the instance moved, + * which is enough for it to schedule the re-render and, under concurrent + * rendering, to detect a store change mid-render and re-render synchronously + * instead of committing a torn frame. + */ +function createStore() { + const listeners = new Set<() => void>() + let version = 0 + + return { + subscribe: (listener: () => void) => { + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, + getSnapshot: () => version, + notify: () => { + version++ + listeners.forEach((listener) => listener()) + }, + } +} + export type ReactVirtualizer< TScrollElement extends Element | Window, TItemElement extends Element, @@ -81,7 +111,18 @@ function useVirtualizerBase< TScrollElement, TItemElement > { - const rerender = React.useReducer((x: number) => x + 1, 0)[1] + const [store] = React.useState(createStore) + + // `useSyncExternalStore` subscribes in a passive effect, so a notification + // raised while React is committing — the initial rect / offset measurement + // in `_willUpdate`, a scroll-element swap, or a `measureElement` ref firing + // for a freshly mounted item — has no listener yet and would only be picked + // up by the store's post-commit check, i.e. after the browser has painted + // the stale range. The layout effect at the bottom of this hook dispatches + // this reducer when the store moved during commit so React re-renders + // synchronously, before paint — the timing the reducer-only implementation + // always had. + const [, rerenderBeforePaint] = React.useReducer((x: number) => x + 1, 0) // Mutable across renders so the onChange closure captured by setOptions // always reads the latest values without us having to re-create it. @@ -188,9 +229,9 @@ function useVirtualizerBase< if (shouldRerender) { if (useFlushSync && sync) { - flushSync(rerender) + flushSync(store.notify) } else { - rerender() + store.notify() } } @@ -217,6 +258,12 @@ function useVirtualizerBase< instance.setOptions(resolvedOptions) + const renderedVersion = useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ) + useIsomorphicLayoutEffect(() => { return instance._didMount() }, []) @@ -239,6 +286,14 @@ function useVirtualizerBase< applyDirectStyles(instance) }) + // Must stay the last layout effect: it observes notifications raised by the + // effects above (and by item refs, which attach before layout effects run). + useIsomorphicLayoutEffect(() => { + if (store.getSnapshot() !== renderedVersion) { + rerenderBeforePaint() + } + }) + return instance } diff --git a/packages/react-virtual/tests/index.test.tsx b/packages/react-virtual/tests/index.test.tsx index dc6da3347..f5fa34ab1 100644 --- a/packages/react-virtual/tests/index.test.tsx +++ b/packages/react-virtual/tests/index.test.tsx @@ -1,8 +1,10 @@ import { beforeEach, test, expect, vi } from 'vitest' import * as React from 'react' -import { render, screen } from '@testing-library/react' +import { renderToString } from 'react-dom/server' +import { act, render, screen } from '@testing-library/react' import { useVirtualizer, Range } from '../src/index' +import type { Rect } from '../src/index' beforeEach(() => { Object.defineProperties(HTMLElement.prototype, { @@ -19,6 +21,8 @@ beforeEach(() => { let renderer: vi.Mock +type OffsetCallback = (offset: number, isScrolling: boolean) => void + interface ListProps { count?: number overscan?: number @@ -28,6 +32,11 @@ interface ListProps { rangeExtractor?: (range: Range) => number[] dynamic?: boolean gap?: number + useFlushSync?: boolean + initialRect?: Rect + // When given, the list installs a stub `observeElementOffset` and stores + // its callback here so tests can drive scroll notifications directly. + offsetCallbackRef?: React.MutableRefObject } function List({ @@ -39,6 +48,9 @@ function List({ rangeExtractor, dynamic, gap, + useFlushSync, + initialRect, + offsetCallbackRef, }: ListProps) { renderer() @@ -60,6 +72,16 @@ function List({ measureElement: () => itemSize ?? 0, rangeExtractor, gap, + useFlushSync, + ...(initialRect ? { initialRect } : {}), + ...(offsetCallbackRef + ? { + observeElementOffset: (_: unknown, cb: OffsetCallback) => { + cb(0, false) + offsetCallbackRef.current = cb + }, + } + : {}), }) React.useEffect(() => { @@ -187,3 +209,118 @@ test('should handle handle height change', () => { rerender() expect(screen.queryByText('Row 0')).toBeInTheDocument() }) + +// --- useSyncExternalStore subscription ------------------------------------- +// +// Re-renders are driven by `useSyncExternalStore`. Scroll notifications +// reach React through the store's subscription; notifications raised while +// React is committing (initial measurement, item refs) are caught by a +// layout effect so the corrected range still paints in the same frame. + +function createOffsetRef() { + return { current: null } as React.MutableRefObject +} + +test('should re-render when the scroll offset changes', () => { + const offsetRef = createOffsetRef() + render() + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(renderer).toHaveBeenCalledTimes(2) + + // 200px viewport, 50px rows, overscan 1: offset 250 → rows 4..9. + act(() => offsetRef.current!(250, true)) + + expect(screen.queryByText('Row 3')).not.toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() + expect(screen.queryByText('Row 9')).toBeInTheDocument() + expect(screen.queryByText('Row 10')).not.toBeInTheDocument() + expect(renderer).toHaveBeenCalledTimes(3) + + // Scroll settles: `isScrolling` flips, range unchanged → one more render. + act(() => offsetRef.current!(250, false)) + expect(renderer).toHaveBeenCalledTimes(4) +}) + +// Runs `fn` outside React's act environment so that nothing but the hook's +// own scheduling decides when the update commits. +function withoutAct(fn: () => T): T { + const g = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } + const prev = g.IS_REACT_ACT_ENVIRONMENT + g.IS_REACT_ACT_ENVIRONMENT = false + try { + return fn() + } finally { + g.IS_REACT_ACT_ENVIRONMENT = prev + } +} + +test('should commit synchronously during scroll with useFlushSync', () => { + const offsetRef = createOffsetRef() + render() + + withoutAct(() => { + offsetRef.current!(250, true) + // `flushSync` has already committed by the time the scroll handler + // returns — no scheduler turn in between. + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + expect(screen.queryByText('Row 5')).toBeInTheDocument() + }) +}) + +test('should let React schedule the commit with useFlushSync: false', async () => { + const offsetRef = createOffsetRef() + render() + + await withoutAct(async () => { + offsetRef.current!(250, true) + // Not flushed synchronously … + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 5')).not.toBeInTheDocument() + + // … but React picks the store change up on its own. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(screen.queryByText('Row 0')).not.toBeInTheDocument() + expect(screen.queryByText('Row 5')).toBeInTheDocument() + }) +}) + +test('should render on the server', () => { + const html = renderToString( + , + ) + + expect(html).toContain('data-testid="item-0"') + expect(html).toContain('data-testid="item-4"') + expect(html).not.toContain('data-testid="item-5"') +}) + +test('should work in StrictMode', () => { + const offsetRef = createOffsetRef() + render( + + + , + ) + + expect(screen.queryByText('Row 0')).toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() + expect(screen.queryByText('Row 5')).not.toBeInTheDocument() + + act(() => offsetRef.current!(250, true)) + + expect(screen.queryByText('Row 3')).not.toBeInTheDocument() + expect(screen.queryByText('Row 4')).toBeInTheDocument() + expect(screen.queryByText('Row 9')).toBeInTheDocument() +}) + +test('should ignore notifications after unmount', () => { + const offsetRef = createOffsetRef() + const { unmount } = render() + const renders = renderer.mock.calls.length + + unmount() + + expect(() => act(() => offsetRef.current!(250, true))).not.toThrow() + expect(renderer).toHaveBeenCalledTimes(renders) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b90143c4..dde993058 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2078,6 +2078,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 +2091,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)(lightningcss@1.33.0)(sass@1.90.0)(terser@5.43.1)(yaml@2.8.1)) @@ -5214,6 +5220,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==} @@ -13237,6 +13246,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':