@samitouri / QOS-React-1 / commits / 62d3f36ea7

[Fiber] Trigger default transition indicator if needed (#33160)

Stacked on #33159. This implements `onDefaultTransitionIndicator`. The sequence is: 1) In `markRootUpdated` we schedule Transition updates as needing `indicatorLanes` on the root. This tracks the lanes that currently need an indicator to either start or remain going until this lane commits. 2) Track mutations during any commit. We use the same hook that view transitions use here but instead of tracking it just per view transition scope, we also track a global boolean for the whole root. 3) If a sync/default commit had any mutations, then we clear the indicator lane for the `currentEventTransitionLane`. This requires that the lane is still active while we do these commits. See #33159. In other words, a sync update gets associated with the current transition and it is assumed to be rendering the loading state for that corresponding transition so we don't need a default indicator for this lane. 4) At the end of `processRootScheduleInMicrotask`, right before we're about to enter a new "event transition lane" scope, it is no longer possible to render any more loading states for the current transition lane. That's when we invoke `onDefaultTransitionIndicator` for any roots that have new indicator lanes. 5) When we commit, we remove the finished lanes from `indicatorLanes` and once that reaches zero again, then we can clean up the default indicator. This approach means that you can start multiple different transitions while an indicator is still going but it won't stop/restart each time. Instead, it'll wait until all are done before stopping. Follow ups: - [x] Default updates are currently not enough to cancel because those aren't flush in the same microtask. That's unfortunate. #33186 - [x] Handle async actions before the setState. Since these don't necessarily have a root this is tricky. #33190 - [x] Disable for `useDeferredValue`. ~Since it also goes through `markRootUpdated` and schedules a Transition lane it'll get a default indicator even though it probably shouldn't have one.~ EDIT: Turns out this just works because it doesn't go through `markRootUpdated` when work is left behind. - [x] Implement built-in DOM version by default. #33162

Sebastian Markbåge committed May 13, 2025 at 15:45 UTC 62d3f36ea79fc0a10b514d4bbcc4ba3f21b3206e
9 files changed +490 -6
packages/react-noop-renderer/src/createReactNoop.js
+1 -3
@@ -1142,9 +1142,7 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
1142 // TODO: Turn this on once tests are fixed
1143 // console.error(error);
1144 }
1145 - function onDefaultTransitionIndicator(): void | (() => void) {
1146 - // TODO: Allow this as an option.
1147 - }
1145 + function onDefaultTransitionIndicator(): void | (() => void) {}
1146
1147 let idCounter = 0;
1148
packages/react-reconciler/src/ReactFiberCommitWork.js
+18
@@ -20,6 +20,7 @@ import type {
20 import type {Fiber, FiberRoot} from './ReactInternalTypes';
21 import type {Lanes} from './ReactFiberLane';
22 import {
23 + includesLoadingIndicatorLanes,
24 includesOnlySuspenseyCommitEligibleLanes,
25 includesOnlyViewTransitionEligibleLanes,
26 } from './ReactFiberLane';
@@ -60,6 +61,7 @@ import {
61 enableViewTransition,
62 enableFragmentRefs,
63 enableEagerAlternateStateNodeCleanup,
64 + enableDefaultTransitionIndicator,
65 } from 'shared/ReactFeatureFlags';
66 import {
67 FunctionComponent,
@@ -268,13 +270,16 @@ import {
270 } from './ReactFiberCommitViewTransitions';
271 import {
272 viewTransitionMutationContext,
273 + pushRootMutationContext,
274 pushMutationContext,
275 popMutationContext,
276 + rootMutationContext,
277 } from './ReactFiberMutationTracking';
278 import {
279 trackNamedViewTransition,
280 untrackNamedViewTransition,
281 } from './ReactFiberDuplicateViewTransitions';
282 +import {markIndicatorHandled} from './ReactFiberRootScheduler';
283
284 // Used during the commit phase to track the state of the Offscreen component stack.
285 // Allows us to avoid traversing the return path to find the nearest Offscreen ancestor.
@@ -2216,6 +2221,7 @@ function commitMutationEffectsOnFiber(
2221 case HostRoot: {
2222 const prevProfilerEffectDuration = pushNestedEffectDurations();
2223
2224 + pushRootMutationContext();
2225 if (supportsResources) {
2226 prepareToCommitHoistables();
2227
@@ -2265,6 +2271,18 @@ function commitMutationEffectsOnFiber(
2271 );
2272 }
2273
2274 + popMutationContext(false);
2275 +
2276 + if (
2277 + enableDefaultTransitionIndicator &&
2278 + rootMutationContext &&
2279 + includesLoadingIndicatorLanes(lanes)
2280 + ) {
2281 + // This root had a mutation. Mark this root as having rendered a manual
2282 + // loading state.
2283 + markIndicatorHandled(root);
2284 + }
2285 +
2286 break;
2287 }
2288 case HostPortal: {
packages/react-reconciler/src/ReactFiberLane.js
+13
@@ -27,6 +27,7 @@ import {
27 transitionLaneExpirationMs,
28 retryLaneExpirationMs,
29 disableLegacyMode,
30 + enableDefaultTransitionIndicator,
31 } from 'shared/ReactFeatureFlags';
32 import {isDevToolsPresent} from './ReactFiberDevToolsHook';
33 import {clz32} from './clz32';
@@ -640,6 +641,10 @@ export function includesOnlySuspenseyCommitEligibleLanes(
641 );
642 }
643
644 +export function includesLoadingIndicatorLanes(lanes: Lanes): boolean {
645 + return (lanes & (SyncLane | DefaultLane)) !== NoLanes;
646 +}
647 +
648 export function includesBlockingLane(lanes: Lanes): boolean {
649 const SyncDefaultLanes =
650 InputContinuousHydrationLane |
@@ -766,6 +771,10 @@ export function createLaneMap<T>(initial: T): LaneMap<T> {
771
772 export function markRootUpdated(root: FiberRoot, updateLane: Lane) {
773 root.pendingLanes |= updateLane;
774 + if (enableDefaultTransitionIndicator) {
775 + // Mark that this lane might need a loading indicator to be shown.
776 + root.indicatorLanes |= updateLane & TransitionLanes;
777 + }
778
779 // If there are any suspended transitions, it's possible this new update
780 // could unblock them. Clear the suspended lanes so that we can try rendering
@@ -847,6 +856,10 @@ export function markRootFinished(
856 root.pingedLanes = NoLanes;
857 root.warmLanes = NoLanes;
858
859 + if (enableDefaultTransitionIndicator) {
860 + root.indicatorLanes &= remainingLanes;
861 + }
862 +
863 root.expiredLanes &= remainingLanes;
864
865 root.entangledLanes &= remainingLanes;
packages/react-reconciler/src/ReactFiberMutationTracking.js
+23 -1
@@ -7,10 +7,23 @@
7 * @flow
8 */
9
10 -import {enableViewTransition} from 'shared/ReactFeatureFlags';
10 +import {
11 + enableDefaultTransitionIndicator,
12 + enableViewTransition,
13 +} from 'shared/ReactFeatureFlags';
14
15 +export let rootMutationContext: boolean = false;
16 export let viewTransitionMutationContext: boolean = false;
17
18 +export function pushRootMutationContext(): void {
19 + if (enableDefaultTransitionIndicator) {
20 + rootMutationContext = false;
21 + }
22 + if (enableViewTransition) {
23 + viewTransitionMutationContext = false;
24 + }
25 +}
26 +
27 export function pushMutationContext(): boolean {
28 if (!enableViewTransition) {
29 return false;
@@ -22,12 +35,21 @@ export function pushMutationContext(): boolean {
35
36 export function popMutationContext(prev: boolean): void {
37 if (enableViewTransition) {
38 + if (viewTransitionMutationContext) {
39 + rootMutationContext = true;
40 + }
41 viewTransitionMutationContext = prev;
42 }
43 }
44
45 export function trackHostMutation(): void {
46 + // This is extremely hot function that must be inlined. Don't add more stuff.
47 if (enableViewTransition) {
48 viewTransitionMutationContext = true;
49 + } else if (enableDefaultTransitionIndicator) {
50 + // We only set this if enableViewTransition is not on. Otherwise we track
51 + // it on the viewTransitionMutationContext and collect it when we pop
52 + // to avoid more than a single operation in this hot path.
53 + rootMutationContext = true;
54 }
55 }
packages/react-reconciler/src/ReactFiberRoot.js
+4
@@ -79,6 +79,9 @@ function FiberRootNode(
79 this.pingedLanes = NoLanes;
80 this.warmLanes = NoLanes;
81 this.expiredLanes = NoLanes;
82 + if (enableDefaultTransitionIndicator) {
83 + this.indicatorLanes = NoLanes;
84 + }
85 this.errorRecoveryDisabledLanes = NoLanes;
86 this.shellSuspendCounter = 0;
87
@@ -94,6 +97,7 @@ function FiberRootNode(
97
98 if (enableDefaultTransitionIndicator) {
99 this.onDefaultTransitionIndicator = onDefaultTransitionIndicator;
100 + this.pendingIndicator = null;
101 }
102
103 this.pooledCache = null;
packages/react-reconciler/src/ReactFiberRootScheduler.js
+40 -2
@@ -20,6 +20,7 @@ import {
20 enableComponentPerformanceTrack,
21 enableYieldingBeforePassive,
22 enableGestureTransition,
23 + enableDefaultTransitionIndicator,
24 } from 'shared/ReactFeatureFlags';
25 import {
26 NoLane,
@@ -80,6 +81,9 @@ import {
81 } from './ReactProfilerTimer';
82 import {peekEntangledActionLane} from './ReactFiberAsyncAction';
83
84 +import noop from 'shared/noop';
85 +import reportGlobalError from 'shared/reportGlobalError';
86 +
87 // A linked list of all the roots with pending work. In an idiomatic app,
88 // there's only a single root, but we do support multi root apps, hence this
89 // extra complexity. But this module is optimized for the single root case.
@@ -316,8 +320,33 @@ function processRootScheduleInMicrotask() {
320 flushSyncWorkAcrossRoots_impl(syncTransitionLanes, false);
321 }
322
319 - // Reset Event Transition Lane so that we allocate a new one next time.
320 - currentEventTransitionLane = NoLane;
323 + if (currentEventTransitionLane !== NoLane) {
324 + // Reset Event Transition Lane so that we allocate a new one next time.
325 + currentEventTransitionLane = NoLane;
326 + startDefaultTransitionIndicatorIfNeeded();
327 + }
328 +}
329 +
330 +function startDefaultTransitionIndicatorIfNeeded() {
331 + if (!enableDefaultTransitionIndicator) {
332 + return;
333 + }
334 + // Check all the roots if there are any new indicators needed.
335 + let root = firstScheduledRoot;
336 + while (root !== null) {
337 + if (root.indicatorLanes !== NoLanes && root.pendingIndicator === null) {
338 + // We have new indicator lanes that requires a loading state. Start the
339 + // default transition indicator.
340 + try {
341 + const onDefaultTransitionIndicator = root.onDefaultTransitionIndicator;
342 + root.pendingIndicator = onDefaultTransitionIndicator() || noop;
343 + } catch (x) {
344 + root.pendingIndicator = noop;
345 + reportGlobalError(x);
346 + }
347 + }
348 + root = root.next;
349 + }
350 }
351
352 function scheduleTaskForRootDuringMicrotask(
@@ -664,3 +693,12 @@ export function requestTransitionLane(
693 export function didCurrentEventScheduleTransition(): boolean {
694 return currentEventTransitionLane !== NoLane;
695 }
696 +
697 +export function markIndicatorHandled(root: FiberRoot): void {
698 + if (enableDefaultTransitionIndicator) {
699 + // The current transition event rendered a synchronous loading state.
700 + // Clear it from the indicator lanes. We don't need to show a separate
701 + // loading state for this lane.
702 + root.indicatorLanes &= ~currentEventTransitionLane;
703 + }
704 +}
packages/react-reconciler/src/ReactFiberWorkLoop.js
+30
@@ -52,11 +52,14 @@ import {
52 enableThrottledScheduling,
53 enableViewTransition,
54 enableGestureTransition,
55 + enableDefaultTransitionIndicator,
56 } from 'shared/ReactFeatureFlags';
57 import {resetOwnerStackLimit} from 'shared/ReactOwnerStackReset';
58 import ReactSharedInternals from 'shared/ReactSharedInternals';
59 import is from 'shared/objectIs';
60
61 +import reportGlobalError from 'shared/reportGlobalError';
62 +
63 import {
64 // Aliased because `act` will override and push to an internal queue
65 scheduleCallback as Scheduler_scheduleCallback,
@@ -3593,6 +3596,33 @@ function flushLayoutEffects(): void {
3596 const finishedWork = pendingFinishedWork;
3597 const lanes = pendingEffectsLanes;
3598
3599 + if (enableDefaultTransitionIndicator) {
3600 + const cleanUpIndicator = root.pendingIndicator;
3601 + if (cleanUpIndicator !== null && root.indicatorLanes === NoLanes) {
3602 + // We have now committed all Transitions that needed the default indicator
3603 + // so we can now run the clean up function. We do this in the layout phase
3604 + // so it has the same semantics as if you did it with a useLayoutEffect or
3605 + // if it was reset automatically with useOptimistic.
3606 + const prevTransition = ReactSharedInternals.T;
3607 + ReactSharedInternals.T = null;
3608 + const previousPriority = getCurrentUpdatePriority();
3609 + setCurrentUpdatePriority(DiscreteEventPriority);
3610 + const prevExecutionContext = executionContext;
3611 + executionContext |= CommitContext;
3612 + root.pendingIndicator = null;
3613 + try {
3614 + cleanUpIndicator();
3615 + } catch (x) {
3616 + reportGlobalError(x);
3617 + } finally {
3618 + // Reset the priority to the previous non-sync value.
3619 + executionContext = prevExecutionContext;
3620 + setCurrentUpdatePriority(previousPriority);
3621 + ReactSharedInternals.T = prevTransition;
3622 + }
3623 + }
3624 + }
3625 +
3626 const subtreeHasLayoutEffects =
3627 (finishedWork.subtreeFlags & LayoutMask) !== NoFlags;
3628 const rootHasLayoutEffect = (finishedWork.flags & LayoutMask) !== NoFlags;
packages/react-reconciler/src/ReactInternalTypes.js
+3
@@ -248,6 +248,7 @@ type BaseFiberRootProperties = {
248 pingedLanes: Lanes,
249 warmLanes: Lanes,
250 expiredLanes: Lanes,
251 + indicatorLanes: Lanes, // enableDefaultTransitionIndicator only
252 errorRecoveryDisabledLanes: Lanes,
253 shellSuspendCounter: number,
254
@@ -280,7 +281,9 @@ type BaseFiberRootProperties = {
281 errorInfo: {+componentStack?: ?string},
282 ) => void,
283
284 + // enableDefaultTransitionIndicator only
285 onDefaultTransitionIndicator: () => void | (() => void),
286 + pendingIndicator: null | (() => void),
287
288 formState: ReactFormState<any, any> | null,
289
packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js new
+358
@@ -0,0 +1,358 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and 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 + * @emails react-core
8 + * @jest-environment node
9 + */
10 +
11 +'use strict';
12 +
13 +let React;
14 +let ReactNoop;
15 +let Scheduler;
16 +let act;
17 +let use;
18 +let useOptimistic;
19 +let useState;
20 +let useTransition;
21 +let useDeferredValue;
22 +let assertLog;
23 +let waitForPaint;
24 +
25 +describe('ReactDefaultTransitionIndicator', () => {
26 + beforeEach(() => {
27 + jest.resetModules();
28 +
29 + React = require('react');
30 + ReactNoop = require('react-noop-renderer');
31 + Scheduler = require('scheduler');
32 + const InternalTestUtils = require('internal-test-utils');
33 + act = InternalTestUtils.act;
34 + assertLog = InternalTestUtils.assertLog;
35 + waitForPaint = InternalTestUtils.waitForPaint;
36 + use = React.use;
37 + useOptimistic = React.useOptimistic;
38 + useState = React.useState;
39 + useTransition = React.useTransition;
40 + useDeferredValue = React.useDeferredValue;
41 + });
42 +
43 + // @gate enableDefaultTransitionIndicator
44 + it('triggers the default indicator while a transition is on-going', async () => {
45 + let resolve;
46 + const promise = new Promise(r => (resolve = r));
47 + function App() {
48 + return use(promise);
49 + }
50 +
51 + const root = ReactNoop.createRoot({
52 + onDefaultTransitionIndicator() {
53 + Scheduler.log('start');
54 + return () => {
55 + Scheduler.log('stop');
56 + };
57 + },
58 + });
59 + await act(() => {
60 + React.startTransition(() => {
61 + root.render(<App />);
62 + });
63 + });
64 +
65 + assertLog(['start']);
66 +
67 + await act(async () => {
68 + await resolve('Hello');
69 + });
70 +
71 + assertLog(['stop']);
72 +
73 + expect(root).toMatchRenderedOutput('Hello');
74 + });
75 +
76 + // @gate enableDefaultTransitionIndicator
77 + it('does not trigger the default indicator if there is a sync mutation', async () => {
78 + const promiseA = Promise.resolve('Hi');
79 + let resolveB;
80 + const promiseB = new Promise(r => (resolveB = r));
81 + let update;
82 + function App({children}) {
83 + const [state, setState] = useState('');
84 + update = setState;
85 + return (
86 + <div>
87 + {state}
88 + {children}
89 + </div>
90 + );
91 + }
92 +
93 + const root = ReactNoop.createRoot({
94 + onDefaultTransitionIndicator() {
95 + Scheduler.log('start');
96 + return () => {
97 + Scheduler.log('stop');
98 + };
99 + },
100 + });
101 + await act(() => {
102 + React.startTransition(() => {
103 + root.render(<App>{promiseA}</App>);
104 + });
105 + });
106 +
107 + assertLog(['start', 'stop']);
108 +
109 + expect(root).toMatchRenderedOutput(<div>Hi</div>);
110 +
111 + await act(() => {
112 + // TODO: This should not require a discrete update ideally but work for default too.
113 + ReactNoop.discreteUpdates(() => {
114 + update('Loading...');
115 + });
116 + React.startTransition(() => {
117 + update('');
118 + root.render(<App>{promiseB}</App>);
119 + });
120 + });
121 +
122 + assertLog([]);
123 +
124 + expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
125 +
126 + await act(async () => {
127 + await resolveB('Hello');
128 + });
129 +
130 + assertLog([]);
131 +
132 + expect(root).toMatchRenderedOutput(<div>Hello</div>);
133 + });
134 +
135 + // @gate enableDefaultTransitionIndicator
136 + it('does not trigger the default indicator if there is an optimistic update', async () => {
137 + const promiseA = Promise.resolve('Hi');
138 + let resolveB;
139 + const promiseB = new Promise(r => (resolveB = r));
140 + let update;
141 + function App({children}) {
142 + const [state, setOptimistic] = useOptimistic('');
143 + update = setOptimistic;
144 + return (
145 + <div>
146 + {state}
147 + {children}
148 + </div>
149 + );
150 + }
151 +
152 + const root = ReactNoop.createRoot({
153 + onDefaultTransitionIndicator() {
154 + Scheduler.log('start');
155 + return () => {
156 + Scheduler.log('stop');
157 + };
158 + },
159 + });
160 + await act(() => {
161 + React.startTransition(() => {
162 + root.render(<App>{promiseA}</App>);
163 + });
164 + });
165 +
166 + assertLog(['start', 'stop']);
167 +
168 + expect(root).toMatchRenderedOutput(<div>Hi</div>);
169 +
170 + await act(() => {
171 + React.startTransition(() => {
172 + update('Loading...');
173 + root.render(<App>{promiseB}</App>);
174 + });
175 + });
176 +
177 + assertLog([]);
178 +
179 + expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
180 +
181 + await act(async () => {
182 + await resolveB('Hello');
183 + });
184 +
185 + assertLog([]);
186 +
187 + expect(root).toMatchRenderedOutput(<div>Hello</div>);
188 + });
189 +
190 + // @gate enableDefaultTransitionIndicator
191 + it('does not trigger the default indicator if there is an isPending update', async () => {
192 + const promiseA = Promise.resolve('Hi');
193 + let resolveB;
194 + const promiseB = new Promise(r => (resolveB = r));
195 + let start;
196 + function App({children}) {
197 + const [isPending, startTransition] = useTransition();
198 + start = startTransition;
199 + return (
200 + <div>
201 + {isPending ? 'Loading...' : ''}
202 + {children}
203 + </div>
204 + );
205 + }
206 +
207 + const root = ReactNoop.createRoot({
208 + onDefaultTransitionIndicator() {
209 + Scheduler.log('start');
210 + return () => {
211 + Scheduler.log('stop');
212 + };
213 + },
214 + });
215 + await act(() => {
216 + React.startTransition(() => {
217 + root.render(<App>{promiseA}</App>);
218 + });
219 + });
220 +
221 + assertLog(['start', 'stop']);
222 +
223 + expect(root).toMatchRenderedOutput(<div>Hi</div>);
224 +
225 + await act(() => {
226 + start(() => {
227 + root.render(<App>{promiseB}</App>);
228 + });
229 + });
230 +
231 + assertLog([]);
232 +
233 + expect(root).toMatchRenderedOutput(<div>Loading...Hi</div>);
234 +
235 + await act(async () => {
236 + await resolveB('Hello');
237 + });
238 +
239 + assertLog([]);
240 +
241 + expect(root).toMatchRenderedOutput(<div>Hello</div>);
242 + });
243 +
244 + // @gate enableDefaultTransitionIndicator
245 + it('triggers the default indicator while an async transition is ongoing', async () => {
246 + let resolve;
247 + const promise = new Promise(r => (resolve = r));
248 + let start;
249 + function App() {
250 + const [, startTransition] = useTransition();
251 + start = startTransition;
252 + return 'Hi';
253 + }
254 +
255 + const root = ReactNoop.createRoot({
256 + onDefaultTransitionIndicator() {
257 + Scheduler.log('start');
258 + return () => {
259 + Scheduler.log('stop');
260 + };
261 + },
262 + });
263 + await act(() => {
264 + root.render(<App />);
265 + });
266 +
267 + assertLog([]);
268 +
269 + await act(() => {
270 + // Start an async action but we haven't called setState yet
271 + // TODO: This should ideally work with React.startTransition too but we don't know the root.
272 + start(() => promise);
273 + });
274 +
275 + assertLog(['start']);
276 +
277 + await act(async () => {
278 + await resolve('Hello');
279 + });
280 +
281 + assertLog(['stop']);
282 +
283 + expect(root).toMatchRenderedOutput('Hi');
284 + });
285 +
286 + it('should not trigger for useDeferredValue (sync)', async () => {
287 + function Text({text}) {
288 + Scheduler.log(text);
289 + return text;
290 + }
291 + function App({value}) {
292 + const deferredValue = useDeferredValue(value, 'Hi');
293 + return <Text text={deferredValue} />;
294 + }
295 +
296 + const root = ReactNoop.createRoot({
297 + onDefaultTransitionIndicator() {
298 + Scheduler.log('start');
299 + return () => {
300 + Scheduler.log('stop');
301 + };
302 + },
303 + });
304 + await act(async () => {
305 + root.render(<App value="Hello" />);
306 + await waitForPaint(['Hi']);
307 + expect(root).toMatchRenderedOutput('Hi');
308 + });
309 +
310 + assertLog(['Hello']);
311 +
312 + expect(root).toMatchRenderedOutput('Hello');
313 +
314 + assertLog([]);
315 +
316 + await act(async () => {
317 + root.render(<App value="Bye" />);
318 + await waitForPaint(['Hello']);
319 + expect(root).toMatchRenderedOutput('Hello');
320 + });
321 +
322 + assertLog(['Bye']);
323 +
324 + expect(root).toMatchRenderedOutput('Bye');
325 + });
326 +
327 + // @gate enableDefaultTransitionIndicator
328 + it('should not trigger for useDeferredValue (transition)', async () => {
329 + function Text({text}) {
330 + Scheduler.log(text);
331 + return text;
332 + }
333 + function App({value}) {
334 + const deferredValue = useDeferredValue(value, 'Hi');
335 + return <Text text={deferredValue} />;
336 + }
337 +
338 + const root = ReactNoop.createRoot({
339 + onDefaultTransitionIndicator() {
340 + Scheduler.log('start');
341 + return () => {
342 + Scheduler.log('stop');
343 + };
344 + },
345 + });
346 + await act(async () => {
347 + React.startTransition(() => {
348 + root.render(<App value="Hello" />);
349 + });
350 + await waitForPaint(['start', 'Hi', 'stop']);
351 + expect(root).toMatchRenderedOutput('Hi');
352 + });
353 +
354 + assertLog(['Hello']);
355 +
356 + expect(root).toMatchRenderedOutput('Hello');
357 + });
358 +});