From 2c97816ac563c51174750736cc31d74a1d3dd943 Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:55:11 +0700 Subject: [PATCH 1/6] fix(react-virtual): defer flushSync to microtask for React 19 compatibility React 19 throws a warning when flushSync is called from inside a lifecycle method (useLayoutEffect). This change defers the flushSync call to a microtask using queueMicrotask(), allowing React to complete its current render cycle before forcing the synchronous update. Fixes the warning: 'flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering.' --- packages/react-virtual/src/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index d835e7c4..a7da4a0e 100644 --- a/packages/react-virtual/src/index.tsx +++ b/packages/react-virtual/src/index.tsx @@ -28,7 +28,7 @@ function useVirtualizerBase< ...options, onChange: (instance, sync) => { if (sync) { - flushSync(rerender) + queueMicrotask(() => flushSync(rerender)) } else { rerender() } From 59f577bab3d92cf5c86caef188fabcea4415bfde Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Sat, 27 Dec 2025 20:59:55 +0700 Subject: [PATCH 2/6] chore: add changeset for React 19 flushSync fix --- .changeset/fix-react-19-flushsync.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/fix-react-19-flushsync.md diff --git a/.changeset/fix-react-19-flushsync.md b/.changeset/fix-react-19-flushsync.md new file mode 100644 index 00000000..61b68521 --- /dev/null +++ b/.changeset/fix-react-19-flushsync.md @@ -0,0 +1,9 @@ +--- +"@tanstack/react-virtual": patch +--- + +fix: defer flushSync to microtask for React 19 compatibility + +React 19 throws a warning when `flushSync` is called from inside a lifecycle method (`useLayoutEffect`). This change wraps the `flushSync` call in `queueMicrotask()` to defer it to a microtask, allowing React to complete its current render cycle before forcing the synchronous update. + +This fixes the warning: "flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering." From dc7c52acefccf8b7c3015f14fabf2ff6abc19e45 Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Sat, 27 Dec 2025 21:05:34 +0700 Subject: [PATCH 3/6] test: update scroll test to be async for queueMicrotask change --- packages/react-virtual/tests/index.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-virtual/tests/index.test.tsx b/packages/react-virtual/tests/index.test.tsx index c7348dc2..19c4b67d 100644 --- a/packages/react-virtual/tests/index.test.tsx +++ b/packages/react-virtual/tests/index.test.tsx @@ -138,7 +138,7 @@ test('should render given dynamic size', async () => { expect(renderer).toHaveBeenCalledTimes(3) }) -test('should render given dynamic size after scroll', () => { +test('should render given dynamic size after scroll', async () => { render() expect(screen.queryByText('Row 0')).toBeInTheDocument() @@ -153,6 +153,9 @@ test('should render given dynamic size after scroll', () => { 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() From 2398da84a147336571877b825b2b549bfd6f30c4 Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Sat, 27 Dec 2025 21:06:44 +0700 Subject: [PATCH 4/6] fix: resolve TypeScript error in test --- packages/react-virtual/tests/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-virtual/tests/index.test.tsx b/packages/react-virtual/tests/index.test.tsx index 19c4b67d..05c69c77 100644 --- a/packages/react-virtual/tests/index.test.tsx +++ b/packages/react-virtual/tests/index.test.tsx @@ -154,7 +154,7 @@ test('should render given dynamic size after scroll', async () => { }) // Wait for microtask to complete (flushSync is deferred via queueMicrotask) - await new Promise((resolve) => queueMicrotask(resolve)) + await new Promise((resolve) => queueMicrotask(() => resolve())) expect(screen.queryByText('Row 2')).not.toBeInTheDocument() expect(screen.queryByText('Row 3')).toBeInTheDocument() From e8888169b687e6623d91c8b4d86781899d7556ba Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Sat, 27 Dec 2025 21:08:47 +0700 Subject: [PATCH 5/6] chore: add issue reference #1094 --- .changeset/fix-react-19-flushsync.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/fix-react-19-flushsync.md b/.changeset/fix-react-19-flushsync.md index 61b68521..143e3b7c 100644 --- a/.changeset/fix-react-19-flushsync.md +++ b/.changeset/fix-react-19-flushsync.md @@ -6,4 +6,6 @@ fix: defer flushSync to microtask for React 19 compatibility React 19 throws a warning when `flushSync` is called from inside a lifecycle method (`useLayoutEffect`). This change wraps the `flushSync` call in `queueMicrotask()` to defer it to a microtask, allowing React to complete its current render cycle before forcing the synchronous update. +Fixes #1094 + This fixes the warning: "flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering." From fa4acc3649420f84bdc9c9b54c6a9a6c2a30c11c Mon Sep 17 00:00:00 2001 From: Chan Nyein Tun <29679269+channyeintun@users.noreply.github.com> Date: Mon, 29 Dec 2025 09:13:39 +0700 Subject: [PATCH 6/6] feat: add deferFlushSync option for React 19 compatibility Instead of unconditionally deferring flushSync via queueMicrotask, add a configurable option that allows users to opt-in to this behavior. - Add deferFlushSync option to VirtualizerOptions (default: false) - Update react-virtual to conditionally defer flushSync based on option - Update tests to cover both synchronous (default) and deferred behaviors This addresses reviewer feedback that unconditionally deferring flushSync changes the scroll correction behavior and reintroduces visible gaps during fast scrolling with dynamic measurements. Fixes #1094 --- .changeset/fix-react-19-flushsync.md | 11 ++- packages/react-virtual/src/index.tsx | 6 +- packages/react-virtual/tests/index.test.tsx | 28 ++++++- packages/virtual-core/src/index.ts | 89 ++++++++++++--------- 4 files changed, 89 insertions(+), 45 deletions(-) diff --git a/.changeset/fix-react-19-flushsync.md b/.changeset/fix-react-19-flushsync.md index 143e3b7c..7bfd0bc2 100644 --- a/.changeset/fix-react-19-flushsync.md +++ b/.changeset/fix-react-19-flushsync.md @@ -1,11 +1,14 @@ --- "@tanstack/react-virtual": patch +"@tanstack/virtual-core": patch --- -fix: defer flushSync to microtask for React 19 compatibility +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 wraps the `flushSync` call in `queueMicrotask()` to defer it to a microtask, allowing React to complete its current render cycle before forcing the synchronous update. +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. -Fixes #1094 +**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. -This fixes the warning: "flushSync was called from inside a lifecycle method. React cannot flush when React is already rendering." +Fixes #1094 diff --git a/packages/react-virtual/src/index.tsx b/packages/react-virtual/src/index.tsx index a7da4a0e..51b71f35 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) { - queueMicrotask(() => 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 05c69c77..8264e46b 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(() => { @@ -138,7 +141,7 @@ test('should render given dynamic size', async () => { expect(renderer).toHaveBeenCalledTimes(3) }) -test('should render given dynamic size after scroll', async () => { +test('should render given dynamic size after scroll', () => { render() expect(screen.queryByText('Row 0')).toBeInTheDocument() @@ -153,6 +156,29 @@ test('should render given dynamic size after scroll', async () => { target: { scrollTop: 400 }, }) + 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 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())) diff --git a/packages/virtual-core/src/index.ts b/packages/virtual-core/src/index.ts index bd646dec..31c3cbd3 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, + ) ], ) }