@samitouri / QOS-React-2 / commits / f15f8f64bb

Store interleaved updates on separate queue until end of render (#20615)

## Motivation An *interleaved* update is one that is scheduled while a render is already in progress, typically from a concurrent user input event. We have to take care not to process these updates during the current render, because a multiple interleaved updates may have been scheduled across many components; to avoid tearing, we cannot render some of those updates without rendering all of them. ## Old approach What we currently do when we detect an interleaved update is assign a lane that is not part of the current render. This has some unfortunate drawbacks. For example, we will eventually run out of lanes at a given priority level. When this happens, our last resort is to interrupt the current render and start over from scratch. If this happens enough, it can lead to starvation. More concerning, there are a suprising number of places that must separately account for this case, often in subtle ways. The maintenance complexity has led to a number of tearing bugs. ## New approach I added a new field to the update queue, `interleaved`. It's a linked list, just like the `pending` field. When an interleaved update is scheduled, we add it to the `interleaved` list instead of `pending`. Then we push the entire queue object onto a global array. When the current render exits, we iterate through the array of interleaved queues and transfer the `interleaved` list to the `pending` list. So, until the current render has exited (whether due to a commit or an interruption), it's impossible to process an interleaved update, because they have not yet been enqueued. In this new approach, we don't need to resort to clever lanes tricks to avoid inconsistencies. This should allow us to simplify a lot of the logic that's currently in ReactFiberWorkLoop and ReactFiberLane, especially `findUpdateLane` and `getNextLanes`. All the logic for interleaved updates is isolated to one place.

Andrew Clark committed Jan 22, 2021 at 16:01 UTC f15f8f64bbc3e02911d1a112fa9bb8f7066a56ee
9 files changed +339 -36
packages/react-reconciler/src/ReactFiberClassComponent.new.js
+3 -3
@@ -213,7 +213,7 @@ const classComponentUpdater = {
213 update.callback = callback;
214 }
215
216 - enqueueUpdate(fiber, update);
216 + enqueueUpdate(fiber, update, lane);
217 scheduleUpdateOnFiber(fiber, lane, eventTime);
218
219 if (__DEV__) {
@@ -245,7 +245,7 @@ const classComponentUpdater = {
245 update.callback = callback;
246 }
247
248 - enqueueUpdate(fiber, update);
248 + enqueueUpdate(fiber, update, lane);
249 scheduleUpdateOnFiber(fiber, lane, eventTime);
250
251 if (__DEV__) {
@@ -276,7 +276,7 @@ const classComponentUpdater = {
276 update.callback = callback;
277 }
278
279 - enqueueUpdate(fiber, update);
279 + enqueueUpdate(fiber, update, lane);
280 scheduleUpdateOnFiber(fiber, lane, eventTime);
281
282 if (__DEV__) {
packages/react-reconciler/src/ReactFiberHooks.new.js
+59 -13
@@ -75,6 +75,7 @@ import {
75 warnIfNotCurrentlyActingUpdatesInDev,
76 warnIfNotScopedWithMatchingAct,
77 markSkippedUpdateLanes,
78 + isInterleavedUpdate,
79 } from './ReactFiberWorkLoop.new';
80
81 import invariant from 'shared/invariant';
@@ -104,6 +105,7 @@ import {logStateUpdateScheduled} from './DebugTracing';
105 import {markStateUpdateScheduled} from './SchedulingProfiler';
106 import {CacheContext} from './ReactFiberCacheComponent.new';
107 import {createUpdate, enqueueUpdate} from './ReactUpdateQueue.new';
108 +import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new';
109
110 const {ReactCurrentDispatcher, ReactCurrentBatchConfig} = ReactSharedInternals;
111
@@ -116,8 +118,9 @@ type Update<S, A> = {|
118 priority?: ReactPriorityLevel,
119 |};
120
119 -type UpdateQueue<S, A> = {|
121 +export type UpdateQueue<S, A> = {|
122 pending: Update<S, A> | null,
123 + interleaved: Update<S, A> | null,
124 dispatch: (A => mixed) | null,
125 lastRenderedReducer: ((S, A) => S) | null,
126 lastRenderedState: S | null,
@@ -650,6 +653,7 @@ function mountReducer<S, I, A>(
653 hook.memoizedState = hook.baseState = initialState;
654 const queue = (hook.queue = {
655 pending: null,
656 + interleaved: null,
657 dispatch: null,
658 lastRenderedReducer: reducer,
659 lastRenderedState: (initialState: any),
@@ -792,6 +796,23 @@ function updateReducer<S, I, A>(
796 queue.lastRenderedState = newState;
797 }
798
799 + // Interleaved updates are stored on a separate queue. We aren't going to
800 + // process them during this render, but we do need to track which lanes
801 + // are remaining.
802 + const lastInterleaved = queue.interleaved;
803 + if (lastInterleaved !== null) {
804 + let interleaved = lastInterleaved;
805 + do {
806 + const interleavedLane = interleaved.lane;
807 + currentlyRenderingFiber.lanes = mergeLanes(
808 + currentlyRenderingFiber.lanes,
809 + interleavedLane,
810 + );
811 + markSkippedUpdateLanes(interleavedLane);
812 + interleaved = ((interleaved: any).next: Update<S, A>);
813 + } while (interleaved !== lastInterleaved);
814 + }
815 +
816 const dispatch: Dispatch<A> = (queue.dispatch: any);
817 return [hook.memoizedState, dispatch];
818 }
@@ -1080,6 +1101,7 @@ function useMutableSource<Source, Snapshot>(
1101 // including any interleaving updates that occur.
1102 const newQueue = {
1103 pending: null,
1104 + interleaved: null,
1105 dispatch: null,
1106 lastRenderedReducer: basicStateReducer,
1107 lastRenderedState: snapshot,
@@ -1135,6 +1157,7 @@ function mountState<S>(
1157 hook.memoizedState = hook.baseState = initialState;
1158 const queue = (hook.queue = {
1159 pending: null,
1160 + interleaved: null,
1161 dispatch: null,
1162 lastRenderedReducer: basicStateReducer,
1163 lastRenderedState: (initialState: any),
@@ -1812,7 +1835,7 @@ function refreshCache<T>(fiber: Fiber, seedKey: ?() => T, seedValue: T) {
1835 cache: seededCache,
1836 };
1837 refreshUpdate.payload = payload;
1815 - enqueueUpdate(provider, refreshUpdate);
1838 + enqueueUpdate(provider, refreshUpdate, lane);
1839 return;
1840 }
1841 }
@@ -1847,17 +1870,6 @@ function dispatchAction<S, A>(
1870 next: (null: any),
1871 };
1872
1850 - // Append the update to the end of the list.
1851 - const pending = queue.pending;
1852 - if (pending === null) {
1853 - // This is the first update. Create a circular list.
1854 - update.next = update;
1855 - } else {
1856 - update.next = pending.next;
1857 - pending.next = update;
1858 - }
1859 - queue.pending = update;
1860 -
1873 const alternate = fiber.alternate;
1874 if (
1875 fiber === currentlyRenderingFiber ||
@@ -1867,7 +1879,41 @@ function dispatchAction<S, A>(
1879 // queue -> linked list of updates. After this render pass, we'll restart
1880 // and apply the stashed updates on top of the work-in-progress hook.
1881 didScheduleRenderPhaseUpdateDuringThisPass = didScheduleRenderPhaseUpdate = true;
1882 + const pending = queue.pending;
1883 + if (pending === null) {
1884 + // This is the first update. Create a circular list.
1885 + update.next = update;
1886 + } else {
1887 + update.next = pending.next;
1888 + pending.next = update;
1889 + }
1890 + queue.pending = update;
1891 } else {
1892 + if (isInterleavedUpdate(fiber, lane)) {
1893 + const interleaved = queue.interleaved;
1894 + if (interleaved === null) {
1895 + // This is the first update. Create a circular list.
1896 + update.next = update;
1897 + // At the end of the current render, this queue's interleaved updates will
1898 + // be transfered to the pending queue.
1899 + pushInterleavedQueue(queue);
1900 + } else {
1901 + update.next = interleaved.next;
1902 + interleaved.next = update;
1903 + }
1904 + queue.interleaved = update;
1905 + } else {
1906 + const pending = queue.pending;
1907 + if (pending === null) {
1908 + // This is the first update. Create a circular list.
1909 + update.next = update;
1910 + } else {
1911 + update.next = pending.next;
1912 + pending.next = update;
1913 + }
1914 + queue.pending = update;
1915 + }
1916 +
1917 if (
1918 fiber.lanes === NoLanes &&
1919 (alternate === null || alternate.lanes === NoLanes)
packages/react-reconciler/src/ReactFiberInterleavedUpdates.new.js new
+55
@@ -0,0 +1,55 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import type {UpdateQueue as HookQueue} from './ReactFiberHooks.new';
11 +import type {SharedQueue as ClassQueue} from './ReactUpdateQueue.new';
12 +
13 +// An array of all update queues that received updates during the current
14 +// render. When this render exits, either because it finishes or because it is
15 +// interrupted, the interleaved updates will be transfered onto the main part
16 +// of the queue.
17 +let interleavedQueues: Array<
18 + HookQueue<any, any> | ClassQueue<any>,
19 +> | null = null;
20 +
21 +export function pushInterleavedQueue(
22 + queue: HookQueue<any, any> | ClassQueue<any>,
23 +) {
24 + if (interleavedQueues === null) {
25 + interleavedQueues = [queue];
26 + } else {
27 + interleavedQueues.push(queue);
28 + }
29 +}
30 +
31 +export function enqueueInterleavedUpdates() {
32 + // Transfer the interleaved updates onto the main queue. Each queue has a
33 + // `pending` field and an `interleaved` field. When they are not null, they
34 + // point to the last node in a circular linked list. We need to append the
35 + // interleaved list to the end of the pending list by joining them into a
36 + // single, circular list.
37 + if (interleavedQueues !== null) {
38 + for (let i = 0; i < interleavedQueues.length; i++) {
39 + const queue = interleavedQueues[i];
40 + const lastInterleavedUpdate = queue.interleaved;
41 + if (lastInterleavedUpdate !== null) {
42 + queue.interleaved = null;
43 + const firstInterleavedUpdate = lastInterleavedUpdate.next;
44 + const lastPendingUpdate = queue.pending;
45 + if (lastPendingUpdate !== null) {
46 + const firstPendingUpdate = lastPendingUpdate.next;
47 + lastPendingUpdate.next = (firstInterleavedUpdate: any);
48 + lastInterleavedUpdate.next = (firstPendingUpdate: any);
49 + }
50 + queue.pending = (lastInterleavedUpdate: any);
51 + }
52 + }
53 + interleavedQueues = null;
54 + }
55 +}
packages/react-reconciler/src/ReactFiberNewContext.new.js
+21 -6
@@ -11,6 +11,7 @@ import type {ReactContext} from 'shared/ReactTypes';
11 import type {Fiber, ContextDependency} from './ReactInternalTypes';
12 import type {StackCursor} from './ReactFiberStack.new';
13 import type {Lanes} from './ReactFiberLane.new';
14 +import type {SharedQueue} from './ReactUpdateQueue.new';
15
16 import {isPrimaryRenderer} from './ReactFiberHostConfig';
17 import {createCursor, push, pop} from './ReactFiberStack.new';
@@ -31,7 +32,7 @@ import {
32
33 import invariant from 'shared/invariant';
34 import is from 'shared/objectIs';
34 -import {createUpdate, enqueueUpdate, ForceUpdate} from './ReactUpdateQueue.new';
35 +import {createUpdate, ForceUpdate} from './ReactUpdateQueue.new';
36 import {markWorkInProgressReceivedUpdate} from './ReactFiberBeginWork.new';
37 import {enableSuspenseServerRenderer} from 'shared/ReactFeatureFlags';
38
@@ -211,16 +212,30 @@ export function propagateContextChange<T>(
212
213 if (fiber.tag === ClassComponent) {
214 // Schedule a force update on the work-in-progress.
214 - const update = createUpdate(
215 - NoTimestamp,
216 - pickArbitraryLane(renderLanes),
217 - );
215 + const lane = pickArbitraryLane(renderLanes);
216 + const update = createUpdate(NoTimestamp, lane);
217 update.tag = ForceUpdate;
218 // TODO: Because we don't have a work-in-progress, this will add the
219 // update to the current fiber, too, which means it will persist even if
220 // this render is thrown away. Since it's a race condition, not sure it's
221 // worth fixing.
223 - enqueueUpdate(fiber, update);
222 +
223 + // Inlined `enqueueUpdate` to remove interleaved update check
224 + const updateQueue = fiber.updateQueue;
225 + if (updateQueue === null) {
226 + // Only occurs if the fiber has been unmounted.
227 + } else {
228 + const sharedQueue: SharedQueue<any> = (updateQueue: any).shared;
229 + const pending = sharedQueue.pending;
230 + if (pending === null) {
231 + // This is the first update. Create a circular list.
232 + update.next = update;
233 + } else {
234 + update.next = pending.next;
235 + pending.next = update;
236 + }
237 + sharedQueue.pending = update;
238 + }
239 }
240 fiber.lanes = mergeLanes(fiber.lanes, renderLanes);
241 const alternate = fiber.alternate;
packages/react-reconciler/src/ReactFiberReconciler.new.js
+1 -1
@@ -314,7 +314,7 @@ export function updateContainer(
314 update.callback = callback;
315 }
316
317 - enqueueUpdate(current, update);
317 + enqueueUpdate(current, update, lane);
318 scheduleUpdateOnFiber(current, lane, eventTime);
319
320 return lane;
packages/react-reconciler/src/ReactFiberThrow.new.js
+1 -1
@@ -293,7 +293,7 @@ function throwException(
293 // prevent a bail out.
294 const update = createUpdate(NoTimestamp, SyncLane);
295 update.tag = ForceUpdate;
296 - enqueueUpdate(sourceFiber, update);
296 + enqueueUpdate(sourceFiber, update, SyncLane);
297 }
298 }
299
packages/react-reconciler/src/ReactFiberWorkLoop.new.js
+22 -2
@@ -204,6 +204,7 @@ import {
204 pop as popFromStack,
205 createCursor,
206 } from './ReactFiberStack.new';
207 +import {enqueueInterleavedUpdates} from './ReactFiberInterleavedUpdates.new';
208
209 import {
210 markNestedUpdateScheduled,
@@ -534,6 +535,7 @@ export function scheduleUpdateOnFiber(
535 }
536 }
537
538 + // TODO: Consolidate with `isInterleavedUpdate` check
539 if (root === workInProgressRoot) {
540 // Received an update to a tree that's in the middle of rendering. Mark
541 // that there was an interleaved update work on this root. Unless the
@@ -671,6 +673,22 @@ function markUpdateLaneFromFiberToRoot(
673 }
674 }
675
676 +export function isInterleavedUpdate(fiber: Fiber, lane: Lane) {
677 + return (
678 + // TODO: Optimize slightly by comparing to root that fiber belongs to.
679 + // Requires some refactoring. Not a big deal though since it's rare for
680 + // concurrent apps to have more than a single root.
681 + workInProgressRoot !== null &&
682 + (fiber.mode & BlockingMode) !== NoMode &&
683 + // If this is a render phase update (i.e. UNSAFE_componentWillReceiveProps),
684 + // then don't treat this as an interleaved update. This pattern is
685 + // accompanied by a warning but we haven't fully deprecated it yet. We can
686 + // remove once the deferRenderPhaseUpdateToNextBatch flag is enabled.
687 + (deferRenderPhaseUpdateToNextBatch ||
688 + (executionContext & RenderContext) === NoContext)
689 + );
690 +}
691 +
692 // Use this function to schedule a task for a root. There's only one task per
693 // root; if a task was already scheduled, we'll check to make sure the priority
694 // of the existing task is the same as the priority of the next level that the
@@ -1352,6 +1370,8 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes) {
1370 workInProgressRootUpdatedLanes = NoLanes;
1371 workInProgressRootPingedLanes = NoLanes;
1372
1373 + enqueueInterleavedUpdates();
1374 +
1375 if (enableSchedulerTracing) {
1376 spawnedWorkDuringRender = null;
1377 }
@@ -2282,7 +2302,7 @@ function captureCommitPhaseErrorOnRoot(
2302 ) {
2303 const errorInfo = createCapturedValue(error, sourceFiber);
2304 const update = createRootErrorUpdate(rootFiber, errorInfo, (SyncLane: Lane));
2285 - enqueueUpdate(rootFiber, update);
2305 + enqueueUpdate(rootFiber, update, (SyncLane: Lane));
2306 const eventTime = requestEventTime();
2307 const root = markUpdateLaneFromFiberToRoot(rootFiber, (SyncLane: Lane));
2308 if (root !== null) {
@@ -2319,7 +2339,7 @@ export function captureCommitPhaseError(sourceFiber: Fiber, error: mixed) {
2339 errorInfo,
2340 (SyncLane: Lane),
2341 );
2322 - enqueueUpdate(fiber, update);
2342 + enqueueUpdate(fiber, update, (SyncLane: Lane));
2343 const eventTime = requestEventTime();
2344 const root = markUpdateLaneFromFiberToRoot(fiber, (SyncLane: Lane));
2345 if (root !== null) {
packages/react-reconciler/src/ReactUpdateQueue.new.js
+48 -10
@@ -102,7 +102,11 @@ import {Callback, ShouldCapture, DidCapture} from './ReactFiberFlags';
102 import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags';
103
104 import {StrictMode} from './ReactTypeOfMode';
105 -import {markSkippedUpdateLanes} from './ReactFiberWorkLoop.new';
105 +import {
106 + markSkippedUpdateLanes,
107 + isInterleavedUpdate,
108 +} from './ReactFiberWorkLoop.new';
109 +import {pushInterleavedQueue} from './ReactFiberInterleavedUpdates.new';
110
111 import invariant from 'shared/invariant';
112
@@ -121,8 +125,9 @@ export type Update<State> = {|
125 next: Update<State> | null,
126 |};
127
124 -type SharedQueue<State> = {|
128 +export type SharedQueue<State> = {|
129 pending: Update<State> | null,
130 + interleaved: Update<State> | null,
131 |};
132
133 export type UpdateQueue<State> = {|
@@ -161,6 +166,7 @@ export function initializeUpdateQueue<State>(fiber: Fiber): void {
166 lastBaseUpdate: null,
167 shared: {
168 pending: null,
169 + interleaved: null,
170 },
171 effects: null,
172 };
@@ -200,7 +206,11 @@ export function createUpdate(eventTime: number, lane: Lane): Update<*> {
206 return update;
207 }
208
203 -export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
209 +export function enqueueUpdate<State>(
210 + fiber: Fiber,
211 + update: Update<State>,
212 + lane: Lane,
213 +) {
214 const updateQueue = fiber.updateQueue;
215 if (updateQueue === null) {
216 // Only occurs if the fiber has been unmounted.
@@ -208,15 +218,31 @@ export function enqueueUpdate<State>(fiber: Fiber, update: Update<State>) {
218 }
219
220 const sharedQueue: SharedQueue<State> = (updateQueue: any).shared;
211 - const pending = sharedQueue.pending;
212 - if (pending === null) {
213 - // This is the first update. Create a circular list.
214 - update.next = update;
221 +
222 + if (isInterleavedUpdate(fiber, lane)) {
223 + const interleaved = sharedQueue.interleaved;
224 + if (interleaved === null) {
225 + // This is the first update. Create a circular list.
226 + update.next = update;
227 + // At the end of the current render, this queue's interleaved updates will
228 + // be transfered to the pending queue.
229 + pushInterleavedQueue(sharedQueue);
230 + } else {
231 + update.next = interleaved.next;
232 + interleaved.next = update;
233 + }
234 + sharedQueue.interleaved = update;
235 } else {
216 - update.next = pending.next;
217 - pending.next = update;
236 + const pending = sharedQueue.pending;
237 + if (pending === null) {
238 + // This is the first update. Create a circular list.
239 + update.next = update;
240 + } else {
241 + update.next = pending.next;
242 + pending.next = update;
243 + }
244 + sharedQueue.pending = update;
245 }
219 - sharedQueue.pending = update;
246
247 if (__DEV__) {
248 if (
@@ -559,6 +585,18 @@ export function processUpdateQueue<State>(
585 queue.firstBaseUpdate = newFirstBaseUpdate;
586 queue.lastBaseUpdate = newLastBaseUpdate;
587
588 + // Interleaved updates are stored on a separate queue. We aren't going to
589 + // process them during this render, but we do need to track which lanes
590 + // are remaining.
591 + const lastInterleaved = queue.shared.interleaved;
592 + if (lastInterleaved !== null) {
593 + let interleaved = lastInterleaved;
594 + do {
595 + newLanes = mergeLanes(newLanes, interleaved.lane);
596 + interleaved = ((interleaved: any).next: Update<State>);
597 + } while (interleaved !== lastInterleaved);
598 + }
599 +
600 // Set the remaining expiration time to be whatever is remaining in the queue.
601 // This should be fine because the only two other things that contribute to
602 // expiration time are props and context. We're already in the middle of the
packages/react-reconciler/src/__tests__/ReactInterleavedUpdates-test.js new
+129
@@ -0,0 +1,129 @@
1 +let React;
2 +let ReactNoop;
3 +let Scheduler;
4 +let startTransition;
5 +let useState;
6 +let useEffect;
7 +
8 +describe('ReactInterleavedUpdates', () => {
9 + beforeEach(() => {
10 + jest.resetModules();
11 +
12 + React = require('react');
13 + ReactNoop = require('react-noop-renderer');
14 + Scheduler = require('scheduler');
15 + startTransition = React.unstable_startTransition;
16 + useState = React.useState;
17 + useEffect = React.useEffect;
18 + });
19 +
20 + function Text({text}) {
21 + Scheduler.unstable_yieldValue(text);
22 + return text;
23 + }
24 +
25 + test('update during an interleaved event is not processed during the current render', async () => {
26 + const updaters = [];
27 +
28 + function Child() {
29 + const [state, setState] = useState(0);
30 + useEffect(() => {
31 + updaters.push(setState);
32 + }, []);
33 + return <Text text={state} />;
34 + }
35 +
36 + function updateChildren(value) {
37 + for (let i = 0; i < updaters.length; i++) {
38 + const setState = updaters[i];
39 + setState(value);
40 + }
41 + }
42 +
43 + const root = ReactNoop.createRoot();
44 +
45 + await ReactNoop.act(async () => {
46 + root.render(
47 + <>
48 + <Child />
49 + <Child />
50 + <Child />
51 + </>,
52 + );
53 + });
54 + expect(Scheduler).toHaveYielded([0, 0, 0]);
55 + expect(root).toMatchRenderedOutput('000');
56 +
57 + await ReactNoop.act(async () => {
58 + updateChildren(1);
59 + // Partially render the children. Only the first one.
60 + expect(Scheduler).toFlushAndYieldThrough([1]);
61 +
62 + // In an interleaved event, schedule an update on each of the children.
63 + // Including the two that haven't rendered yet.
64 + updateChildren(2);
65 +
66 + // We should continue rendering without including the interleaved updates.
67 + expect(Scheduler).toFlushUntilNextPaint([1, 1]);
68 + expect(root).toMatchRenderedOutput('111');
69 + });
70 + // The interleaved updates flush in a separate render.
71 + expect(Scheduler).toHaveYielded([2, 2, 2]);
72 + expect(root).toMatchRenderedOutput('222');
73 + });
74 +
75 + // @gate experimental
76 + test('low priority update during an interleaved event is not processed during the current render', async () => {
77 + // Same as previous test, but the interleaved update is lower priority than
78 + // the in-progress render.
79 + const updaters = [];
80 +
81 + function Child() {
82 + const [state, setState] = useState(0);
83 + useEffect(() => {
84 + updaters.push(setState);
85 + }, []);
86 + return <Text text={state} />;
87 + }
88 +
89 + function updateChildren(value) {
90 + for (let i = 0; i < updaters.length; i++) {
91 + const setState = updaters[i];
92 + setState(value);
93 + }
94 + }
95 +
96 + const root = ReactNoop.createRoot();
97 +
98 + await ReactNoop.act(async () => {
99 + root.render(
100 + <>
101 + <Child />
102 + <Child />
103 + <Child />
104 + </>,
105 + );
106 + });
107 + expect(Scheduler).toHaveYielded([0, 0, 0]);
108 + expect(root).toMatchRenderedOutput('000');
109 +
110 + await ReactNoop.act(async () => {
111 + updateChildren(1);
112 + // Partially render the children. Only the first one.
113 + expect(Scheduler).toFlushAndYieldThrough([1]);
114 +
115 + // In an interleaved event, schedule an update on each of the children.
116 + // Including the two that haven't rendered yet.
117 + startTransition(() => {
118 + updateChildren(2);
119 + });
120 +
121 + // We should continue rendering without including the interleaved updates.
122 + expect(Scheduler).toFlushUntilNextPaint([1, 1]);
123 + expect(root).toMatchRenderedOutput('111');
124 + });
125 + // The interleaved updates flush in a separate render.
126 + expect(Scheduler).toHaveYielded([2, 2, 2]);
127 + expect(root).toMatchRenderedOutput('222');
128 + });
129 +});