[Fiber] Trigger default indicator for isomorphic async actions with no root associated (#33190)
Stacked on #33160, #33162, #33186 and #33188. We have a special case that's awkward for default indicators. When you start a new async Transition from `React.startTransition` then there's not yet any associated root with the Transition because you haven't necessarily `setState` on anything yet until the promise resolves. That's what `entangleAsyncAction` handles by creating a lane that everything entangles with until all async actions are done. If there are no sync updates before the end of the event, we should trigger a default indicator until either the async action completes without update or if it gets entangled with some roots we should keep it going until those roots are done.
Sebastian Markbåge committed
May 13, 2025 at 16:10 UTC
3a5b326d8180f005a10e34a07ded6d5632efe337
5 files changed
+274
-20
fixtures/view-transition/src/components/Page.js
+1
-1
@@ -113,8 +113,8 @@ export default function Page({url, navigate}) {
113
<button
114
onClick={() =>
115
startTransition(async () => {
116
- setShowModal(true);
116
await sleep(2000);
117
+ setShowModal(true);
118
})
119
}>
120
Show Modal
packages/react-reconciler/src/ReactFiberAsyncAction.js
+101
-1
@@ -15,7 +15,10 @@ import type {
15
import type {Lane} from './ReactFiberLane';
16
import type {Transition} from 'react/src/ReactStartTransition';
17
18
-import {requestTransitionLane} from './ReactFiberRootScheduler';
18
+import {
19
+ requestTransitionLane,
20
+ ensureScheduleIsScheduled,
21
+} from './ReactFiberRootScheduler';
22
import {NoLane} from './ReactFiberLane';
23
import {
24
hasScheduledTransitionWork,
@@ -24,9 +27,13 @@ import {
27
import {
28
enableComponentPerformanceTrack,
29
enableProfilerTimer,
30
+ enableDefaultTransitionIndicator,
31
} from 'shared/ReactFeatureFlags';
32
import {clearEntangledAsyncTransitionTypes} from './ReactFiberTransitionTypes';
33
34
+import noop from 'shared/noop';
35
+import reportGlobalError from 'shared/reportGlobalError';
36
+
37
// If there are multiple, concurrent async actions, they are entangled. All
38
// transition updates that occur while the async action is still in progress
39
// are treated as part of the action.
@@ -46,6 +53,21 @@ let currentEntangledLane: Lane = NoLane;
53
// until the async action scope has completed.
54
let currentEntangledActionThenable: Thenable<void> | null = null;
55
56
+// Track the default indicator for every root. undefined means we haven't
57
+// had any roots registered yet. null means there's more than one callback.
58
+// If there's more than one callback we bailout to not supporting isomorphic
59
+// default indicators.
60
+let isomorphicDefaultTransitionIndicator:
61
+ | void
62
+ | null
63
+ | (() => void | (() => void)) = undefined;
64
+// The clean up function for the currently running indicator.
65
+let pendingIsomorphicIndicator: null | (() => void) = null;
66
+// The number of roots that have pending Transitions that depend on the
67
+// started isomorphic indicator.
68
+let pendingEntangledRoots: number = 0;
69
+let needsIsomorphicIndicator: boolean = false;
70
+
71
export function entangleAsyncAction<S>(
72
transition: Transition,
73
thenable: Thenable<S>,
@@ -66,6 +88,12 @@ export function entangleAsyncAction<S>(
88
},
89
};
90
currentEntangledActionThenable = entangledThenable;
91
+ if (enableDefaultTransitionIndicator) {
92
+ needsIsomorphicIndicator = true;
93
+ // We'll check if we need a default indicator in a microtask. Ensure
94
+ // we have this scheduled even if no root is scheduled.
95
+ ensureScheduleIsScheduled();
96
+ }
97
}
98
currentEntangledPendingCount++;
99
thenable.then(pingEngtangledActionScope, pingEngtangledActionScope);
@@ -86,6 +114,9 @@ function pingEngtangledActionScope() {
114
}
115
}
116
clearEntangledAsyncTransitionTypes();
117
+ if (pendingEntangledRoots === 0) {
118
+ stopIsomorphicDefaultIndicator();
119
+ }
120
if (currentEntangledListeners !== null) {
121
// All the actions have finished. Close the entangled async action scope
122
// and notify all the listeners.
@@ -98,6 +129,7 @@ function pingEngtangledActionScope() {
129
currentEntangledListeners = null;
130
currentEntangledLane = NoLane;
131
currentEntangledActionThenable = null;
132
+ needsIsomorphicIndicator = false;
133
for (let i = 0; i < listeners.length; i++) {
134
const listener = listeners[i];
135
listener();
@@ -161,3 +193,71 @@ export function peekEntangledActionLane(): Lane {
193
export function peekEntangledActionThenable(): Thenable<void> | null {
194
return currentEntangledActionThenable;
195
}
196
+
197
+export function registerDefaultIndicator(
198
+ onDefaultTransitionIndicator: () => void | (() => void),
199
+): void {
200
+ if (!enableDefaultTransitionIndicator) {
201
+ return;
202
+ }
203
+ if (isomorphicDefaultTransitionIndicator === undefined) {
204
+ isomorphicDefaultTransitionIndicator = onDefaultTransitionIndicator;
205
+ } else if (
206
+ isomorphicDefaultTransitionIndicator !== onDefaultTransitionIndicator
207
+ ) {
208
+ isomorphicDefaultTransitionIndicator = null;
209
+ // Stop any on-going indicator since it's now ambiguous.
210
+ stopIsomorphicDefaultIndicator();
211
+ }
212
+}
213
+
214
+export function startIsomorphicDefaultIndicatorIfNeeded() {
215
+ if (!enableDefaultTransitionIndicator) {
216
+ return;
217
+ }
218
+ if (!needsIsomorphicIndicator) {
219
+ return;
220
+ }
221
+ if (
222
+ isomorphicDefaultTransitionIndicator != null &&
223
+ pendingIsomorphicIndicator === null
224
+ ) {
225
+ try {
226
+ pendingIsomorphicIndicator =
227
+ isomorphicDefaultTransitionIndicator() || noop;
228
+ } catch (x) {
229
+ pendingIsomorphicIndicator = noop;
230
+ reportGlobalError(x);
231
+ }
232
+ }
233
+}
234
+
235
+function stopIsomorphicDefaultIndicator() {
236
+ if (!enableDefaultTransitionIndicator) {
237
+ return;
238
+ }
239
+ if (pendingIsomorphicIndicator !== null) {
240
+ const cleanup = pendingIsomorphicIndicator;
241
+ pendingIsomorphicIndicator = null;
242
+ cleanup();
243
+ }
244
+}
245
+
246
+function releaseIsomorphicIndicator() {
247
+ if (--pendingEntangledRoots === 0) {
248
+ stopIsomorphicDefaultIndicator();
249
+ }
250
+}
251
+
252
+export function hasOngoingIsomorphicIndicator(): boolean {
253
+ return pendingIsomorphicIndicator !== null;
254
+}
255
+
256
+export function retainIsomorphicIndicator(): () => void {
257
+ pendingEntangledRoots++;
258
+ return releaseIsomorphicIndicator;
259
+}
260
+
261
+export function markIsomorphicIndicatorHandled(): void {
262
+ needsIsomorphicIndicator = false;
263
+}
packages/react-reconciler/src/ReactFiberReconciler.js
+6
-1
@@ -125,6 +125,7 @@ export {
125
defaultOnRecoverableError,
126
} from './ReactFiberErrorLogger';
127
import {getLabelForLane, TotalLanes} from 'react-reconciler/src/ReactFiberLane';
128
+import {registerDefaultIndicator} from './ReactFiberAsyncAction';
129
130
type OpaqueRoot = FiberRoot;
131
@@ -259,7 +260,7 @@ export function createContainer(
260
): OpaqueRoot {
261
const hydrate = false;
262
const initialChildren = null;
262
- return createFiberRoot(
263
+ const root = createFiberRoot(
264
containerInfo,
265
tag,
266
hydrate,
@@ -274,6 +275,8 @@ export function createContainer(
275
onDefaultTransitionIndicator,
276
transitionCallbacks,
277
);
278
+ registerDefaultIndicator(onDefaultTransitionIndicator);
279
+ return root;
280
}
281
282
export function createHydrationContainer(
@@ -323,6 +326,8 @@ export function createHydrationContainer(
326
transitionCallbacks,
327
);
328
329
+ registerDefaultIndicator(onDefaultTransitionIndicator);
330
+
331
// TODO: Move this to FiberRoot constructor
332
root.context = getContextForSubtree(null);
333
packages/react-reconciler/src/ReactFiberRootScheduler.js
+40
-16
@@ -85,6 +85,13 @@ import {peekEntangledActionLane} from './ReactFiberAsyncAction';
85
import noop from 'shared/noop';
86
import reportGlobalError from 'shared/reportGlobalError';
87
88
+import {
89
+ startIsomorphicDefaultIndicatorIfNeeded,
90
+ hasOngoingIsomorphicIndicator,
91
+ retainIsomorphicIndicator,
92
+ markIsomorphicIndicatorHandled,
93
+} from './ReactFiberAsyncAction';
94
+
95
// A linked list of all the roots with pending work. In an idiomatic app,
96
// there's only a single root, but we do support multi root apps, hence this
97
// extra complexity. But this module is optimized for the single root case.
@@ -130,6 +137,20 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
137
// without consulting the schedule.
138
mightHavePendingSyncWork = true;
139
140
+ ensureScheduleIsScheduled();
141
+
142
+ if (
143
+ __DEV__ &&
144
+ !disableLegacyMode &&
145
+ ReactSharedInternals.isBatchingLegacy &&
146
+ root.tag === LegacyRoot
147
+ ) {
148
+ // Special `act` case: Record whenever a legacy update is scheduled.
149
+ ReactSharedInternals.didScheduleLegacyUpdate = true;
150
+ }
151
+}
152
+
153
+export function ensureScheduleIsScheduled(): void {
154
// At the end of the current event, go through each of the roots and ensure
155
// there's a task scheduled for each one at the correct priority.
156
if (__DEV__ && ReactSharedInternals.actQueue !== null) {
@@ -144,16 +165,6 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
165
scheduleImmediateRootScheduleTask();
166
}
167
}
147
-
148
- if (
149
- __DEV__ &&
150
- !disableLegacyMode &&
151
- ReactSharedInternals.isBatchingLegacy &&
152
- root.tag === LegacyRoot
153
- ) {
154
- // Special `act` case: Record whenever a legacy update is scheduled.
155
- ReactSharedInternals.didScheduleLegacyUpdate = true;
156
- }
168
}
169
170
export function flushSyncWorkOnAllRoots() {
@@ -339,18 +350,30 @@ function startDefaultTransitionIndicatorIfNeeded() {
350
if (!enableDefaultTransitionIndicator) {
351
return;
352
}
353
+ // Check if we need to start an isomorphic indicator like if an async action
354
+ // was started.
355
+ startIsomorphicDefaultIndicatorIfNeeded();
356
// Check all the roots if there are any new indicators needed.
357
let root = firstScheduledRoot;
358
while (root !== null) {
359
if (root.indicatorLanes !== NoLanes && root.pendingIndicator === null) {
360
// We have new indicator lanes that requires a loading state. Start the
361
// default transition indicator.
348
- try {
349
- const onDefaultTransitionIndicator = root.onDefaultTransitionIndicator;
350
- root.pendingIndicator = onDefaultTransitionIndicator() || noop;
351
- } catch (x) {
352
- root.pendingIndicator = noop;
353
- reportGlobalError(x);
362
+ if (hasOngoingIsomorphicIndicator()) {
363
+ // We already have an isomorphic indicator going which means it has to
364
+ // also apply to this root since it implies all roots have the same one.
365
+ // We retain this indicator so that it keeps going until we commit this
366
+ // root.
367
+ root.pendingIndicator = retainIsomorphicIndicator();
368
+ } else {
369
+ try {
370
+ const onDefaultTransitionIndicator =
371
+ root.onDefaultTransitionIndicator;
372
+ root.pendingIndicator = onDefaultTransitionIndicator() || noop;
373
+ } catch (x) {
374
+ root.pendingIndicator = noop;
375
+ reportGlobalError(x);
376
+ }
377
}
378
}
379
root = root.next;
@@ -708,5 +731,6 @@ export function markIndicatorHandled(root: FiberRoot): void {
731
// Clear it from the indicator lanes. We don't need to show a separate
732
// loading state for this lane.
733
root.indicatorLanes &= ~currentEventTransitionLane;
734
+ markIsomorphicIndicatorHandled();
735
}
736
}
packages/react-reconciler/src/__tests__/ReactDefaultTransitionIndicator-test.js
+126
-1
@@ -265,7 +265,6 @@ describe('ReactDefaultTransitionIndicator', () => {
265
266
await act(() => {
267
// Start an async action but we haven't called setState yet
268
- // TODO: This should ideally work with React.startTransition too but we don't know the root.
268
start(() => promise);
269
});
270
@@ -280,6 +279,132 @@ describe('ReactDefaultTransitionIndicator', () => {
279
expect(root).toMatchRenderedOutput('Hi');
280
});
281
282
+ // @gate enableDefaultTransitionIndicator
283
+ it('triggers the default indicator while an async transition is ongoing (isomorphic)', async () => {
284
+ let resolve;
285
+ const promise = new Promise(r => (resolve = r));
286
+ function App() {
287
+ return 'Hi';
288
+ }
289
+
290
+ const root = ReactNoop.createRoot({
291
+ onDefaultTransitionIndicator() {
292
+ Scheduler.log('start');
293
+ return () => {
294
+ Scheduler.log('stop');
295
+ };
296
+ },
297
+ });
298
+ await act(() => {
299
+ root.render(<App />);
300
+ });
301
+
302
+ assertLog([]);
303
+
304
+ await act(() => {
305
+ // Start an async action but we haven't called setState yet
306
+ React.startTransition(() => promise);
307
+ });
308
+
309
+ assertLog(['start']);
310
+
311
+ await act(async () => {
312
+ await resolve('Hello');
313
+ });
314
+
315
+ assertLog(['stop']);
316
+
317
+ expect(root).toMatchRenderedOutput('Hi');
318
+ });
319
+
320
+ it('does not triggers isomorphic async action default indicator if there are two different ones', async () => {
321
+ let resolve;
322
+ const promise = new Promise(r => (resolve = r));
323
+ function App() {
324
+ return 'Hi';
325
+ }
326
+
327
+ const root = ReactNoop.createRoot({
328
+ onDefaultTransitionIndicator() {
329
+ Scheduler.log('start');
330
+ return () => {
331
+ Scheduler.log('stop');
332
+ };
333
+ },
334
+ });
335
+ // Initialize second root. This is now ambiguous which indicator to use.
336
+ ReactNoop.createRoot({
337
+ onDefaultTransitionIndicator() {
338
+ Scheduler.log('start2');
339
+ return () => {
340
+ Scheduler.log('stop2');
341
+ };
342
+ },
343
+ });
344
+ await act(() => {
345
+ root.render(<App />);
346
+ });
347
+
348
+ assertLog([]);
349
+
350
+ await act(() => {
351
+ // Start an async action but we haven't called setState yet
352
+ React.startTransition(() => promise);
353
+ });
354
+
355
+ assertLog([]);
356
+
357
+ await act(async () => {
358
+ await resolve('Hello');
359
+ });
360
+
361
+ assertLog([]);
362
+
363
+ expect(root).toMatchRenderedOutput('Hi');
364
+ });
365
+
366
+ it('does not triggers isomorphic async action default indicator if there is a loading state', async () => {
367
+ let resolve;
368
+ const promise = new Promise(r => (resolve = r));
369
+ let update;
370
+ function App() {
371
+ const [state, setState] = useState(false);
372
+ update = setState;
373
+ return state ? 'Loading' : 'Hi';
374
+ }
375
+
376
+ const root = ReactNoop.createRoot({
377
+ onDefaultTransitionIndicator() {
378
+ Scheduler.log('start');
379
+ return () => {
380
+ Scheduler.log('stop');
381
+ };
382
+ },
383
+ });
384
+ await act(() => {
385
+ root.render(<App />);
386
+ });
387
+
388
+ assertLog([]);
389
+
390
+ await act(() => {
391
+ update(true);
392
+ React.startTransition(() => promise.then(() => update(false)));
393
+ });
394
+
395
+ assertLog([]);
396
+
397
+ expect(root).toMatchRenderedOutput('Loading');
398
+
399
+ await act(async () => {
400
+ await resolve('Hello');
401
+ });
402
+
403
+ assertLog([]);
404
+
405
+ expect(root).toMatchRenderedOutput('Hi');
406
+ });
407
+
408
it('should not trigger for useDeferredValue (sync)', async () => {
409
function Text({text}) {
410
Scheduler.log(text);