@samitouri / QOS-React-2 / commits / 5e4e2dae0b

Defer setState callbacks until component is visible (#24872)

A class component `setState` callback should not fire if a component is inside a hidden Offscreen tree. Instead, it should wait until the next time the component is made visible.

Andrew Clark committed Jul 8, 2022 at 11:51 UTC 5e4e2dae0ba1836d26fa4e5edb4475d3b3e0a60c
5 files changed +254 -78
packages/react-reconciler/src/ReactFiberClassUpdateQueue.new.js
+71 -32
@@ -102,7 +102,12 @@ import {
102 enterDisallowedContextReadInDEV,
103 exitDisallowedContextReadInDEV,
104 } from './ReactFiberNewContext.new';
105 -import {Callback, ShouldCapture, DidCapture} from './ReactFiberFlags';
105 +import {
106 + Callback,
107 + Visibility,
108 + ShouldCapture,
109 + DidCapture,
110 +} from './ReactFiberFlags';
111
112 import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags';
113
@@ -136,6 +141,7 @@ export type Update<State> = {|
141 export type SharedQueue<State> = {|
142 pending: Update<State> | null,
143 lanes: Lanes,
144 + hiddenCallbacks: Array<() => mixed> | null,
145 |};
146
147 export type UpdateQueue<State> = {|
@@ -143,7 +149,7 @@ export type UpdateQueue<State> = {|
149 firstBaseUpdate: Update<State> | null,
150 lastBaseUpdate: Update<State> | null,
151 shared: SharedQueue<State>,
146 - effects: Array<Update<State>> | null,
152 + callbacks: Array<() => mixed> | null,
153 |};
154
155 export const UpdateState = 0;
@@ -175,8 +181,9 @@ export function initializeUpdateQueue<State>(fiber: Fiber): void {
181 shared: {
182 pending: null,
183 lanes: NoLanes,
184 + hiddenCallbacks: null,
185 },
179 - effects: null,
186 + callbacks: null,
187 };
188 fiber.updateQueue = queue;
189 }
@@ -194,7 +201,7 @@ export function cloneUpdateQueue<State>(
201 firstBaseUpdate: currentQueue.firstBaseUpdate,
202 lastBaseUpdate: currentQueue.lastBaseUpdate,
203 shared: currentQueue.shared,
197 - effects: currentQueue.effects,
204 + callbacks: null,
205 };
206 workInProgress.updateQueue = clone;
207 }
@@ -326,7 +333,9 @@ export function enqueueCapturedUpdate<State>(
333
334 tag: update.tag,
335 payload: update.payload,
329 - callback: update.callback,
336 + // When this update is rebased, we should not fire its
337 + // callback again.
338 + callback: null,
339
340 next: null,
341 };
@@ -355,7 +364,7 @@ export function enqueueCapturedUpdate<State>(
364 firstBaseUpdate: newFirst,
365 lastBaseUpdate: newLast,
366 shared: currentQueue.shared,
358 - effects: currentQueue.effects,
367 + callbacks: currentQueue.callbacks,
368 };
369 workInProgress.updateQueue = queue;
370 return;
@@ -577,7 +586,10 @@ export function processUpdateQueue<State>(
586
587 tag: update.tag,
588 payload: update.payload,
580 - callback: update.callback,
589 +
590 + // When this update is rebased, we should not fire its
591 + // callback again.
592 + callback: null,
593
594 next: null,
595 };
@@ -594,18 +606,16 @@ export function processUpdateQueue<State>(
606 instance,
607 );
608 const callback = update.callback;
597 - if (
598 - callback !== null &&
599 - // If the update was already committed, we should not queue its
600 - // callback again.
601 - update.lane !== NoLane
602 - ) {
609 + if (callback !== null) {
610 workInProgress.flags |= Callback;
604 - const effects = queue.effects;
605 - if (effects === null) {
606 - queue.effects = [update];
611 + if (isHiddenUpdate) {
612 + workInProgress.flags |= Visibility;
613 + }
614 + const callbacks = queue.callbacks;
615 + if (callbacks === null) {
616 + queue.callbacks = [callback];
617 } else {
608 - effects.push(update);
618 + callbacks.push(callback);
619 }
620 }
621 }
@@ -679,22 +689,51 @@ export function checkHasForceUpdateAfterProcessing(): boolean {
689 return hasForceUpdate;
690 }
691
682 -export function commitUpdateQueue<State>(
683 - finishedWork: Fiber,
684 - finishedQueue: UpdateQueue<State>,
685 - instance: any,
692 +export function deferHiddenCallbacks<State>(
693 + updateQueue: UpdateQueue<State>,
694 ): void {
687 - // Commit the effects
688 - const effects = finishedQueue.effects;
689 - finishedQueue.effects = null;
690 - if (effects !== null) {
691 - for (let i = 0; i < effects.length; i++) {
692 - const effect = effects[i];
693 - const callback = effect.callback;
694 - if (callback !== null) {
695 - effect.callback = null;
696 - callCallback(callback, instance);
697 - }
695 + // When an update finishes on a hidden component, its callback should not
696 + // be fired until/unless the component is made visible again. Stash the
697 + // callback on the shared queue object so it can be fired later.
698 + const newHiddenCallbacks = updateQueue.callbacks;
699 + if (newHiddenCallbacks !== null) {
700 + const existingHiddenCallbacks = updateQueue.shared.hiddenCallbacks;
701 + if (existingHiddenCallbacks === null) {
702 + updateQueue.shared.hiddenCallbacks = newHiddenCallbacks;
703 + } else {
704 + updateQueue.shared.hiddenCallbacks = existingHiddenCallbacks.concat(
705 + newHiddenCallbacks,
706 + );
707 + }
708 + }
709 +}
710 +
711 +export function commitHiddenCallbacks<State>(
712 + updateQueue: UpdateQueue<State>,
713 + context: any,
714 +): void {
715 + // This component is switching from hidden -> visible. Commit any callbacks
716 + // that were previously deferred.
717 + const hiddenCallbacks = updateQueue.shared.hiddenCallbacks;
718 + if (hiddenCallbacks !== null) {
719 + updateQueue.shared.hiddenCallbacks = null;
720 + for (let i = 0; i < hiddenCallbacks.length; i++) {
721 + const callback = hiddenCallbacks[i];
722 + callCallback(callback, context);
723 + }
724 + }
725 +}
726 +
727 +export function commitCallbacks<State>(
728 + updateQueue: UpdateQueue<State>,
729 + context: any,
730 +): void {
731 + const callbacks = updateQueue.callbacks;
732 + if (callbacks !== null) {
733 + updateQueue.callbacks = null;
734 + for (let i = 0; i < callbacks.length; i++) {
735 + const callback = callbacks[i];
736 + callCallback(callback, context);
737 }
738 }
739 }
packages/react-reconciler/src/ReactFiberClassUpdateQueue.old.js
+71 -32
@@ -102,7 +102,12 @@ import {
102 enterDisallowedContextReadInDEV,
103 exitDisallowedContextReadInDEV,
104 } from './ReactFiberNewContext.old';
105 -import {Callback, ShouldCapture, DidCapture} from './ReactFiberFlags';
105 +import {
106 + Callback,
107 + Visibility,
108 + ShouldCapture,
109 + DidCapture,
110 +} from './ReactFiberFlags';
111
112 import {debugRenderPhaseSideEffectsForStrictMode} from 'shared/ReactFeatureFlags';
113
@@ -136,6 +141,7 @@ export type Update<State> = {|
141 export type SharedQueue<State> = {|
142 pending: Update<State> | null,
143 lanes: Lanes,
144 + hiddenCallbacks: Array<() => mixed> | null,
145 |};
146
147 export type UpdateQueue<State> = {|
@@ -143,7 +149,7 @@ export type UpdateQueue<State> = {|
149 firstBaseUpdate: Update<State> | null,
150 lastBaseUpdate: Update<State> | null,
151 shared: SharedQueue<State>,
146 - effects: Array<Update<State>> | null,
152 + callbacks: Array<() => mixed> | null,
153 |};
154
155 export const UpdateState = 0;
@@ -175,8 +181,9 @@ export function initializeUpdateQueue<State>(fiber: Fiber): void {
181 shared: {
182 pending: null,
183 lanes: NoLanes,
184 + hiddenCallbacks: null,
185 },
179 - effects: null,
186 + callbacks: null,
187 };
188 fiber.updateQueue = queue;
189 }
@@ -194,7 +201,7 @@ export function cloneUpdateQueue<State>(
201 firstBaseUpdate: currentQueue.firstBaseUpdate,
202 lastBaseUpdate: currentQueue.lastBaseUpdate,
203 shared: currentQueue.shared,
197 - effects: currentQueue.effects,
204 + callbacks: null,
205 };
206 workInProgress.updateQueue = clone;
207 }
@@ -326,7 +333,9 @@ export function enqueueCapturedUpdate<State>(
333
334 tag: update.tag,
335 payload: update.payload,
329 - callback: update.callback,
336 + // When this update is rebased, we should not fire its
337 + // callback again.
338 + callback: null,
339
340 next: null,
341 };
@@ -355,7 +364,7 @@ export function enqueueCapturedUpdate<State>(
364 firstBaseUpdate: newFirst,
365 lastBaseUpdate: newLast,
366 shared: currentQueue.shared,
358 - effects: currentQueue.effects,
367 + callbacks: currentQueue.callbacks,
368 };
369 workInProgress.updateQueue = queue;
370 return;
@@ -577,7 +586,10 @@ export function processUpdateQueue<State>(
586
587 tag: update.tag,
588 payload: update.payload,
580 - callback: update.callback,
589 +
590 + // When this update is rebased, we should not fire its
591 + // callback again.
592 + callback: null,
593
594 next: null,
595 };
@@ -594,18 +606,16 @@ export function processUpdateQueue<State>(
606 instance,
607 );
608 const callback = update.callback;
597 - if (
598 - callback !== null &&
599 - // If the update was already committed, we should not queue its
600 - // callback again.
601 - update.lane !== NoLane
602 - ) {
609 + if (callback !== null) {
610 workInProgress.flags |= Callback;
604 - const effects = queue.effects;
605 - if (effects === null) {
606 - queue.effects = [update];
611 + if (isHiddenUpdate) {
612 + workInProgress.flags |= Visibility;
613 + }
614 + const callbacks = queue.callbacks;
615 + if (callbacks === null) {
616 + queue.callbacks = [callback];
617 } else {
608 - effects.push(update);
618 + callbacks.push(callback);
619 }
620 }
621 }
@@ -679,22 +689,51 @@ export function checkHasForceUpdateAfterProcessing(): boolean {
689 return hasForceUpdate;
690 }
691
682 -export function commitUpdateQueue<State>(
683 - finishedWork: Fiber,
684 - finishedQueue: UpdateQueue<State>,
685 - instance: any,
692 +export function deferHiddenCallbacks<State>(
693 + updateQueue: UpdateQueue<State>,
694 ): void {
687 - // Commit the effects
688 - const effects = finishedQueue.effects;
689 - finishedQueue.effects = null;
690 - if (effects !== null) {
691 - for (let i = 0; i < effects.length; i++) {
692 - const effect = effects[i];
693 - const callback = effect.callback;
694 - if (callback !== null) {
695 - effect.callback = null;
696 - callCallback(callback, instance);
697 - }
695 + // When an update finishes on a hidden component, its callback should not
696 + // be fired until/unless the component is made visible again. Stash the
697 + // callback on the shared queue object so it can be fired later.
698 + const newHiddenCallbacks = updateQueue.callbacks;
699 + if (newHiddenCallbacks !== null) {
700 + const existingHiddenCallbacks = updateQueue.shared.hiddenCallbacks;
701 + if (existingHiddenCallbacks === null) {
702 + updateQueue.shared.hiddenCallbacks = newHiddenCallbacks;
703 + } else {
704 + updateQueue.shared.hiddenCallbacks = existingHiddenCallbacks.concat(
705 + newHiddenCallbacks,
706 + );
707 + }
708 + }
709 +}
710 +
711 +export function commitHiddenCallbacks<State>(
712 + updateQueue: UpdateQueue<State>,
713 + context: any,
714 +): void {
715 + // This component is switching from hidden -> visible. Commit any callbacks
716 + // that were previously deferred.
717 + const hiddenCallbacks = updateQueue.shared.hiddenCallbacks;
718 + if (hiddenCallbacks !== null) {
719 + updateQueue.shared.hiddenCallbacks = null;
720 + for (let i = 0; i < hiddenCallbacks.length; i++) {
721 + const callback = hiddenCallbacks[i];
722 + callCallback(callback, context);
723 + }
724 + }
725 +}
726 +
727 +export function commitCallbacks<State>(
728 + updateQueue: UpdateQueue<State>,
729 + context: any,
730 +): void {
731 + const callbacks = updateQueue.callbacks;
732 + if (callbacks !== null) {
733 + updateQueue.callbacks = null;
734 + for (let i = 0; i < callbacks.length; i++) {
735 + const callback = callbacks[i];
736 + callCallback(callback, context);
737 }
738 }
739 }
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+28 -7
@@ -75,6 +75,7 @@ import {
75 ChildDeletion,
76 Snapshot,
77 Update,
78 + Callback,
79 Ref,
80 Hydrating,
81 Passive,
@@ -100,7 +101,11 @@ import {
101 startPassiveEffectTimer,
102 } from './ReactProfilerTimer.new';
103 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
103 -import {commitUpdateQueue} from './ReactFiberClassUpdateQueue.new';
104 +import {
105 + deferHiddenCallbacks,
106 + commitHiddenCallbacks,
107 + commitCallbacks,
108 +} from './ReactFiberClassUpdateQueue.new';
109 import {
110 getPublicInstance,
111 supportsMutation,
@@ -854,7 +859,7 @@ function commitLayoutEffectOnFiber(
859 const updateQueue: UpdateQueue<
860 *,
861 > | null = (finishedWork.updateQueue: any);
857 - if (updateQueue !== null) {
862 + if (finishedWork.flags & Callback && updateQueue !== null) {
863 if (__DEV__) {
864 if (
865 finishedWork.type === finishedWork.elementType &&
@@ -885,7 +890,7 @@ function commitLayoutEffectOnFiber(
890 // We could update instance props and state here,
891 // but instead we rely on them being set during last render.
892 // TODO: revisit this when we implement resuming.
888 - commitUpdateQueue(finishedWork, updateQueue, instance);
893 + commitCallbacks(updateQueue, instance);
894 }
895 break;
896 }
@@ -895,7 +900,7 @@ function commitLayoutEffectOnFiber(
900 const updateQueue: UpdateQueue<
901 *,
902 > | null = (finishedWork.updateQueue: any);
898 - if (updateQueue !== null) {
903 + if (finishedWork.flags & Callback && updateQueue !== null) {
904 let instance = null;
905 if (finishedWork.child !== null) {
906 switch (finishedWork.child.tag) {
@@ -907,7 +912,7 @@ function commitLayoutEffectOnFiber(
912 break;
913 }
914 }
910 - commitUpdateQueue(finishedWork, updateQueue, instance);
915 + commitCallbacks(updateQueue, instance);
916 }
917 break;
918 }
@@ -1059,6 +1064,10 @@ function reappearLayoutEffectsOnFiber(node: Fiber) {
1064 safelyCallComponentDidMount(node, node.return, instance);
1065 }
1066 safelyAttachRef(node, node.return);
1067 + const updateQueue: UpdateQueue<*> | null = (node.updateQueue: any);
1068 + if (updateQueue !== null) {
1069 + commitHiddenCallbacks(updateQueue, instance);
1070 + }
1071 break;
1072 }
1073 case HostComponent: {
@@ -2155,6 +2164,15 @@ function commitMutationEffectsOnFiber(
2164 safelyDetachRef(current, current.return);
2165 }
2166 }
2167 +
2168 + if (flags & Callback && offscreenSubtreeIsHidden) {
2169 + const updateQueue: UpdateQueue<
2170 + *,
2171 + > | null = (finishedWork.updateQueue: any);
2172 + if (updateQueue !== null) {
2173 + deferHiddenCallbacks(updateQueue);
2174 + }
2175 + }
2176 return;
2177 }
2178 case HostComponent: {
@@ -2341,16 +2359,21 @@ function commitMutationEffectsOnFiber(
2359 return;
2360 }
2361 case OffscreenComponent: {
2362 + const newState: OffscreenState | null = finishedWork.memoizedState;
2363 + const isHidden = newState !== null;
2364 const wasHidden = current !== null && current.memoizedState !== null;
2365
2366 if (finishedWork.mode & ConcurrentMode) {
2367 // Before committing the children, track on the stack whether this
2368 // offscreen subtree was already hidden, so that we don't unmount the
2369 // effects again.
2370 + const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2371 const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2372 + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden || isHidden;
2373 offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
2374 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2375 offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2376 + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2377 } else {
2378 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2379 }
@@ -2359,8 +2382,6 @@ function commitMutationEffectsOnFiber(
2382
2383 if (flags & Visibility) {
2384 const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
2362 - const newState: OffscreenState | null = finishedWork.memoizedState;
2363 - const isHidden = newState !== null;
2385 const offscreenBoundary: Fiber = finishedWork;
2386
2387 // Track the current state on the Offscreen instance so we can
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+28 -7
@@ -75,6 +75,7 @@ import {
75 ChildDeletion,
76 Snapshot,
77 Update,
78 + Callback,
79 Ref,
80 Hydrating,
81 Passive,
@@ -100,7 +101,11 @@ import {
101 startPassiveEffectTimer,
102 } from './ReactProfilerTimer.old';
103 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
103 -import {commitUpdateQueue} from './ReactFiberClassUpdateQueue.old';
104 +import {
105 + deferHiddenCallbacks,
106 + commitHiddenCallbacks,
107 + commitCallbacks,
108 +} from './ReactFiberClassUpdateQueue.old';
109 import {
110 getPublicInstance,
111 supportsMutation,
@@ -854,7 +859,7 @@ function commitLayoutEffectOnFiber(
859 const updateQueue: UpdateQueue<
860 *,
861 > | null = (finishedWork.updateQueue: any);
857 - if (updateQueue !== null) {
862 + if (finishedWork.flags & Callback && updateQueue !== null) {
863 if (__DEV__) {
864 if (
865 finishedWork.type === finishedWork.elementType &&
@@ -885,7 +890,7 @@ function commitLayoutEffectOnFiber(
890 // We could update instance props and state here,
891 // but instead we rely on them being set during last render.
892 // TODO: revisit this when we implement resuming.
888 - commitUpdateQueue(finishedWork, updateQueue, instance);
893 + commitCallbacks(updateQueue, instance);
894 }
895 break;
896 }
@@ -895,7 +900,7 @@ function commitLayoutEffectOnFiber(
900 const updateQueue: UpdateQueue<
901 *,
902 > | null = (finishedWork.updateQueue: any);
898 - if (updateQueue !== null) {
903 + if (finishedWork.flags & Callback && updateQueue !== null) {
904 let instance = null;
905 if (finishedWork.child !== null) {
906 switch (finishedWork.child.tag) {
@@ -907,7 +912,7 @@ function commitLayoutEffectOnFiber(
912 break;
913 }
914 }
910 - commitUpdateQueue(finishedWork, updateQueue, instance);
915 + commitCallbacks(updateQueue, instance);
916 }
917 break;
918 }
@@ -1059,6 +1064,10 @@ function reappearLayoutEffectsOnFiber(node: Fiber) {
1064 safelyCallComponentDidMount(node, node.return, instance);
1065 }
1066 safelyAttachRef(node, node.return);
1067 + const updateQueue: UpdateQueue<*> | null = (node.updateQueue: any);
1068 + if (updateQueue !== null) {
1069 + commitHiddenCallbacks(updateQueue, instance);
1070 + }
1071 break;
1072 }
1073 case HostComponent: {
@@ -2130,6 +2139,15 @@ function commitMutationEffectsOnFiber(
2139 safelyDetachRef(current, current.return);
2140 }
2141 }
2142 +
2143 + if (flags & Callback && offscreenSubtreeIsHidden) {
2144 + const updateQueue: UpdateQueue<
2145 + *,
2146 + > | null = (finishedWork.updateQueue: any);
2147 + if (updateQueue !== null) {
2148 + deferHiddenCallbacks(updateQueue);
2149 + }
2150 + }
2151 return;
2152 }
2153 case HostComponent: {
@@ -2312,16 +2330,21 @@ function commitMutationEffectsOnFiber(
2330 return;
2331 }
2332 case OffscreenComponent: {
2333 + const newState: OffscreenState | null = finishedWork.memoizedState;
2334 + const isHidden = newState !== null;
2335 const wasHidden = current !== null && current.memoizedState !== null;
2336
2337 if (finishedWork.mode & ConcurrentMode) {
2338 // Before committing the children, track on the stack whether this
2339 // offscreen subtree was already hidden, so that we don't unmount the
2340 // effects again.
2341 + const prevOffscreenSubtreeIsHidden = offscreenSubtreeIsHidden;
2342 const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden;
2343 + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden || isHidden;
2344 offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden;
2345 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2346 offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden;
2347 + offscreenSubtreeIsHidden = prevOffscreenSubtreeIsHidden;
2348 } else {
2349 recursivelyTraverseMutationEffects(root, finishedWork, lanes);
2350 }
@@ -2330,8 +2353,6 @@ function commitMutationEffectsOnFiber(
2353
2354 if (flags & Visibility) {
2355 const offscreenInstance: OffscreenInstance = finishedWork.stateNode;
2333 - const newState: OffscreenState | null = finishedWork.memoizedState;
2334 - const isHidden = newState !== null;
2356 const offscreenBoundary: Fiber = finishedWork;
2357
2358 // Track the current state on the Offscreen instance so we can
packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
+56
@@ -640,4 +640,60 @@ describe('ReactOffscreen', () => {
640 });
641 expect(root).toMatchRenderedOutput(null);
642 });
643 +
644 + // @gate enableOffscreen
645 + it('class component setState callbacks do not fire until tree is visible', async () => {
646 + const root = ReactNoop.createRoot();
647 +
648 + let child;
649 + class Child extends React.Component {
650 + state = {text: 'A'};
651 + render() {
652 + child = this;
653 + return <Text text={this.state.text} />;
654 + }
655 + }
656 +
657 + // Initial render
658 + await act(async () => {
659 + root.render(
660 + <Offscreen mode="hidden">
661 + <Child />
662 + </Offscreen>,
663 + );
664 + });
665 + expect(Scheduler).toHaveYielded(['A']);
666 + expect(root).toMatchRenderedOutput(<span hidden={true} prop="A" />);
667 +
668 + // Schedule an update to a hidden class component. The update will finish
669 + // rendering in the background, but the callback shouldn't fire yet, because
670 + // the component isn't visible.
671 + await act(async () => {
672 + child.setState({text: 'B'}, () => {
673 + Scheduler.unstable_yieldValue('B update finished');
674 + });
675 + });
676 + expect(Scheduler).toHaveYielded(['B']);
677 + expect(root).toMatchRenderedOutput(<span hidden={true} prop="B" />);
678 +
679 + // Now reveal the hidden component. Simultaneously, schedule another
680 + // update with a callback to the same component. When the component is
681 + // revealed, both the B callback and C callback should fire, in that order.
682 + await act(async () => {
683 + root.render(
684 + <Offscreen mode="visible">
685 + <Child />
686 + </Offscreen>,
687 + );
688 + child.setState({text: 'C'}, () => {
689 + Scheduler.unstable_yieldValue('C update finished');
690 + });
691 + });
692 + expect(Scheduler).toHaveYielded([
693 + 'C',
694 + 'B update finished',
695 + 'C update finished',
696 + ]);
697 + expect(root).toMatchRenderedOutput(<span prop="C" />);
698 + });
699 });