Log Suspended startViewTransition Phase (#34511)
Stacked on #34510. The "Commit" phase for a View Transition starts before the snapshot phase (before mutation) and then stretches into the async gap of `startViewTransition`, encompasses the mutation phase inside of its update callback and finally the layout phase. However, between the mutation phase and the layout phase we may suspend the start of the view transition on fonts and/or images. In that case we now split the Commit phase into first one before we suspend and then we log "Waiting for Images and/or Fonts" and then another Commit phase around the layout effects. <img width="897" height="119" alt="Screenshot 2025-09-16 at 11 37 26 PM" src="https://github.com/user-attachments/assets/0fe21388-bb48-4456-a594-62227d12d9b7" />
Sebastian Markbåge committed
Sep 18, 2025 at 15:25 UTC
ad578aa01fcd08488b8378c7538d802e7a1e8b26
5 files changed
+90
-1
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+12
-1
@@ -125,6 +125,7 @@ import {
125
enableViewTransition,
126
enableHydrationChangeEvent,
127
enableFragmentRefsScrollIntoView,
128
+ enableProfilerTimer,
129
} from 'shared/ReactFeatureFlags';
130
import {
131
HostComponent,
@@ -2098,6 +2099,7 @@ export function startViewTransition(
2099
spawnedWorkCallback: () => void,
2100
passiveCallback: () => mixed,
2101
errorCallback: mixed => void,
2102
+ blockedCallback: string => void, // Profiling-only
2103
): null | RunningViewTransition {
2104
const ownerDocument: Document =
2105
rootContainer.nodeType === DOCUMENT_NODE
@@ -2131,10 +2133,10 @@ export function startViewTransition(
2133
blockingPromises.push(ownerDocument.fonts.ready);
2134
}
2135
}
2136
+ const blockingIndexSnapshot = blockingPromises.length;
2137
if (suspendedState !== null) {
2138
// Suspend on any images that still haven't loaded and are in the viewport.
2139
const suspenseyImages = suspendedState.suspenseyImages;
2137
- const blockingIndexSnapshot = blockingPromises.length;
2140
let imgBytes = 0;
2141
for (let i = 0; i < suspenseyImages.length; i++) {
2142
const suspenseyImage = suspenseyImages[i];
@@ -2162,6 +2164,15 @@ export function startViewTransition(
2164
}
2165
}
2166
if (blockingPromises.length > 0) {
2167
+ if (enableProfilerTimer) {
2168
+ const blockedReason =
2169
+ blockingIndexSnapshot > 0
2170
+ ? blockingPromises.length > blockingIndexSnapshot
2171
+ ? 'Waiting on Fonts and Images'
2172
+ : 'Waiting on Fonts'
2173
+ : 'Waiting on Images';
2174
+ blockedCallback(blockedReason);
2175
+ }
2176
const blockingReady = Promise.race([
2177
Promise.all(blockingPromises),
2178
new Promise(resolve =>
packages/react-native-renderer/src/ReactFiberConfigNative.js
+1
@@ -673,6 +673,7 @@ export function startViewTransition(
673
spawnedWorkCallback: () => void,
674
passiveCallback: () => mixed,
675
errorCallback: mixed => void,
676
+ blockedCallback: string => void, // Profiling-only
677
): null | RunningViewTransition {
678
mutationCallback();
679
layoutCallback();
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+39
@@ -1254,6 +1254,45 @@ export function logSuspendedCommitPhase(
1254
}
1255
}
1256
1257
+export function logSuspendedViewTransitionPhase(
1258
+ startTime: number,
1259
+ endTime: number,
1260
+ reason: string,
1261
+ debugTask: null | ConsoleTask,
1262
+): void {
1263
+ // This means the commit was suspended on CSS or images.
1264
+ if (supportsUserTiming) {
1265
+ if (endTime <= startTime) {
1266
+ return;
1267
+ }
1268
+ // TODO: Include the exact reason and URLs of what resources suspended.
1269
+ // TODO: This might also be Suspended while waiting on a View Transition.
1270
+ if (__DEV__ && debugTask) {
1271
+ debugTask.run(
1272
+ // $FlowFixMe[method-unbinding]
1273
+ console.timeStamp.bind(
1274
+ console,
1275
+ reason,
1276
+ startTime,
1277
+ endTime,
1278
+ currentTrack,
1279
+ LANES_TRACK_GROUP,
1280
+ 'secondary-light',
1281
+ ),
1282
+ );
1283
+ } else {
1284
+ console.timeStamp(
1285
+ reason,
1286
+ startTime,
1287
+ endTime,
1288
+ currentTrack,
1289
+ LANES_TRACK_GROUP,
1290
+ 'secondary-light',
1291
+ );
1292
+ }
1293
+ }
1294
+}
1295
+
1296
export function logCommitErrored(
1297
startTime: number,
1298
endTime: number,
packages/react-reconciler/src/ReactFiberWorkLoop.js
+37
@@ -81,6 +81,7 @@ import {
81
logSuspendedWithDelayPhase,
82
logSuspenseThrottlePhase,
83
logSuspendedCommitPhase,
84
+ logSuspendedViewTransitionPhase,
85
logCommitPhase,
86
logPaintYieldPhase,
87
logStartViewTransitionYieldPhase,
@@ -704,6 +705,7 @@ let pendingTransitionTypes: null | TransitionTypes = null;
705
let pendingDidIncludeRenderPhaseUpdate: boolean = false;
706
let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
707
let pendingDelayedCommitReason: DelayedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
708
+let pendingSuspendedViewTransitionReason: null | string = null; // Profiling-only
709
710
// Use these to prevent an infinite loop of nested updates
711
const NESTED_UPDATE_LIMIT = 50;
@@ -3445,6 +3447,7 @@ function commitRoot(
3447
pendingEffectsRenderEndTime = completedRenderEndTime;
3448
pendingSuspendedCommitReason = suspendedCommitReason;
3449
pendingDelayedCommitReason = IMMEDIATE_COMMIT;
3450
+ pendingSuspendedViewTransitionReason = null;
3451
}
3452
3453
if (enableGestureTransition && isGestureRender(lanes)) {
@@ -3604,6 +3607,7 @@ function commitRoot(
3607
flushSpawnedWork,
3608
flushPassiveEffects,
3609
reportViewTransitionError,
3610
+ enableProfilerTimer ? suspendedViewTransition : (null: any),
3611
);
3612
} else {
3613
// Flush synchronously.
@@ -3624,6 +3628,24 @@ function reportViewTransitionError(error: mixed) {
3628
onRecoverableError(error, makeErrorInfo(null));
3629
}
3630
3631
+function suspendedViewTransition(reason: string): void {
3632
+ if (enableProfilerTimer && enableComponentPerformanceTrack) {
3633
+ // We'll split the commit into two phases, because we're suspended in the middle.
3634
+ recordCommitEndTime();
3635
+ logCommitPhase(
3636
+ pendingSuspendedCommitReason === IMMEDIATE_COMMIT
3637
+ ? pendingEffectsRenderEndTime
3638
+ : commitStartTime,
3639
+ commitEndTime,
3640
+ commitErrors,
3641
+ pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
3642
+ workInProgressUpdateTask,
3643
+ );
3644
+ pendingSuspendedViewTransitionReason = reason;
3645
+ pendingSuspendedCommitReason = SUSPENDED_COMMIT;
3646
+ }
3647
+}
3648
+
3649
function flushAfterMutationEffects(): void {
3650
if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) {
3651
return;
@@ -3688,6 +3710,21 @@ function flushLayoutEffects(): void {
3710
}
3711
pendingEffectsStatus = NO_PENDING_EFFECTS;
3712
3713
+ if (enableProfilerTimer && enableComponentPerformanceTrack) {
3714
+ const suspendedViewTransitionReason = pendingSuspendedViewTransitionReason;
3715
+ if (suspendedViewTransitionReason !== null) {
3716
+ // We suspended in the middle of the commit for the view transition.
3717
+ // We'll start a new commit track now.
3718
+ recordCommitTime();
3719
+ logSuspendedViewTransitionPhase(
3720
+ commitEndTime, // The start is the end of the first commit part.
3721
+ commitStartTime, // The end is the start of the second commit part.
3722
+ suspendedViewTransitionReason,
3723
+ workInProgressUpdateTask,
3724
+ );
3725
+ }
3726
+ }
3727
+
3728
const root = pendingEffectsRoot;
3729
const finishedWork = pendingFinishedWork;
3730
const lanes = pendingEffectsLanes;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+1
@@ -423,6 +423,7 @@ export function startViewTransition(
423
spawnedWorkCallback: () => void,
424
passiveCallback: () => mixed,
425
errorCallback: mixed => void,
426
+ blockedCallback: string => void, // Profiling-only
427
): null | RunningViewTransition {
428
mutationCallback();
429
layoutCallback();