Repository: facebook/react
Pull request: [Fiber] Mark cascading updates

A common source of performance problems is due to cascading renders from calling `setState` in `useLayoutEffect` or `useEffect`. This marks the entry from the update to when we start the render as red and `"Cascade"` to highlight this.

<img width="964" alt="Screenshot 2024-12-19 at 10 54 59 PM" src="https://github.com/user-attachments/assets/2bfa91e6-1dc1-4b7f-a659-50aaf2a97e83" />

In addition to this case, there's another case where you call `setState` multiple times in the same event causing multiple renders. This might be due to multiple `flushSync`, or spawned a microtasks from a `useLayoutEffect`. In theory it could also be from a microtask scheduled after the first `setState`. This one we can only detect if it's from an event that has a `window.event` since otherwise it's hard to know if we're still in the same event.

<img width="1210" alt="Screenshot 2024-12-19 at 11 38 44 PM" src="https://github.com/user-attachments/assets/ee188bc4-8ebb-4e95-b5a5-4d724856c27d" />

I decided against making a ping in a microtask considered a cascade. Because that should ideally be using the Suspense Optimization and so wouldn't be considered multi-pass.

<img width="1284" alt="Screenshot 2024-12-19 at 11 07 30 PM" src="https://github.com/user-attachments/assets/2d173750-a475-41a0-b6cf-679d15c4ca97" />

We might consider making the whole render phase and maybe commit phase red but that should maybe reserved for actual errors. The "Blocked" phase really represents the `setState` and so will have the stack trace of the first update.

