diff --git a/.changeset/fix-react-19-flushsync.md b/.changeset/fix-react-19-flushsync.md
new file mode 100644
index 000000000..7bfd0bc2e
--- /dev/null
+++ b/.changeset/fix-react-19-flushsync.md
@@ -0,0 +1,14 @@
+---
+"@tanstack/react-virtual": patch
+"@tanstack/virtual-core": patch
+---
+
+feat: add `deferFlushSync` option for React 19 compatibility
+
+React 19 throws a warning when `flushSync` is called from inside a lifecycle method (`useLayoutEffect`). This change adds a new `deferFlushSync` option that users can enable to defer `flushSync` to a microtask, suppressing the warning.
+
+**Breaking Change Mitigation**: The default behavior remains unchanged (synchronous `flushSync`) to preserve scroll correction performance. Users experiencing the React 19 warning can opt-in to the deferred behavior by setting `deferFlushSync: true`.
+
+> **Note**: Enabling `deferFlushSync` may cause visible white space gaps during fast scrolling with dynamic measurements.
+
+Fixes #1094
diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx
index d835e7c4b..51b71f35a 100644
--- a/packages/react-virtual/src/index.tsx
+++ b/packages/react-virtual/src/index.tsx
@@ -28,7 +28,11 @@ function useVirtualizerBase<
...options,
onChange: (instance, sync) => {
if (sync) {
- flushSync(rerender)
+ if (options.deferFlushSync) {
+ queueMicrotask(() => flushSync(rerender))
+ } else {
+ flushSync(rerender)
+ }
} else {
rerender()
}
diff --git a/packages/react-virtual/tests/index.test.tsx b/packages/react-virtual/tests/index.test.tsx
index c7348dc20..8264e46b6 100644
--- a/packages/react-virtual/tests/index.test.tsx
+++ b/packages/react-virtual/tests/index.test.tsx
@@ -27,6 +27,7 @@ interface ListProps {
itemSize?: number
rangeExtractor?: (range: Range) => number[]
dynamic?: boolean
+ deferFlushSync?: boolean
}
function List({
@@ -37,6 +38,7 @@ function List({
itemSize,
rangeExtractor,
dynamic,
+ deferFlushSync,
}: ListProps) {
renderer()
@@ -57,6 +59,7 @@ function List({
},
measureElement: () => itemSize ?? 0,
rangeExtractor,
+ deferFlushSync,
})
React.useEffect(() => {
@@ -161,6 +164,32 @@ test('should render given dynamic size after scroll', () => {
expect(renderer).toHaveBeenCalledTimes(2)
})
+test('should render given dynamic size after scroll with deferFlushSync', async () => {
+ render(
)
+
+ expect(screen.queryByText('Row 0')).toBeInTheDocument()
+ expect(screen.queryByText('Row 1')).toBeInTheDocument()
+ expect(screen.queryByText('Row 2')).toBeInTheDocument()
+ expect(screen.queryByText('Row 3')).not.toBeInTheDocument()
+
+ expect(renderer).toHaveBeenCalledTimes(3)
+ renderer.mockReset()
+
+ fireEvent.scroll(screen.getByTestId('scroller'), {
+ target: { scrollTop: 400 },
+ })
+
+ // Wait for microtask to complete (flushSync is deferred via queueMicrotask)
+ await new Promise((resolve) => queueMicrotask(() => resolve()))
+
+ expect(screen.queryByText('Row 2')).not.toBeInTheDocument()
+ expect(screen.queryByText('Row 3')).toBeInTheDocument()
+ expect(screen.queryByText('Row 6')).toBeInTheDocument()
+ expect(screen.queryByText('Row 7')).not.toBeInTheDocument()
+
+ expect(renderer).toHaveBeenCalledTimes(2)
+})
+
test('should use rangeExtractor', () => {
render( [0, 1]} />)
diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts
index bd646dec6..31c3cbd3c 100644
--- a/packages/virtual-core/src/index.ts
+++ b/packages/virtual-core/src/index.ts
@@ -85,7 +85,7 @@ export const observeElementRect = (
handler(getRect(element as unknown as HTMLElement))
if (!targetWindow.ResizeObserver) {
- return () => {}
+ return () => { }
}
const observer = new targetWindow.ResizeObserver((entries) => {
@@ -161,12 +161,12 @@ export const observeElementOffset = (
instance.options.useScrollendEvent && supportsScrollend
? () => undefined
: debounce(
- targetWindow,
- () => {
- cb(offset, false)
- },
- instance.options.isScrollingResetDelay,
- )
+ targetWindow,
+ () => {
+ cb(offset, false)
+ },
+ instance.options.isScrollingResetDelay,
+ )
const createHandler = (isScrolling: boolean) => () => {
const { horizontal, isRtl } = instance.options
@@ -212,12 +212,12 @@ export const observeWindowOffset = (
instance.options.useScrollendEvent && supportsScrollend
? () => undefined
: debounce(
- targetWindow,
- () => {
- cb(offset, false)
- },
- instance.options.isScrollingResetDelay,
- )
+ targetWindow,
+ () => {
+ cb(offset, false)
+ },
+ instance.options.isScrollingResetDelay,
+ )
const createHandler = (isScrolling: boolean) => () => {
offset = element[instance.options.horizontal ? 'scrollX' : 'scrollY']
@@ -348,6 +348,16 @@ export interface VirtualizerOptions<
enabled?: boolean
isRtl?: boolean
useAnimationFrameWithResizeObserver?: boolean
+ /**
+ * If true, defers the flushSync call during synchronous updates to a microtask.
+ * This suppresses the React 19 warning: "flushSync was called from inside a lifecycle method."
+ *
+ * @warning Enabling this may cause visible white space gaps during fast scrolling
+ * with dynamic measurements, as the scroll correction won't be applied synchronously.
+ *
+ * @default false
+ */
+ deferFlushSync?: boolean
}
export class Virtualizer<
@@ -373,10 +383,10 @@ export class Virtualizer<
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
- item: VirtualItem,
- delta: number,
- instance: Virtualizer,
- ) => boolean)
+ item: VirtualItem,
+ delta: number,
+ instance: Virtualizer,
+ ) => boolean)
elementsCache = new Map()
private observer = (() => {
let _ro: ResizeObserver | null = null
@@ -434,7 +444,7 @@ export class Virtualizer<
horizontal: false,
getItemKey: defaultKeyExtractor,
rangeExtractor: defaultRangeExtractor,
- onChange: () => {},
+ onChange: () => { },
measureElement,
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
@@ -447,6 +457,7 @@ export class Virtualizer<
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
+ deferFlushSync: false,
...opts,
}
}
@@ -605,12 +616,12 @@ export class Virtualizer<
return furthestMeasurements.size === this.options.lanes
? Array.from(furthestMeasurements.values()).sort((a, b) => {
- if (a.end === b.end) {
- return a.index - b.index
- }
+ if (a.end === b.end) {
+ return a.index - b.index
+ }
- return a.end - b.end
- })[0]
+ return a.end - b.end
+ })[0]
: undefined
}
@@ -802,11 +813,11 @@ export class Virtualizer<
return (this.range =
measurements.length > 0 && outerSize > 0
? calculateRange({
- measurements,
- outerSize,
- scrollOffset,
- lanes,
- })
+ measurements,
+ outerSize,
+ scrollOffset,
+ lanes,
+ })
: null)
},
{
@@ -837,11 +848,11 @@ export class Virtualizer<
return startIndex === null || endIndex === null
? []
: rangeExtractor({
- startIndex,
- endIndex,
- overscan,
- count,
- })
+ startIndex,
+ endIndex,
+ overscan,
+ count,
+ })
},
{
key: process.env.NODE_ENV !== 'production' && 'getVirtualIndexes',
@@ -960,12 +971,12 @@ export class Virtualizer<
}
return notUndefined(
measurements[
- findNearestBinarySearch(
- 0,
- measurements.length - 1,
- (index: number) => notUndefined(measurements[index]).start,
- offset,
- )
+ findNearestBinarySearch(
+ 0,
+ measurements.length - 1,
+ (index: number) => notUndefined(measurements[index]).start,
+ offset,
+ )
],
)
}