Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-stores-listen.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion packages/react-virtual/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
61 changes: 58 additions & 3 deletions packages/react-virtual/src/index.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -188,9 +229,9 @@ function useVirtualizerBase<

if (shouldRerender) {
if (useFlushSync && sync) {
flushSync(rerender)
flushSync(store.notify)
} else {
rerender()
store.notify()
}
}

Expand All @@ -217,6 +258,12 @@ function useVirtualizerBase<

instance.setOptions(resolvedOptions)

const renderedVersion = useSyncExternalStore(
store.subscribe,
store.getSnapshot,
store.getSnapshot,
)

useIsomorphicLayoutEffect(() => {
return instance._didMount()
}, [])
Expand All @@ -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
}

Expand Down
139 changes: 138 additions & 1 deletion packages/react-virtual/tests/index.test.tsx
Original file line number Diff line number Diff line change
@@ -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, {
Expand All @@ -19,6 +21,8 @@ beforeEach(() => {

let renderer: vi.Mock<undefined, []>

type OffsetCallback = (offset: number, isScrolling: boolean) => void

interface ListProps {
count?: number
overscan?: number
Expand All @@ -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<OffsetCallback | null>
}

function List({
Expand All @@ -39,6 +48,9 @@ function List({
rangeExtractor,
dynamic,
gap,
useFlushSync,
initialRect,
offsetCallbackRef,
}: ListProps) {
renderer()

Expand All @@ -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(() => {
Expand Down Expand Up @@ -187,3 +209,118 @@ test('should handle handle height change', () => {
rerender(<List count={1} height={200} />)
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<OffsetCallback | null>
}

test('should re-render when the scroll offset changes', () => {
const offsetRef = createOffsetRef()
render(<List offsetCallbackRef={offsetRef} />)

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<T>(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(<List offsetCallbackRef={offsetRef} />)

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(<List offsetCallbackRef={offsetRef} useFlushSync={false} />)

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(
<List initialRect={{ height: 200, width: 200 }} />,
)

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(
<React.StrictMode>
<List offsetCallbackRef={offsetRef} />
</React.StrictMode>,
)

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(<List offsetCallbackRef={offsetRef} />)
const renders = renderer.mock.calls.length

unmount()

expect(() => act(() => offsetRef.current!(250, true))).not.toThrow()
expect(renderer).toHaveBeenCalledTimes(renders)
})
11 changes: 11 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.