Repository: facebook/react
Pull request: [Fiber] Highlight a Component with Deeply Equal Props in the Performance Track

Stacked on #33658 and #33659.

If we detect that a component is receiving only deeply equal objects, then we highlight it as potentially problematic and worth looking into.

<img width="1055" alt="Screenshot 2025-06-27 at 4 15 28 PM" src="https://github.com/user-attachments/assets/e96c6a05-7fff-4fd7-b59a-36ed79f8e609" />

It's fairly conservative and can bail out for a number of reasons:

- We only log it on the first parent that triggered this case since other children could be indirect causes.
- If children has changed then we bail out since this component will rerender anyway. This means that it won't warn for a lot of cases that receive plain DOM children since the DOM children won't themselves get logged.
- If the component's total render time including children is 100ms or less then we skip warning because rerendering might not be a big deal.
- We don't warn if you have shallow equality but could memoize the JSX element itself since we don't typically recommend that and React Compiler doesn't do that. It only warns if you have nested objects too.
- If the depth of the objects is deeper than like the 3 levels that we print diffs for then we wouldn't warn since we don't know if they were equal (although we might still warn on a child).
- If the component had any updates scheduled on itself (e.g. setState) then we don't warn since it would rerender anyway. This should really consider Context updates too but we don't do that atm. Technically you should still memoize the incoming props even if you also had unrelated updates since it could apply to deeper bailouts.

Changed files:
filename: packages/react-reconciler/src/ReactFiberCommitWork.js
patch: @@ -143,6 +143,8 @@ import {
   logComponentUnmount,
   logComponentReappeared,
   logComponentDisappeared,
+  pushDeepEquality,
+  popDeepEquality,
 } from './ReactFiberPerformanceTrack';
 import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode';
 import {deferHiddenCallbacks} from './ReactFiberClassUpdateQueue';
@@ -3489,6 +3491,7 @@ function commitPassiveMountOnFiber(
   const prevEffectStart = pushComponentEffectStart();
   const prevEffectDuration = pushComponentEffectDuration();
   const prevEffectErrors = pushComponentEffectErrors();
+  const prevDeepEquality = pushDeepEquality();
 
   const isViewTransitionEligible = enableViewTransition
     ? includesOnlyViewTransitionEligibleLanes(committedLanes)
@@ -3533,6 +3536,7 @@ function commitPassiveMountOnFiber(
           ((finishedWork.actualStartTime: any): number),
           endTime,
           inHydratedSubtree,
+          committedLanes,
         );
       }
 
@@ -3577,6 +3581,7 @@ function commitPassiveMountOnFiber(
             ((finishedWork.actualStartTime: any): number),
             endTime,
             inHydratedSubtree,
+            committedLanes,
           );
         }
       }
