Repository: facebook/react
Pull request: [Fiber] Mark error boundaries and commit phases when an error is thrown

This tracks commit phase errors and marks the component that errored as red. These also get the errors attached to the entry.

<img width="1505" alt="Screenshot 2024-12-20 at 2 40 14 PM" src="https://github.com/user-attachments/assets/cac3ead7-a024-4e33-ab27-2e95293c4299" />

In the render phase I just mark the Error Boundary that caught the error. We don't have access to the actual error since it's locked behind closures in the update queue. We could probably expose that someway.

<img width="949" alt="Screenshot 2024-12-20 at 1 49 05 PM" src="https://github.com/user-attachments/assets/3032455d-d9f2-462b-9c07-7be23663ecd3" />

Follow ups:

Since the Error Boundary doesn't commit its attempted render, we don't log those. If we did then maybe we should just mark the errored component like I do for the commit phase. We could potentially walk the list of errors and log the captured fibers and just log their entries as children.

We could also potentially walk the uncommitted Fiber tree by stashing it somewhere or even getting it from the alternate. This could be done on Suspense boundaries too to track failed hydrations.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.js
patch: @@ -98,6 +98,7 @@ import {
   Cloned,
   PerformedWork,
   ForceClientRender,
+  DidCapture,
 } from './ReactFiberFlags';
 import {
   commitStartTime,
@@ -107,14 +108,17 @@ import {
   resetComponentEffectTimers,
   pushComponentEffectStart,
   popComponentEffectStart,
+  pushComponentEffectErrors,
+  popComponentEffectErrors,
   componentEffectStartTime,
   componentEffectEndTime,
   componentEffectDuration,
+  componentEffectErrors,
 } from './ReactProfilerTimer';
 import {
   logComponentRender,
+  logComponentErrored,
   logComponentEffect,
-  logSuspenseBoundaryClientRendered,
 } from './ReactFiberPerformanceTrack';
 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
 import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue';
@@ -395,7 +399,7 @@ function commitLayoutEffectOnFiber(
   committedLanes: Lanes,
 ): void {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   // When updating this function, also update reappearLayoutEffects, which does
   // most of the same things when an offscreen tree goes from hidden -> visible.
   const flags = finishedWork.flags;
@@ -631,10 +635,12 @@ function commitLayoutEffectOnFiber(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function abortRootTransitions(
@@ -1627,7 +1633,7 @@ function commitMutationEffectsOnFiber(
   lanes: Lanes,
 ) {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   const current = finishedWork.alternate;
   const flags = finishedWork.flags;
 
@@ -2136,10 +2142,12 @@ function commitMutationEffectsOnFiber(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function commitReconciliationEffects(finishedWork: Fiber) {
@@ -2212,7 +2220,7 @@ function recursivelyTraverseLayoutEffects(
 
 export function disappearLayoutEffects(finishedWork: Fiber) {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   switch (finishedWork.tag) {
     case FunctionComponent:
     case ForwardRef:
@@ -2285,10 +2293,12 @@ export function disappearLayoutEffects(finishedWork: Fiber) {
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function recursivelyTraverseDisappearLayoutEffects(parentFiber: Fiber) {
@@ -2310,7 +2320,7 @@ export function reappearLayoutEffects(
   includeWorkInProgressEffects: boolean,
 ) {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   // Turn on layout effects in a tree that previously disappeared.
   const flags = finishedWork.flags;
   switch (finishedWork.tag) {
@@ -2461,10 +2471,12 @@ export function reappearLayoutEffects(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function recursivelyTraverseReappearLayoutEffects(
@@ -2701,26 +2713,7 @@ function commitPassiveMountOnFiber(
   endTime: number, // Profiling-only. The start time of the next Fiber or root completion.
 ): void {
   const prevEffectStart = pushComponentEffectStart();
-
-  // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
-  // render time. We do this after the fact in the passive effect to avoid the overhead of this
-  // getting in the way of the render characteristics and avoid the overhead of unwinding
-  // uncommitted renders.
-  if (
-    enableProfilerTimer &&
-    enableComponentPerformanceTrack &&
-    (finishedWork.mode & ProfileMode) !== NoMode &&
-    ((finishedWork.actualStartTime: any): number) > 0 &&
-    (finishedWork.flags & PerformedWork) !== NoFlags
-  ) {
-    logComponentRender(
-      finishedWork,
-      ((finishedWork.actualStartTime: any): number),
-      endTime,
-      inHydratedSubtree,
-    );
-  }
-
+  const prevEffectErrors = pushComponentEffectErrors();
   // When updating this function, also update reconnectPassiveEffects, which does
   // most of the same things when an offscreen tree goes from hidden -> visible,
   // or when toggling effects inside a hidden tree.
@@ -2729,6 +2722,25 @@ function commitPassiveMountOnFiber(
     case FunctionComponent:
     case ForwardRef:
     case SimpleMemoComponent: {
+      // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
+      // render time. We do this after the fact in the passive effect to avoid the overhead of this
+      // getting in the way of the render characteristics and avoid the overhead of unwinding
+      // uncommitted renders.
+      if (
+        enableProfilerTimer &&
+        enableComponentPerformanceTrack &&
+        (finishedWork.mode & ProfileMode) !== NoMode &&
+        ((finishedWork.actualStartTime: any): number) > 0 &&
+        (finishedWork.flags & PerformedWork) !== NoFlags
+      ) {
+        logComponentRender(
+          finishedWork,
+          ((finishedWork.actualStartTime: any): number),
+          endTime,
+          inHydratedSubtree,
+        );
+      }
+
       recursivelyTraversePassiveMountEffects(
         finishedRoot,
         finishedWork,
@@ -2744,6 +2756,45 @@ function commitPassiveMountOnFiber(
       }
       break;
     }
+    case ClassComponent: {
+      // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
+      // render time. We do this after the fact in the passive effect to avoid the overhead of this
+      // getting in the way of the render characteristics and avoid the overhead of unwinding
+      // uncommitted renders.
+      if (
+        enableProfilerTimer &&
+        enableComponentPerformanceTrack &&
+        (finishedWork.mode & ProfileMode) !== NoMode &&
+        ((finishedWork.actualStartTime: any): number) > 0
+      ) {
+        if ((finishedWork.flags & DidCapture) !== NoFlags) {
+          logComponentErrored(
+            finishedWork,
+            ((finishedWork.actualStartTime: any): number),
+            endTime,
+            // TODO: The captured values are all hidden inside the updater/callback closures so
+            // we can't get to the errors but they're there so we should be able to log them.
+            [],
+          );
+        } else if ((finishedWork.flags & PerformedWork) !== NoFlags) {
+          logComponentRender(
+            finishedWork,
+            ((finishedWork.actualStartTime: any): number),
+            endTime,
+            inHydratedSubtree,
+          );
+        }
+      }
+
+      recursivelyTraversePassiveMountEffects(
+        finishedRoot,
+        finishedWork,
+        committedLanes,
+        committedTransitions,
+        endTime,
+      );
+      break;
+    }
     case HostRoot: {
       const prevEffectDuration = pushNestedEffectDurations();
 
@@ -2891,7 +2942,7 @@ function commitPassiveMountOnFiber(
             // rendered boundary. Such as postpone.
             if (hydrationErrors !== null) {
               const startTime: number = (finishedWork.actualStartTime: any);
-              logSuspenseBoundaryClientRendered(
+              logComponentErrored(
                 finishedWork,
                 startTime,
                 endTime,
@@ -3074,10 +3125,12 @@ function commitPassiveMountOnFiber(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function recursivelyTraverseReconnectPassiveEffects(
@@ -3137,7 +3190,7 @@ export function reconnectPassiveEffects(
   endTime: number, // Profiling-only. The start time of the next Fiber or root completion.
 ) {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
   // render time. We do this after the fact in the passive effect to avoid the overhead of this
   // getting in the way of the render characteristics and avoid the overhead of unwinding
@@ -3331,10 +3384,12 @@ export function reconnectPassiveEffects(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function recursivelyTraverseAtomicPassiveEffects(
@@ -3611,7 +3666,7 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
 
 function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   switch (finishedWork.tag) {
     case FunctionComponent:
     case ForwardRef:
@@ -3696,10 +3751,12 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
@@ -3819,7 +3876,7 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
   nearestMountedAncestor: Fiber | null,
 ): void {
   const prevEffectStart = pushComponentEffectStart();
-
+  const prevEffectErrors = pushComponentEffectErrors();
   switch (current.tag) {
     case FunctionComponent:
     case ForwardRef:
@@ -3946,10 +4003,12 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
       componentEffectStartTime,
       componentEffectEndTime,
       componentEffectDuration,
+      componentEffectErrors,
     );
   }
 
   popComponentEffectStart(prevEffectStart);
+  popComponentEffectErrors(prevEffectErrors);
 }
 
 export function invokeLayoutEffectMountInDEV(fiber: Fiber): void {

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -13,6 +13,8 @@ import type {Lanes} from './ReactFiberLane';
 
 import type {CapturedValue} from './ReactCapturedValue';
 
+import {SuspenseComponent} from './ReactWorkTags';
+
 import getComponentNameFromFiber from './getComponentNameFromFiber';
 
 import {
@@ -159,13 +161,64 @@ export function logComponentRender(
   }
 }
 
-export function logSuspenseBoundaryClientRendered(
+export function logComponentErrored(
+  fiber: Fiber,
+  startTime: number,
+  endTime: number,
+  errors: Array<CapturedValue<mixed>>,
+): void {
+  if (supportsUserTiming) {
+    const name = getComponentNameFromFiber(fiber);
+    if (name === null) {
+      // Skip
+      return;
+    }
+    const properties = [];
+    if (__DEV__) {
+      for (let i = 0; i < errors.length; i++) {
+        const capturedValue = errors[i];
+        const error = capturedValue.value;
+        const message =
+          typeof error === 'object' &&
+          error !== null &&
+          typeof error.message === 'string'
+            ? // eslint-disable-next-line react-internal/safe-string-coercion
+              String(error.message)
+            : // eslint-disable-next-line react-internal/safe-string-coercion
+              String(error);
+        properties.push(['Error', message]);
+      }
+    }
+    performance.measure(name, {
+      start: startTime,
+      end: endTime,
+      detail: {
+        devtools: {
+          color: 'error',
+          track: COMPONENTS_TRACK,
+          tooltipText:
+            fiber.tag === SuspenseComponent
+              ? 'Hydration failed'
+              : 'Error boundary caught an error',
+          properties,
+        },
+      },
+    });
+  }
+}
+
+function logComponentEffectErrored(
   fiber: Fiber,
   startTime: number,
   endTime: number,
   errors: Array<CapturedValue<mixed>>,
 ): void {
   if (supportsUserTiming) {
+    const name = getComponentNameFromFiber(fiber);
+    if (name === null) {
+      // Skip
+      return;
+    }
     const properties = [];
     if (__DEV__) {
       for (let i = 0; i < errors.length; i++) {
@@ -182,14 +235,14 @@ export function logSuspenseBoundaryClientRendered(
         properties.push(['Error', message]);
       }
     }
-    performance.measure('Suspense', {
+    performance.measure(name, {
       start: startTime,
       end: endTime,
       detail: {
         devtools: {
           color: 'error',
           track: COMPONENTS_TRACK,
-          tooltipText: 'Hydration failed',
+          tooltipText: 'A lifecycle or effect errored',
           properties,
         },
       },
@@ -202,7 +255,12 @@ export function logComponentEffect(
   startTime: number,
   endTime: number,
   selfTime: number,
+  errors: null | Array<CapturedValue<mixed>>,
 ): void {
+  if (errors !== null) {
+    logComponentEffectErrored(fiber, startTime, endTime, errors);
+    return;
+  }
   const name = getComponentNameFromFiber(fiber);
   if (name === null) {
     // Skip
@@ -534,7 +592,54 @@ export function logSuspendedCommitPhase(
   }
 }
 
-export function logCommitPhase(startTime: number, endTime: number): void {
+export function logCommitErrored(
+  startTime: number,
+  endTime: number,
+  errors: Array<CapturedValue<mixed>>,
+  passive: boolean,
+): void {
+  if (supportsUserTiming) {
+    const properties = [];
+    if (__DEV__) {
+      for (let i = 0; i < errors.length; i++) {
+        const capturedValue = errors[i];
+        const error = capturedValue.value;
+        const message =
+          typeof error === 'object' &&
+          error !== null &&
+          typeof error.message === 'string'
+            ? // eslint-disable-next-line react-internal/safe-string-coercion
+              String(error.message)
+            : // eslint-disable-next-line react-internal/safe-string-coercion
+              String(error);
+        properties.push(['Error', message]);
+      }
+    }
+    performance.measure('Errored', {
+      start: startTime,
+      end: endTime,
+      detail: {
+        devtools: {
+          color: 'error',
+          track: reusableLaneDevToolDetails.track,
+          trackGroup: LANES_TRACK_GROUP,
+          tooltipText: passive ? 'Remaining Effects Errored' : 'Commit Errored',
+          properties,
+        },
+      },
+    });
+  }
+}
+
+export function logCommitPhase(
+  startTime: number,
+  endTime: number,
+  errors: null | Array<CapturedValue<mixed>>,
+): void {
+  if (errors !== null) {
+    logCommitErrored(startTime, endTime, errors, false);
+    return;
+  }
   if (supportsUserTiming) {
     reusableLaneDevToolDetails.color = 'secondary-dark';
     reusableLaneOptions.start = startTime;
@@ -562,7 +667,12 @@ export function logPaintYieldPhase(
 export function logPassiveCommitPhase(
   startTime: number,
   endTime: number,
+  errors: null | Array<CapturedValue<mixed>>,
 ): void {
+  if (errors !== null) {
+    logCommitErrored(startTime, endTime, errors, true);
+    return;
+  }
   if (supportsUserTiming) {
     reusableLaneDevToolDetails.color = 'secondary-dark';
     reusableLaneOptions.start = startTime;

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -253,6 +253,7 @@ import {
   renderStartTime,
   commitStartTime,
   commitEndTime,
+  commitErrors,
   recordRenderTime,
   recordCommitTime,
   recordCommitEndTime,
@@ -264,6 +265,8 @@ import {
   yieldStartTime,
   yieldReason,
   startPingTimerByLanes,
+  recordEffectError,
+  resetCommitErrors,
 } from './ReactProfilerTimer';
 
 // DEV stuff
@@ -3340,6 +3343,7 @@ function commitRootImpl(
   if (enableProfilerTimer) {
     // Mark the current commit time to be shared by all Profilers in this
     // batch. This enables them to be grouped later.
+    resetCommitErrors();
     recordCommitTime();
     if (enableComponentPerformanceTrack) {
       if (suspendedCommitReason === SUSPENDED_COMMIT) {
@@ -3433,6 +3437,7 @@ function commitRootImpl(
         ? completedRenderEndTime
         : commitStartTime,
       commitEndTime,
+      commitErrors,
     );
   }
 
@@ -3722,6 +3727,7 @@ function flushPassiveEffectsImpl(wasDelayedCommit: void | boolean) {
 
   let passiveEffectStartTime = 0;
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    resetCommitErrors();
     passiveEffectStartTime = now();
     logPaintYieldPhase(
       commitEndTime,
@@ -3758,7 +3764,11 @@ function flushPassiveEffectsImpl(wasDelayedCommit: void | boolean) {
 
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
     const passiveEffectsEndTime = now();
-    logPassiveCommitPhase(passiveEffectStartTime, passiveEffectsEndTime);
+    logPassiveCommitPhase(
+      passiveEffectStartTime,
+      passiveEffectsEndTime,
+      commitErrors,
+    );
     finalizeRender(lanes, passiveEffectsEndTime);
   }
 
@@ -3842,6 +3852,9 @@ function captureCommitPhaseErrorOnRoot(
   error: mixed,
 ) {
   const errorInfo = createCapturedValueAtFiber(error, sourceFiber);
+  if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    recordEffectError(errorInfo);
+  }
   const update = createRootErrorUpdate(
     rootFiber.stateNode,
     errorInfo,
@@ -3883,6 +3896,9 @@ export function captureCommitPhaseError(
           !isAlreadyFailedLegacyErrorBoundary(instance))
       ) {
         const errorInfo = createCapturedValueAtFiber(error, sourceFiber);
+        if (enableProfilerTimer && enableComponentPerformanceTrack) {
+          recordEffectError(errorInfo);
+        }
         const update = createClassErrorUpdate((SyncLane: Lane));
         const root = enqueueUpdate(fiber, update, (SyncLane: Lane));
         if (root !== null) {

filename: packages/react-reconciler/src/ReactProfilerTimer.js
patch: @@ -12,6 +12,9 @@ import type {Fiber} from './ReactInternalTypes';
 import type {SuspendedReason} from './ReactFiberWorkLoop';
 
 import type {Lane, Lanes} from './ReactFiberLane';
+
+import type {CapturedValue} from './ReactCapturedValue';
+
 import {
   isTransitionLane,
   isBlockingLane,
@@ -41,11 +44,13 @@ const {unstable_now: now} = Scheduler;
 export let renderStartTime: number = -0;
 export let commitStartTime: number = -0;
 export let commitEndTime: number = -0;
+export let commitErrors: null | Array<CapturedValue<mixed>> = null;
 export let profilerStartTime: number = -1.1;
 export let profilerEffectDuration: number = -0;
 export let componentEffectDuration: number = -0;
 export let componentEffectStartTime: number = -1.1;
 export let componentEffectEndTime: number = -1.1;
+export let componentEffectErrors: null | Array<CapturedValue<mixed>> = null;
 
 export let blockingClampTime: number = -0;
 export let blockingUpdateTime: number = -1.1; // First sync setState scheduled.
@@ -282,6 +287,26 @@ export function popComponentEffectStart(prevEffectStart: number): void {
   }
 }
 
+export function pushComponentEffectErrors(): null | Array<
+  CapturedValue<mixed>,
+> {
+  if (!enableProfilerTimer || !enableProfilerCommitHooks) {
+    return null;
+  }
+  const prevErrors = componentEffectErrors;
+  componentEffectErrors = null;
+  return prevErrors;
+}
+
+export function popComponentEffectErrors(
+  prevErrors: null | Array<CapturedValue<mixed>>,
+): void {
+  if (!enableProfilerTimer || !enableProfilerCommitHooks) {
+    return;
+  }
+  componentEffectErrors = prevErrors;
+}
+
 /**
  * Tracks whether the current update was a nested/cascading update (scheduled from a layout effect).
  *
@@ -416,6 +441,24 @@ export function recordEffectDuration(fiber: Fiber): void {
   }
 }
 
+export function recordEffectError(errorInfo: CapturedValue<mixed>): void {
+  if (!enableProfilerTimer || !enableProfilerCommitHooks) {
+    return;
+  }
+  if (componentEffectErrors === null) {
+    componentEffectErrors = [];
+  }
+  componentEffectErrors.push(errorInfo);
+  if (commitErrors === null) {
+    commitErrors = [];
+  }
+  commitErrors.push(errorInfo);
+}
+
+export function resetCommitErrors(): void {
+  commitErrors = null;
+}
+
 export function startEffectTimer(): void {
   if (!enableProfilerTimer || !enableProfilerCommitHooks) {
     return;