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, + ) ], ) }