@samitouri / QOS-React / commits / ad578aa01f

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