Repository: facebook/react
Pull request: [Fiber] Show Diff Render Props in Performance Track in DEV

<img width="634" alt="Screenshot 2025-06-27 at 1 13 20 PM" src="https://github.com/user-attachments/assets/dc8c488b-4a23-453f-918f-36b245364934" />

We have to be careful with performance in DEV. It can slow down DX since these are ran whether you're currently running a performance trace or not. It can also show up as misleading since these add time to the "Remaining Effects" entry.

I'm not adding all props to the entries. Instead, I'm only adding the changed props after diffing and none for initial mount. I'm trying to as much as possible pick a fast path when possible. I'm also only logging this for the "render" entries and not the effects. If we did something for effects, it would be more like checking with dep changed.

This could still have a negative effect on dev performance since we're now also using the slower `performance.measure` API when there's a diff.

Changed files:
filename: packages/react-client/src/ReactFlightPerformanceTrack.js
patch: @@ -103,10 +103,10 @@ export function logComponentRender(
     if (__DEV__ && debugTask) {
       const properties: Array<[string, string]> = [];
       if (componentInfo.key != null) {
-        addValueToProperties('key', componentInfo.key, properties, 0);
+        addValueToProperties('key', componentInfo.key, properties, 0, '');
       }
       if (componentInfo.props != null) {
-        addObjectToProperties(componentInfo.props, properties, 0);
+        addObjectToProperties(componentInfo.props, properties, 0, '');
       }
       debugTask.run(
         // $FlowFixMe[method-unbinding]
@@ -158,10 +158,10 @@ export function logComponentAborted(
         ],
       ];
       if (componentInfo.key != null) {
-        addValueToProperties('key', componentInfo.key, properties, 0);
+        addValueToProperties('key', componentInfo.key, properties, 0, '');
       }
       if (componentInfo.props != null) {
-        addObjectToProperties(componentInfo.props, properties, 0);
+        addObjectToProperties(componentInfo.props, properties, 0, '');
       }
       performance.measure(entryName, {
         start: startTime < 0 ? 0 : startTime,
@@ -215,10 +215,10 @@ export function logComponentErrored(
             String(error);
       const properties = [['Error', message]];
       if (componentInfo.key != null) {
-        addValueToProperties('key', componentInfo.key, properties, 0);
+        addValueToProperties('key', componentInfo.key, properties, 0, '');
       }
       if (componentInfo.props != null) {
-        addObjectToProperties(componentInfo.props, properties, 0);
+        addObjectToProperties(componentInfo.props, properties, 0, '');
       }
       performance.measure(entryName, {
         start: startTime < 0 ? 0 : startTime,
@@ -423,9 +423,9 @@ export function logComponentAwait(
     if (__DEV__ && debugTask) {
       const properties: Array<[string, string]> = [];
       if (typeof value === 'object' && value !== null) {
-        addObjectToProperties(value, properties, 0);
+        addObjectToProperties(value, properties, 0, '');
       } else if (value !== undefined) {
-        addValueToProperties('Resolved', value, properties, 0);
+        addValueToProperties('Resolved', value, properties, 0, '');
       }
       debugTask.run(
         // $FlowFixMe[method-unbinding]
@@ -525,9 +525,9 @@ export function logIOInfo(
     if (__DEV__ && debugTask) {
       const properties: Array<[string, string]> = [];
       if (typeof value === 'object' && value !== null) {
-        addObjectToProperties(value, properties, 0);
+        addObjectToProperties(value, properties, 0, '');
       } else if (value !== undefined) {
-        addValueToProperties('Resolved', value, properties, 0);
+        addValueToProperties('Resolved', value, properties, 0, '');
       }
       debugTask.run(
         // $FlowFixMe[method-unbinding]

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -29,33 +29,26 @@ import {
 import {
   addValueToProperties,
   addObjectToProperties,
+  addObjectDiffToProperties,
 } from 'shared/ReactPerformanceTrackProperties';
 
 import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
 
 const supportsUserTiming =
   enableProfilerTimer &&
   typeof console !== 'undefined' &&
-  typeof console.timeStamp === 'function';
+  typeof console.timeStamp === 'function' &&
+  (!__DEV__ ||
+    // In DEV we also rely on performance.measure
+    (typeof performance !== 'undefined' &&
+      // $FlowFixMe[method-unbinding]
+      typeof performance.measure === 'function'));
 
 const COMPONENTS_TRACK = 'Components ⚛';
 const LANES_TRACK_GROUP = 'Scheduler ⚛';
 
 let currentTrack: string = 'Blocking'; // Lane
 
-const reusableLaneDevToolDetails = {
-  color: 'primary',
-  track: 'Blocking', // Lane
-  trackGroup: LANES_TRACK_GROUP,
-};
-const reusableLaneOptions = {
-  start: -0,
-  end: -0,
-  detail: {
-    devtools: reusableLaneDevToolDetails,
-  },
-};
-
 export function setCurrentTrackFromLanes(lanes: Lanes): void {
   currentTrack = getGroupNameOfHighestPriorityLane(lanes);
 }
@@ -166,6 +159,21 @@ export function logComponentDisappeared(
   logComponentTrigger(fiber, startTime, endTime, 'Disconnect');
 }
 
+const reusableComponentDevToolDetails = {
+  color: 'primary',
+  properties: (null: null | Array<[string, string]>),
+  track: COMPONENTS_TRACK,
+};
+const reusableComponentOptions = {
+  start: -0,
+  end: -0,
+  detail: {
+    devtools: reusableComponentDevToolDetails,
+  },
+};
+
+const resuableChangedPropsEntry = ['Changed Props', ''];
+
 export function logComponentRender(
   fiber: Fiber,
   startTime: number,
@@ -178,8 +186,9 @@ export function logComponentRender(
     return;
   }
   if (supportsUserTiming) {
+    const alternate = fiber.alternate;
     let selfTime: number = (fiber.actualDuration: any);
-    if (fiber.alternate === null || fiber.alternate.child !== fiber.child) {
+    if (alternate === null || alternate.child !== fiber.child) {
       for (let child = fiber.child; child !== null; child = child.sibling) {
         selfTime -= (child.actualDuration: any);
       }
@@ -200,6 +209,36 @@ export function logComponentRender(
             : 'error';
     const debugTask = fiber._debugTask;
     if (__DEV__ && debugTask) {
+      const props = fiber.memoizedProps;
+      if (
+        props !== null &&
+        alternate !== null &&
+        alternate.memoizedProps !== props
+      ) {
+        // If this is an update, we'll diff the props and emit which ones changed.
+        const properties: Array<[string, string]> = [resuableChangedPropsEntry];
+        addObjectDiffToProperties(
+          alternate.memoizedProps,
+          props,
+          properties,
+          0,
+        );
+        if (properties.length > 1) {
+          reusableComponentOptions.start = startTime;
+          reusableComponentOptions.end = endTime;
+          reusableComponentDevToolDetails.color = color;
+          reusableComponentDevToolDetails.properties = properties;
+          debugTask.run(
+            // $FlowFixMe[method-unbinding]
+            performance.measure.bind(
+              performance,
+              name,
+              reusableComponentOptions,
+            ),
+          );
+          return;
+        }
+      }
       debugTask.run(
         // $FlowFixMe[method-unbinding]
         console.timeStamp.bind(
@@ -237,12 +276,7 @@ export function logComponentErrored(
       // Skip
       return;
     }
-    if (
-      __DEV__ &&
-      typeof performance !== 'undefined' &&
-      // $FlowFixMe[method-unbinding]
-      typeof performance.measure === 'function'
-    ) {
+    if (__DEV__) {
       let debugTask: ?ConsoleTask = null;
       const properties: Array<[string, string]> = [];
       for (let i = 0; i < errors.length; i++) {
@@ -267,10 +301,10 @@ export function logComponentErrored(
         properties.push(['Error', message]);
       }
       if (fiber.key !== null) {
-        addValueToProperties('key', fiber.key, properties, 0);
+        addValueToProperties('key', fiber.key, properties, 0, '');
       }
       if (fiber.memoizedProps !== null) {
-        addObjectToProperties(fiber.memoizedProps, properties, 0);
+        addObjectToProperties(fiber.memoizedProps, properties, 0, '');
       }
       if (debugTask == null) {
         // If the captured values don't have a debug task, fallback to the
@@ -325,12 +359,7 @@ function logComponentEffectErrored(
       // Skip
       return;
     }
-    if (
-      __DEV__ &&
-      typeof performance !== 'undefined' &&
-      // $FlowFixMe[method-unbinding]
-      typeof performance.measure === 'function'
-    ) {
+    if (__DEV__) {
       const properties: Array<[string, string]> = [];
       for (let i = 0; i < errors.length; i++) {
         const capturedValue = errors[i];
@@ -346,10 +375,10 @@ function logComponentEffectErrored(
         properties.push(['Error', message]);
       }
       if (fiber.key !== null) {
-        addValueToProperties('key', fiber.key, properties, 0);
+        addValueToProperties('key', fiber.key, properties, 0, '');
       }
       if (fiber.memoizedProps !== null) {
-        addObjectToProperties(fiber.memoizedProps, properties, 0);
+        addObjectToProperties(fiber.memoizedProps, properties, 0, '');
       }
       const options = {
         start: startTime,
@@ -815,12 +844,7 @@ export function logRecoveredRenderPhase(
   hydrationFailed: boolean,
 ): void {
   if (supportsUserTiming) {
-    if (
-      __DEV__ &&
-      typeof performance !== 'undefined' &&
-      // $FlowFixMe[method-unbinding]
-      typeof performance.measure === 'function'
-    ) {
+    if (__DEV__) {
       const properties: Array<[string, string]> = [];
       for (let i = 0; i < recoverableErrors.length; i++) {
         const capturedValue = recoverableErrors[i];
@@ -939,12 +963,7 @@ export function logCommitErrored(
   passive: boolean,
 ): void {
   if (supportsUserTiming) {
-    if (
-      __DEV__ &&
-      typeof performance !== 'undefined' &&
-      // $FlowFixMe[method-unbinding]
-      typeof performance.measure === 'function'
-    ) {
+    if (__DEV__) {
       const properties: Array<[string, string]> = [];
       for (let i = 0; i < errors.length; i++) {
         const capturedValue = errors[i];
@@ -997,8 +1016,6 @@ export function logCommitPhase(
     return;
   }
   if (supportsUserTiming) {
-    reusableLaneOptions.start = startTime;
-    reusableLaneOptions.end = endTime;
     console.timeStamp(
       'Commit',
       startTime,

filename: packages/shared/ReactPerformanceTrackProperties.js
patch: @@ -53,11 +53,12 @@ export function addObjectToProperties(
   object: Object,
   properties: Array<[string, string]>,
   indent: number,
+  prefix: string,
 ): void {
   for (const key in object) {
     if (hasOwnProperty.call(object, key) && key[0] !== '_') {
       const value = object[key];
-      addValueToProperties(key, value, properties, indent);
+      addValueToProperties(key, value, properties, indent, prefix);
     }
   }
 }
@@ -67,6 +68,7 @@ export function addValueToProperties(
   value: mixed,
   properties: Array<[string, string]>,
   indent: number,
+  prefix: string,
 ): void {
   let desc;
   switch (typeof value) {
@@ -94,11 +96,11 @@ export function addValueToProperties(
             break;
           }
           properties.push([
-            '\xa0\xa0'.repeat(indent) + propertyName,
+            prefix + '\xa0\xa0'.repeat(indent) + propertyName,
             '<' + typeName,
           ]);
           if (key !== null) {
-            addValueToProperties('key', key, properties, indent + 1);
+            addValueToProperties('key', key, properties, indent + 1, prefix);
           }
           let hasChildren = false;
           for (const propKey in props) {
@@ -118,6 +120,7 @@ export function addValueToProperties(
                 props[propKey],
                 properties,
                 indent + 1,
+                prefix,
               );
             }
           }
@@ -137,10 +140,19 @@ export function addValueToProperties(
             desc = JSON.stringify(array);
             break;
           } else if (kind === ENTRIES_ARRAY) {
-            properties.push(['\xa0\xa0'.repeat(indent) + propertyName, '']);
+            properties.push([
+              prefix + '\xa0\xa0'.repeat(indent) + propertyName,
+              '',
+            ]);
             for (let i = 0; i < array.length; i++) {
               const entry = array[i];
-              addValueToProperties(entry[0], entry[1], properties, indent + 1);
+              addValueToProperties(
+                entry[0],
+                entry[1],
+                properties,
+                indent + 1,
+                prefix,
+              );
             }
             return;
           }
@@ -186,11 +198,11 @@ export function addValueToProperties(
           }
         }
         properties.push([
-          '\xa0\xa0'.repeat(indent) + propertyName,
+          prefix + '\xa0\xa0'.repeat(indent) + propertyName,
           objectName === 'Object' ? (indent < 3 ? '' : '\u2026') : objectName,
         ]);
         if (indent < 3) {
-          addObjectToProperties(value, properties, indent + 1);
+          addObjectToProperties(value, properties, indent + 1, prefix);
         }
         return;
       }
@@ -218,5 +230,128 @@ export function addValueToProperties(
       // eslint-disable-next-line react-internal/safe-string-coercion
       desc = String(value);
   }
-  properties.push(['\xa0\xa0'.repeat(indent) + propertyName, desc]);
+  properties.push([prefix + '\xa0\xa0'.repeat(indent) + propertyName, desc]);
+}
+
+const REMOVED = '\u2013\xa0';
+const ADDED = '+\xa0';
+const UNCHANGED = '\u2007\xa0';
+
+export function addObjectDiffToProperties(
+  prev: Object,
+  next: Object,
+  properties: Array<[string, string]>,
+  indent: number,
+): void {
+  // 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.
+  for (const key in prev) {
+    if (!(key in next)) {
+      properties.push([REMOVED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
+    }
+  }
+  for (const key in next) {
+    if (key in prev) {
+      const prevValue = prev[key];
+      const nextValue = next[key];
+      if (prevValue !== nextValue) {
+        if (indent === 0 && key === 'children') {
+          // Omit any change inside the top level children prop since it's expected to change
+          // with any change to children of the component and their props will be logged
+          // 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']);
+          continue;
+        }
+        if (indent >= 3) {
+          // Just fallthrough to print the two values if we're deep.
+          // This will skip nested properties of the objects.
+        } else if (
+          typeof prevValue === 'object' &&
+          typeof nextValue === 'object' &&
+          prevValue !== null &&
+          nextValue !== null &&
+          prevValue.$$typeof === nextValue.$$typeof
+        ) {
+          if (nextValue.$$typeof === REACT_ELEMENT_TYPE) {
+            if (
+              prevValue.type === nextValue.type &&
+              prevValue.key === nextValue.key
+            ) {
+              // If the only thing that has changed is the props of a nested element, then
+              // we omit the props because it is likely to be represented as a diff elsewhere.
+              const typeName =
+                getComponentNameFromType(nextValue.type) || '\u2026';
+              const line = '\xa0\xa0'.repeat(indent) + key;
+              const desc = '<' + typeName + ' \u2026 />';
+              properties.push([REMOVED + line, desc], [ADDED + line, desc]);
+              continue;
+            }
+          } else {
+            // $FlowFixMe[method-unbinding]
+            const prevKind = Object.prototype.toString.call(prevValue);
+            // $FlowFixMe[method-unbinding]
+            const nextKind = Object.prototype.toString.call(nextValue);
+            if (
+              prevKind === nextKind &&
+              (nextKind === '[object Object]' || nextKind === '[object Array]')
+            ) {
+              // Diff nested object
+              const entry = [
+                UNCHANGED + '\xa0\xa0'.repeat(indent) + key,
+                nextKind === '[object Array]' ? 'Array' : '',
+              ];
+              properties.push(entry);
+              const prevLength = properties.length;
+              addObjectDiffToProperties(
+                prevValue,
+                nextValue,
+                properties,
+                indent + 1,
+              );
+              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] =
+                  'Referentially unequal but deeply equal objects. Consider memoization.';
+              }
+              continue;
+            }
+          }
+        } else if (
+          typeof prevValue === 'function' &&
+          typeof nextValue === 'function' &&
+          prevValue.name === nextValue.name &&
+          prevValue.length === nextValue.length
+        ) {
+          // $FlowFixMe[method-unbinding]
+          const prevSrc = Function.prototype.toString.call(prevValue);
+          // $FlowFixMe[method-unbinding]
+          const nextSrc = Function.prototype.toString.call(nextValue);
+          if (prevSrc === nextSrc) {
+            // This looks like it might be the same function but different closures.
+            let desc;
+            if (nextValue.name === '') {
+              desc = '() => {}';
+            } else {
+              desc = nextValue.name + '() {}';
+            }
+            properties.push([
+              UNCHANGED + '\xa0\xa0'.repeat(indent) + key,
+              desc +
+                ' Referentially unequal function closure. Consider memoization.',
+            ]);
+            continue;
+          }
+        }
+
+        // Otherwise, emit the change in property and the values.
+        addValueToProperties(key, prevValue, properties, indent, REMOVED);
+        addValueToProperties(key, nextValue, properties, indent, ADDED);
+      }
+    } else {
+      properties.push([ADDED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
+    }
+  }
 }