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
});