Repository: facebook/react
Pull request: Log Suspended startViewTransition Phase

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" />


Changed files:
filename: packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
patch: @@ -125,6 +125,7 @@ import {
   enableViewTransition,
   enableHydrationChangeEvent,
   enableFragmentRefsScrollIntoView,
+  enableProfilerTimer,
 } from 'shared/ReactFeatureFlags';
 import {
   HostComponent,
@@ -2098,6 +2099,7 @@ export function startViewTransition(
   spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
   errorCallback: mixed => void,
+  blockedCallback: string => void, // Profiling-only
 ): null | RunningViewTransition {
   const ownerDocument: Document =
     rootContainer.nodeType === DOCUMENT_NODE
@@ -2131,10 +2133,10 @@ export function startViewTransition(
             blockingPromises.push(ownerDocument.fonts.ready);
           }
         }
+        const blockingIndexSnapshot = blockingPromises.length;
         if (suspendedState !== null) {
           // Suspend on any images that still haven't loaded and are in the viewport.
           const suspenseyImages = suspendedState.suspenseyImages;
-          const blockingIndexSnapshot = blockingPromises.length;
           let imgBytes = 0;
           for (let i = 0; i < suspenseyImages.length; i++) {
             const suspenseyImage = suspenseyImages[i];
@@ -2162,6 +2164,15 @@ export function startViewTransition(
           }
         }
         if (blockingPromises.length > 0) {
+          if (enableProfilerTimer) {
+            const blockedReason =
+              blockingIndexSnapshot > 0
+                ? blockingPromises.length > blockingIndexSnapshot
+                  ? 'Waiting on Fonts and Images'
+                  : 'Waiting on Fonts'
+                : 'Waiting on Images';
+            blockedCallback(blockedReason);
+          }
           const blockingReady = Promise.race([
             Promise.all(blockingPromises),
             new Promise(resolve =>

filename: packages/react-native-renderer/src/ReactFiberConfigNative.js
patch: @@ -673,6 +673,7 @@ export function startViewTransition(
   spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
   errorCallback: mixed => void,
+  blockedCallback: string => void, // Profiling-only
 ): null | RunningViewTransition {
   mutationCallback();
   layoutCallback();

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -1254,6 +1254,45 @@ export function logSuspendedCommitPhase(
   }
 }
 
+export function logSuspendedViewTransitionPhase(
+  startTime: number,
+  endTime: number,
+  reason: string,
+  debugTask: null | ConsoleTask,
+): void {
+  // This means the commit was suspended on CSS or images.
+  if (supportsUserTiming) {
+    if (endTime <= startTime) {
+      return;
+    }
+    // TODO: Include the exact reason and URLs of what resources suspended.
+    // TODO: This might also be Suspended while waiting on a View Transition.
+    if (__DEV__ && debugTask) {
+      debugTask.run(
+        // $FlowFixMe[method-unbinding]
+        console.timeStamp.bind(
+          console,
+          reason,
+          startTime,
+          endTime,
+          currentTrack,
+          LANES_TRACK_GROUP,
+          'secondary-light',
+        ),
+      );
+    } else {
+      console.timeStamp(
+        reason,
+        startTime,
+        endTime,
+        currentTrack,
+        LANES_TRACK_GROUP,
+        'secondary-light',
+      );
+    }
+  }
+}
+
 export function logCommitErrored(
   startTime: number,
   endTime: number,

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -81,6 +81,7 @@ import {
   logSuspendedWithDelayPhase,
   logSuspenseThrottlePhase,
   logSuspendedCommitPhase,
+  logSuspendedViewTransitionPhase,
   logCommitPhase,
   logPaintYieldPhase,
   logStartViewTransitionYieldPhase,
@@ -704,6 +705,7 @@ let pendingTransitionTypes: null | TransitionTypes = null;
 let pendingDidIncludeRenderPhaseUpdate: boolean = false;
 let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
 let pendingDelayedCommitReason: DelayedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
+let pendingSuspendedViewTransitionReason: null | string = null; // Profiling-only
 
 // Use these to prevent an infinite loop of nested updates
 const NESTED_UPDATE_LIMIT = 50;
@@ -3445,6 +3447,7 @@ function commitRoot(
     pendingEffectsRenderEndTime = completedRenderEndTime;
     pendingSuspendedCommitReason = suspendedCommitReason;
     pendingDelayedCommitReason = IMMEDIATE_COMMIT;
+    pendingSuspendedViewTransitionReason = null;
   }
 
   if (enableGestureTransition && isGestureRender(lanes)) {
@@ -3604,6 +3607,7 @@ function commitRoot(
       flushSpawnedWork,
       flushPassiveEffects,
       reportViewTransitionError,
+      enableProfilerTimer ? suspendedViewTransition : (null: any),
     );
   } else {
     // Flush synchronously.
@@ -3624,6 +3628,24 @@ function reportViewTransitionError(error: mixed) {
   onRecoverableError(error, makeErrorInfo(null));
 }
 
+function suspendedViewTransition(reason: string): void {
+  if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    // We'll split the commit into two phases, because we're suspended in the middle.
+    recordCommitEndTime();
+    logCommitPhase(
+      pendingSuspendedCommitReason === IMMEDIATE_COMMIT
+        ? pendingEffectsRenderEndTime
+        : commitStartTime,
+      commitEndTime,
+      commitErrors,
+      pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
+      workInProgressUpdateTask,
+    );
+    pendingSuspendedViewTransitionReason = reason;
+    pendingSuspendedCommitReason = SUSPENDED_COMMIT;
+  }
+}
+
 function flushAfterMutationEffects(): void {
   if (pendingEffectsStatus !== PENDING_AFTER_MUTATION_PHASE) {
     return;
@@ -3688,6 +3710,21 @@ function flushLayoutEffects(): void {
   }
   pendingEffectsStatus = NO_PENDING_EFFECTS;
 
+  if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    const suspendedViewTransitionReason = pendingSuspendedViewTransitionReason;
+    if (suspendedViewTransitionReason !== null) {
+      // We suspended in the middle of the commit for the view transition.
+      // We'll start a new commit track now.
+      recordCommitTime();
+      logSuspendedViewTransitionPhase(
+        commitEndTime, // The start is the end of the first commit part.
+        commitStartTime, // The end is the start of the second commit part.
+        suspendedViewTransitionReason,
+        workInProgressUpdateTask,
+      );
+    }
+  }
+
   const root = pendingEffectsRoot;
   const finishedWork = pendingFinishedWork;
   const lanes = pendingEffectsLanes;

filename: packages/react-test-renderer/src/ReactFiberConfigTestHost.js
patch: @@ -423,6 +423,7 @@ export function startViewTransition(
   spawnedWorkCallback: () => void,
   passiveCallback: () => mixed,
   errorCallback: mixed => void,
+  blockedCallback: string => void, // Profiling-only
 ): null | RunningViewTransition {
   mutationCallback();
   layoutCallback();