@samitouri / QOS-React-1 / commits / 3a5b326d81

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