Changed files:
filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -276,11 +276,15 @@ export function logBlockingStart(
   eventTime: number,
   eventType: null | string,
   eventIsRepeat: boolean,
+  isSpawnedUpdate: boolean,
   renderStartTime: number,
   lanes: Lanes,
 ): void {
   if (supportsUserTiming) {
     reusableLaneDevToolDetails.track = 'Blocking';
+    // If a blocking update was spawned within render or an effect, that's considered a cascading render.
+    // If you have a second blocking update within the same event, that suggests multiple flushSync or
+    // setState in a microtask which is also considered a cascade.
     if (eventTime > 0 && eventType !== null) {
       // Log the time from the event timeStamp until we called setState.
       reusableLaneDevToolDetails.color = eventIsRepeat
@@ -295,14 +299,17 @@ export function logBlockingStart(
     }
     if (updateTime > 0) {
       // Log the time from when we called setState until we started rendering.
-      reusableLaneDevToolDetails.color = includesOnlyHydrationOrOffscreenLanes(
-        lanes,
-      )
-        ? 'tertiary-light'
-        : 'primary-light';
+      reusableLaneDevToolDetails.color = isSpawnedUpdate
+        ? 'error'
+        : includesOnlyHydrationOrOffscreenLanes(lanes)
+          ? 'tertiary-light'
+          : 'primary-light';
       reusableLaneOptions.start = updateTime;
       reusableLaneOptions.end = renderStartTime;
-      performance.measure('Blocked', reusableLaneOptions);
+      performance.measure(
+        isSpawnedUpdate ? 'Cascade' : 'Blocked',
+        reusableLaneOptions,
+      );
     }
   }
 }

filename: packages/react-reconciler/src/ReactFiberRootScheduler.js
patch: @@ -128,12 +128,12 @@ export function ensureRootIsScheduled(root: FiberRoot): void {
     // We're inside an `act` scope.
     if (!didScheduleMicrotask_act) {
       didScheduleMicrotask_act = true;
-      scheduleImmediateTask(processRootScheduleInMicrotask);
+      scheduleImmediateRootScheduleTask();
     }
   } else {
     if (!didScheduleMicrotask) {
       didScheduleMicrotask = true;
-      scheduleImmediateTask(processRootScheduleInMicrotask);
+      scheduleImmediateRootScheduleTask();
     }
   }
 
@@ -229,13 +229,17 @@ function flushSyncWorkAcrossRoots_impl(
   isFlushingWork = false;
 }
 
-function processRootScheduleInMicrotask() {
+function processRootScheduleInImmediateTask() {
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
     // Track the currently executing event if there is one so we can ignore this
     // event when logging events.
     trackSchedulerEvent();
   }
 
+  processRootScheduleInMicrotask();
+}
+
+function processRootScheduleInMicrotask() {
   // This function is always called inside a microtask. It should never be
   // called synchronously.
   didScheduleMicrotask = false;
@@ -558,15 +562,15 @@ function cancelCallback(callbackNode: mixed) {
   }
 }
 
-function scheduleImmediateTask(cb: () => mixed) {
+function scheduleImmediateRootScheduleTask() {
   if (__DEV__ && ReactSharedInternals.actQueue !== null) {
     // Special case: Inside an `act` scope, we push microtasks to the fake `act`
     // callback queue. This is because we currently support calling `act`
     // without awaiting the result. The plan is to deprecate that, and require
     // that you always await the result so that the microtasks have a chance to
     // run. But it hasn't happened yet.
     ReactSharedInternals.actQueue.push(() => {
-      cb();
+      processRootScheduleInMicrotask();
       return null;
     });
   }
@@ -588,14 +592,20 @@ function scheduleImmediateTask(cb: () => mixed) {
         // wrong semantically but it prevents an infinite loop. The bug is
         // Safari's, not ours, so we just do our best to not crash even though
         // the behavior isn't completely correct.
-        Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
+        Scheduler_scheduleCallback(
+          ImmediateSchedulerPriority,
+          processRootScheduleInImmediateTask,
+        );
         return;
       }
-      cb();
+      processRootScheduleInMicrotask();
     });
   } else {
     // If microtasks are not supported, use Scheduler.
-    Scheduler_scheduleCallback(ImmediateSchedulerPriority, cb);
+    Scheduler_scheduleCallback(
+      ImmediateSchedulerPriority,
+      processRootScheduleInImmediateTask,
+    );
   }
 }
 

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -236,6 +236,7 @@ import {
   blockingEventTime,
   blockingEventType,
   blockingEventIsRepeat,
+  blockingSpawnedUpdate,
   blockingSuspendedTime,
   transitionClampTime,
   transitionStartTime,
@@ -1664,11 +1665,8 @@ export function flushSyncWork(): boolean {
 
 export function isAlreadyRendering(): boolean {
   // Used by the renderer to print a warning if certain APIs are called from
-  // the wrong context.
-  return (
-    __DEV__ &&
-    (executionContext & (RenderContext | CommitContext)) !== NoContext
-  );
+  // the wrong context, and for profiling warnings.
+  return (executionContext & (RenderContext | CommitContext)) !== NoContext;
 }
 
 export function isInvalidExecutionContextForEventFunction(): boolean {
@@ -1797,6 +1795,7 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
         clampedEventTime,
         blockingEventType,
         blockingEventIsRepeat,
+        blockingSpawnedUpdate,
         renderStartTime,
         lanes,
       );

filename: packages/react-reconciler/src/ReactProfilerTimer.js
patch: @@ -30,6 +30,8 @@ import {
   enableComponentPerformanceTrack,
 } from 'shared/ReactFeatureFlags';
 
+import {isAlreadyRendering} from './ReactFiberWorkLoop';
+
 // Intentionally not named imports because Rollup would use dynamic dispatch for
 // CommonJS interop named imports.
 import * as Scheduler from 'scheduler';
@@ -50,6 +52,7 @@ export let blockingUpdateTime: number = -1.1; // First sync setState scheduled.
 export let blockingEventTime: number = -1.1; // Event timeStamp of the first setState.
 export let blockingEventType: null | string = null; // Event type of the first setState.
 export let blockingEventIsRepeat: boolean = false;
+export let blockingSpawnedUpdate: boolean = false;
 export let blockingSuspendedTime: number = -1.1;
 // TODO: This should really be one per Transition lane.
 export let transitionClampTime: number = -0;
@@ -78,13 +81,21 @@ export function startUpdateTimerByLane(lane: Lane): void {
   if (isSyncLane(lane) || isBlockingLane(lane)) {
     if (blockingUpdateTime < 0) {
       blockingUpdateTime = now();
+      if (isAlreadyRendering()) {
+        blockingSpawnedUpdate = true;
+      }
       const newEventTime = resolveEventTimeStamp();
       const newEventType = resolveEventType();
       if (
         newEventTime !== blockingEventTime ||
         newEventType !== blockingEventType
       ) {
         blockingEventIsRepeat = false;
+      } else if (newEventType !== null) {
+        // If this is a second update in the same event, we treat it as a spawned update.
+        // This might be a microtask spawned from useEffect, multiple flushSync or
+        // a setState in a microtask spawned after the first setState. Regardless it's bad.
+        blockingSpawnedUpdate = true;
       }
       blockingEventTime = newEventTime;
       blockingEventType = newEventType;
@@ -141,6 +152,7 @@ export function clearBlockingTimers(): void {
   blockingUpdateTime = -1.1;
   blockingSuspendedTime = -1.1;
   blockingEventIsRepeat = true;
+  blockingSpawnedUpdate = false;
 }
 
 export function startAsyncTransitionTimer(): void {