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
14 changes: 14 additions & 0 deletions .changeset/fix-react-19-flushsync.md
Original file line number Diff line number Diff line change
@@ -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
6 changes: 5 additions & 1 deletion packages/react-virtual/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ function useVirtualizerBase<
...options,
onChange: (instance, sync) => {
if (sync) {
flushSync(rerender)
if (options.deferFlushSync) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lift the check to sync

queueMicrotask(() => flushSync(rerender))
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the queueMicrotask

} else {
flushSync(rerender)
}
} else {
rerender()
}
Expand Down
29 changes: 29 additions & 0 deletions packages/react-virtual/tests/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface ListProps {
itemSize?: number
rangeExtractor?: (range: Range) => number[]
dynamic?: boolean
deferFlushSync?: boolean
}

function List({
Expand All @@ -37,6 +38,7 @@ function List({
itemSize,
rangeExtractor,
dynamic,
deferFlushSync,
}: ListProps) {
renderer()

Expand All @@ -57,6 +59,7 @@ function List({
},
measureElement: () => itemSize ?? 0,
rangeExtractor,
deferFlushSync,
})

React.useEffect(() => {
Expand Down Expand Up @@ -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(<List itemSize={100} dynamic deferFlushSync />)

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<void>((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(<List rangeExtractor={() => [0, 1]} />)

Expand Down
89 changes: 50 additions & 39 deletions packages/virtual-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ export const observeElementRect = <T extends Element>(
handler(getRect(element as unknown as HTMLElement))

if (!targetWindow.ResizeObserver) {
return () => {}
return () => { }
}

const observer = new targetWindow.ResizeObserver((entries) => {
Expand Down Expand Up @@ -161,12 +161,12 @@ export const observeElementOffset = <T extends Element>(
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
Expand Down Expand Up @@ -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']
Expand Down Expand Up @@ -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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this be only react specific, also let's use useFlushSync

}

export class Virtualizer<
Expand All @@ -373,10 +383,10 @@ export class Virtualizer<
shouldAdjustScrollPositionOnItemSizeChange:
| undefined
| ((
item: VirtualItem,
delta: number,
instance: Virtualizer<TScrollElement, TItemElement>,
) => boolean)
item: VirtualItem,
delta: number,
instance: Virtualizer<TScrollElement, TItemElement>,
) => boolean)
elementsCache = new Map<Key, TItemElement>()
private observer = (() => {
let _ro: ResizeObserver | null = null
Expand Down Expand Up @@ -434,7 +444,7 @@ export class Virtualizer<
horizontal: false,
getItemKey: defaultKeyExtractor,
rangeExtractor: defaultRangeExtractor,
onChange: () => {},
onChange: () => { },
measureElement,
initialRect: { width: 0, height: 0 },
scrollMargin: 0,
Expand All @@ -447,6 +457,7 @@ export class Virtualizer<
isRtl: false,
useScrollendEvent: false,
useAnimationFrameWithResizeObserver: false,
deferFlushSync: false,
...opts,
}
}
Expand Down Expand Up @@ -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
}

Expand Down Expand Up @@ -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)
},
{
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
)
],
)
}
Expand Down