fix(react-virtual): skip flushSync for notifies raised inside _willUp… · TanStack/virtual@90197d9 · GitHub
Skip to content

Commit 90197d9

Browse files
piecykclaude
andcommitted
fix(react-virtual): skip flushSync for notifies raised inside _willUpdate
With a `useVirtualizerState` subscriber attached, `_willUpdate` publishes an option change that moved the range. When that happens mid-scroll the notify is sync, and the adapter called `flushSync` from inside a layout effect, where React skips the flush and warns in development. Widen the `measureElement` guard into a commit-window flag that also covers `_willUpdate`, so those notifies use a plain re-render at the same sync priority. The changeset now also notes that, with a subscriber attached, an option change that moves the range fires `onChange` when it is committed rather than at the next scroll event. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 6d2d7c1 commit 90197d9

3 files changed

Lines changed: 63 additions & 15 deletions

File tree

‎.changeset/use-virtualizer-state.md‎

Lines changed: 1 addition & 0 deletions

‎packages/react-virtual/src/index.tsx‎

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -108,9 +108,10 @@ function useVirtualizerBase<
108108
directRef.current.enabled = directDomUpdates
109109
directRef.current.mode = directDomUpdatesMode
110110

111-
// Set while the virtualizer measures an item through `measureElement`, which is
112-
// passed as a ref and therefore runs while React is committing.
113-
const measuringFromRef = React.useRef(false)
111+
// Set while React is committing and the virtualizer may notify: inside
112+
// `measureElement`, which is passed as a ref, and inside `_willUpdate`,
113+
// which runs in a layout effect. `flushSync` cannot flush in that window.
114+
const committingRef = React.useRef(false)
114115

115116
// Writes the size container's total extent to the DOM. Idempotent — guarded
116117
// by lastSize. Split out from applyDirectStyles so it can run *before* the
@@ -195,15 +196,17 @@ function useVirtualizerBase<
195196
}
196197

197198
if (shouldRerender) {
198-
// A sync notify raised from `measureElement` reaches us while React is
199-
// committing, because `measureElement` is a ref callback. `flushSync`
200-
// cannot flush there: React still runs the callback at sync priority, but
201-
// it skips the flush and warns in development. The commit phase already
202-
// runs at discrete (sync) priority, so leaving `flushSync` out for that
203-
// window keeps the same lane and the same flush point — without the
204-
// warning. Every other sync notify (ResizeObserver re-measures, scroll
205-
// adjustments) still flushes synchronously.
206-
if (useFlushSync && sync && !measuringFromRef.current) {
199+
// A sync notify can reach us while React is committing: from
200+
// `measureElement`, a ref callback, or from `_willUpdate`, a layout
201+
// effect, when it publishes an option change that moved the range
202+
// mid-scroll. `flushSync` cannot flush there: React still runs the
203+
// callback at sync priority, but it skips the flush and warns in
204+
// development. The commit phase already runs at discrete (sync)
205+
// priority, so leaving `flushSync` out for that window keeps the same
206+
// lane and the same flush point — without the warning. Every other
207+
// sync notify (ResizeObserver re-measures, scroll adjustments) still
208+
// flushes synchronously.
209+
if (useFlushSync && sync && !committingRef.current) {
207210
flushSync(rerender)
208211
} else {
209212
rerender()
@@ -218,11 +221,11 @@ function useVirtualizerBase<
218221
const v = new Virtualizer<TScrollElement, TItemElement>(resolvedOptions)
219222
const measureElement = v.measureElement
220223
v.measureElement = (node: TItemElement | null) => {
221-
measuringFromRef.current = true
224+
committingRef.current = true
222225
try {
223226
measureElement(node)
224227
} finally {
225-
measuringFromRef.current = false
228+
committingRef.current = false
226229
}
227230
// A row can mount in a commit that does not include this component — a
228231
// memoised child re-rendered through `useVirtualizerState` — which the
@@ -263,7 +266,12 @@ function useVirtualizerBase<
263266
// top until the next scroll. Positions are written afterwards by the
264267
// applyDirectStyles effect below.
265268
applyContainerSize(instance)
266-
return instance._willUpdate()
269+
committingRef.current = true
270+
try {
271+
return instance._willUpdate()
272+
} finally {
273+
committingRef.current = false
274+
}
267275
})
268276

269277
// After every render commit, newly mounted item refs have registered in

‎packages/react-virtual/tests/state.test.tsx‎

Lines changed: 39 additions & 0 deletions

0 commit comments

Comments
 (0)