@@ -4079,6 +4084,7 @@ function commitPassiveMountOnFiber(
   popComponentEffectStart(prevEffectStart);
   popComponentEffectDuration(prevEffectDuration);
   popComponentEffectErrors(prevEffectErrors);
+  popDeepEquality(prevDeepEquality);
 }
 
 function recursivelyTraverseReconnectPassiveEffects(
@@ -4140,6 +4146,8 @@ export function reconnectPassiveEffects(
   const prevEffectStart = pushComponentEffectStart();
   const prevEffectDuration = pushComponentEffectDuration();
   const prevEffectErrors = pushComponentEffectErrors();
+  const prevDeepEquality = pushDeepEquality();
+
   // 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
@@ -4156,6 +4164,7 @@ export function reconnectPassiveEffects(
       ((finishedWork.actualStartTime: any): number),
       endTime,
       inHydratedSubtree,
+      committedLanes,
     );
   }
 
@@ -4340,6 +4349,7 @@ export function reconnectPassiveEffects(
   popComponentEffectStart(prevEffectStart);
   popComponentEffectDuration(prevEffectDuration);
   popComponentEffectErrors(prevEffectErrors);
+  popDeepEquality(prevDeepEquality);
 }
 
 function recursivelyTraverseAtomicPassiveEffects(
@@ -4389,6 +4399,8 @@ function commitAtomicPassiveEffects(
   committedTransitions: Array<Transition> | null,
   endTime: number, // Profiling-only. The start time of the next Fiber or root completion.
 ) {
+  const prevDeepEquality = pushDeepEquality();
+
   // If this component rendered in Profiling mode (DEV or in Profiler component) then log its
   // render time. A render can happen even if the subtree is offscreen.
   if (
@@ -4403,6 +4415,7 @@ function commitAtomicPassiveEffects(
       ((finishedWork.actualStartTime: any): number),
       endTime,
       inHydratedSubtree,
+      committedLanes,
     );
   }
 
@@ -4453,6 +4466,8 @@ function commitAtomicPassiveEffects(
       break;
     }
   }
+
+  popDeepEquality(prevDeepEquality);
 }
 
 export function commitPassiveUnmountEffects(finishedWork: Fiber): void {

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -24,6 +24,7 @@ import {
   includesOnlyHydrationLanes,
   includesOnlyOffscreenLanes,
   includesOnlyHydrationOrOffscreenLanes,
+  includesSomeLane,
 } from './ReactFiberLane';
 
 import {
@@ -104,6 +105,7 @@ function logComponentTrigger(
     reusableComponentOptions.start = startTime;
     reusableComponentOptions.end = endTime;
     reusableComponentDevToolDetails.color = 'warning';
+    reusableComponentDevToolDetails.tooltipText = trigger;
     reusableComponentDevToolDetails.properties = null;
     const debugTask = fiber._debugTask;
     if (__DEV__ && debugTask) {
@@ -153,11 +155,30 @@ export function logComponentDisappeared(
   logComponentTrigger(fiber, startTime, endTime, 'Disconnect');
 }
 
+let alreadyWarnedForDeepEquality = false;
+
+export function pushDeepEquality(): boolean {
+  if (__DEV__) {
+    // If this is true then we don't reset it to false because we're tracking if any
+    // parent already warned about having deep equality props in this subtree.
+    return alreadyWarnedForDeepEquality;
+  }
+  return false;
+}
+
+export function popDeepEquality(prev: boolean): void {
+  if (__DEV__) {
+    alreadyWarnedForDeepEquality = prev;
+  }
+}
+
 const reusableComponentDevToolDetails = {
   color: 'primary',
   properties: (null: null | Array<[string, string]>),
+  tooltipText: '',
   track: COMPONENTS_TRACK,
 };
+
 const reusableComponentOptions = {
   start: -0,
   end: -0,
@@ -168,11 +189,17 @@ const reusableComponentOptions = {
 
 const resuableChangedPropsEntry = ['Changed Props', ''];
 
+const DEEP_EQUALITY_WARNING =
+  'This component received deeply equal props. It might benefit from useMemo or the React Compiler in its owner.';
+
+const reusableDeeplyEqualPropsEntry = ['Changed Props', DEEP_EQUALITY_WARNING];
+
 export function logComponentRender(
   fiber: Fiber,
   startTime: number,
   endTime: number,
   wasHydrated: boolean,
+  committedLanes: Lanes,
 ): void {
   const name = getComponentNameFromFiber(fiber);
   if (name === null) {
@@ -211,17 +238,36 @@ export function logComponentRender(
       ) {
         // If this is an update, we'll diff the props and emit which ones changed.
         const properties: Array<[string, string]> = [resuableChangedPropsEntry];
-        addObjectDiffToProperties(
+        const isDeeplyEqual = addObjectDiffToProperties(
           alternate.memoizedProps,
           props,
           properties,
           0,
         );
         if (properties.length > 1) {
+          if (
+            isDeeplyEqual &&
+            !alreadyWarnedForDeepEquality &&
+            !includesSomeLane(alternate.lanes, committedLanes) &&
+            (fiber.actualDuration: any) > 100
+          ) {
+            alreadyWarnedForDeepEquality = true;
+            // This is the first component in a subtree which rerendered with deeply equal props
+            // and didn't have its own work scheduled and took a non-trivial amount of time.
+            // We highlight this for further inspection.
+            // Note that we only consider this case if properties.length > 1 which it will only
+            // be if we have emitted any diffs. We'd only emit diffs if there were any nested
+            // equal objects. Therefore, we don't warn for simple shallow equality.
+            properties[0] = reusableDeeplyEqualPropsEntry;
+            reusableComponentDevToolDetails.color = 'warning';
+            reusableComponentDevToolDetails.tooltipText = DEEP_EQUALITY_WARNING;
+          } else {
+            reusableComponentDevToolDetails.color = color;
+            reusableComponentDevToolDetails.tooltipText = name;
+          }
+          reusableComponentDevToolDetails.properties = properties;
           reusableComponentOptions.start = startTime;
           reusableComponentOptions.end = endTime;
-          reusableComponentDevToolDetails.color = color;
-          reusableComponentDevToolDetails.properties = properties;
           debugTask.run(
             // $FlowFixMe[method-unbinding]
             performance.measure.bind(

filename: packages/shared/ReactPerformanceTrackProperties.js
patch: @@ -161,7 +161,13 @@ export function addValueToProperties(
           if (value.status === 'fulfilled') {
             // Print the inner value
             const idx = properties.length;
-            addValueToProperties(propertyName, value.value, properties, indent);
+            addValueToProperties(
+              propertyName,
+              value.value,
+              properties,
+              indent,
+              prefix,
+            );
             if (properties.length > idx) {
               // Wrap the value or type in Promise descriptor.
               const insertedEntry = properties[idx];
@@ -177,6 +183,7 @@ export function addValueToProperties(
               value.reason,
               properties,
               indent,
+              prefix,
             );
             if (properties.length > idx) {
               // Wrap the value or type in Promise descriptor.
@@ -242,13 +249,15 @@ export function addObjectDiffToProperties(
   next: Object,
   properties: Array<[string, string]>,
   indent: number,
-): void {
+): boolean {
   // Note: We diff even non-owned properties here but things that are shared end up just the same.
   // If a property is added or removed, we just emit the property name and omit the value it had.
   // Mainly for performance. We need to minimize to only relevant information.
+  let isDeeplyEqual = true;
   for (const key in prev) {
     if (!(key in next)) {
       properties.push([REMOVED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
+      isDeeplyEqual = false;
     }
   }
   for (const key in next) {
@@ -262,6 +271,7 @@ export function addObjectDiffToProperties(
           // elsewhere but still mark it as a cause of render.
           const line = '\xa0\xa0'.repeat(indent) + key;
           properties.push([REMOVED + line, '\u2026'], [ADDED + line, '\u2026']);
+          isDeeplyEqual = false;
           continue;
         }
         if (indent >= 3) {
@@ -286,6 +296,7 @@ export function addObjectDiffToProperties(
               const line = '\xa0\xa0'.repeat(indent) + key;
               const desc = '<' + typeName + ' \u2026 />';
               properties.push([REMOVED + line, desc], [ADDED + line, desc]);
+              isDeeplyEqual = false;
               continue;
             }
           } else {
@@ -304,13 +315,15 @@ export function addObjectDiffToProperties(
               ];
               properties.push(entry);
               const prevLength = properties.length;
-              addObjectDiffToProperties(
+              const nestedEqual = addObjectDiffToProperties(
                 prevValue,
                 nextValue,
                 properties,
                 indent + 1,
               );
-              if (prevLength === properties.length) {
+              if (!nestedEqual) {
+                isDeeplyEqual = false;
+              } else if (prevLength === properties.length) {
                 // Nothing notably changed inside the nested object. So this is only a change in reference
                 // equality. Let's note it.
                 entry[1] =
@@ -349,9 +362,12 @@ export function addObjectDiffToProperties(
         // Otherwise, emit the change in property and the values.
         addValueToProperties(key, prevValue, properties, indent, REMOVED);
         addValueToProperties(key, nextValue, properties, indent, ADDED);
+        isDeeplyEqual = false;
       }
     } else {
       properties.push([ADDED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
+      isDeeplyEqual = false;
     }
   }
+  return isDeeplyEqual;
 }