Repository: facebook/react
Pull request: [Fiber] Bail out of diffing wide objects and arrays

Fixes https://github.com/facebook/react/issues/34693

When we commit in development, React will diff objects to check if they might be referentially equal and add a hint that the object should be memoized. If they're unequal, React will show a diff.

However, we attempted to diff the full object which can be problematic for wide objects or, like in the original report, large Arrays.

With this we just bail out after 100 properties. Ideally, we'd have different counters for how many properties we check and how many diffed properties we print. Checking 10k properties would make sense. Adding a diff for 10k properties does not.

## Test plan

- [x] https://codesandbox.io/p/sandbox/hopeful-julien-4jzxzk?file=%2Fsrc%2FApp.js%3A13%2C30 no longer freezes on update: https://codesandbox.io/p/devbox/suspicious-cache-dsfqc3?workspaceId=ws_HE9JRzFW36Y2kpBoSwdvZ5

Changed files:
filename: packages/react-reconciler/src/__tests__/ReactPerformanceTrack-test.js
patch: @@ -0,0 +1,327 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ * @jest-environment node
+ */
+
+let React;
+let ReactNoop;
+let Scheduler;
+let act;
+let useEffect;
+
+describe('ReactPerformanceTracks', () => {
+  beforeEach(() => {
+    Object.defineProperty(performance, 'measure', {
+      value: jest.fn(),
+      configurable: true,
+    });
+    console.timeStamp = () => {};
+    jest.spyOn(console, 'timeStamp').mockImplementation(() => {});
+
+    jest.resetModules();
+
+    React = require('react');
+    ReactNoop = require('react-noop-renderer');
+    Scheduler = require('scheduler');
+    act = require('internal-test-utils').act;
+    useEffect = React.useEffect;
+  });
+
+  // @gate __DEV__ && enableComponentPerformanceTrack
+  it('shows a hint if an update is triggered by a deeply equal object', async () => {
+    const App = function App({items}) {
+      Scheduler.unstable_advanceTime(10);
+      useEffect(() => {}, [items]);
+    };
+
+    Scheduler.unstable_advanceTime(1);
+    const items = ['one', 'two'];
+    await act(() => {
+      ReactNoop.render(<App items={items} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        'Mount',
+        {
+          detail: {
+            devtools: {
+              color: 'warning',
+              properties: null,
+              tooltipText: 'Mount',
+              track: 'Components ⚛',
+            },
+          },
+          end: 11,
+          start: 1,
+        },
+      ],
+    ]);
+    performance.measure.mockClear();
+
+    Scheduler.unstable_advanceTime(10);
+    await act(() => {
+      ReactNoop.render(<App items={items.concat('4')} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        '​App',
+        {
+          detail: {
+            devtools: {
+              color: 'primary-dark',
+              properties: [
+                ['Changed Props', ''],
+                ['  items', 'Array'],
+                ['+   2', '…'],
+              ],
+              tooltipText: 'App',
+              track: 'Components ⚛',
+            },
+          },
+          end: 31,
+          start: 21,
+        },
+      ],
+    ]);
+  });
+
+  // @gate __DEV__ && enableComponentPerformanceTrack
+  it('bails out of diffing wide arrays', async () => {
+    const App = function App({items}) {
+      Scheduler.unstable_advanceTime(10);
+      React.useEffect(() => {}, [items]);
+    };
+
+    Scheduler.unstable_advanceTime(1);
+    const items = Array.from({length: 1000}, (_, i) => i);
+    await act(() => {
+      ReactNoop.render(<App items={items} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        'Mount',
+        {
+          detail: {
+            devtools: {
+              color: 'warning',
+              properties: null,
+              tooltipText: 'Mount',
+              track: 'Components ⚛',
+            },
+          },
+          end: 11,
+          start: 1,
+        },
+      ],
+    ]);
+    performance.measure.mockClear();
+
+    Scheduler.unstable_advanceTime(10);
+    await act(() => {
+      ReactNoop.render(<App items={items.concat('-1')} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        '​App',
+        {
+          detail: {
+            devtools: {
+              color: 'primary-dark',
+              properties: [
+                ['Changed Props', ''],
+                ['  items', 'Array'],
+                [
+                  'Previous object has more than 100 properties. React will not attempt to diff objects with too many properties.',
+                  '',
+                ],
+                [
+                  'Next object has more than 100 properties. React will not attempt to diff objects with too many properties.',
+                  '',
+                ],
+              ],
+              tooltipText: 'App',
+              track: 'Components ⚛',
+            },
+          },
+          end: 31,
+          start: 21,
+        },
+      ],
+    ]);
+  });
+
+  // @gate __DEV__ && enableComponentPerformanceTrack
+  it('does not show all properties of wide objects', async () => {
+    const App = function App({items}) {
+      Scheduler.unstable_advanceTime(10);
+      React.useEffect(() => {}, [items]);
+    };
+
+    Scheduler.unstable_advanceTime(1);
+    await act(() => {
+      ReactNoop.render(<App data={{buffer: null}} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        'Mount',
+        {
+          detail: {
+            devtools: {
+              color: 'warning',
+              properties: null,
+              tooltipText: 'Mount',
+              track: 'Components ⚛',
+            },
+          },
+          end: 11,
+          start: 1,
+        },
+      ],
+    ]);
+    performance.measure.mockClear();
+
+    Scheduler.unstable_advanceTime(10);
+
+    const bigData = new Uint8Array(1000);
+    await act(() => {
+      ReactNoop.render(<App data={{buffer: bigData}} />);
+    });
+
+    expect(performance.measure.mock.calls).toEqual([
+      [
+        '​App',
+        {
+          detail: {
+            devtools: {
+              color: 'primary-dark',
+              properties: [
+                ['Changed Props', ''],
+                ['  data', ''],
+                ['–   buffer', 'null'],
+                ['+   buffer', 'Uint8Array'],
+                ['+     0', '0'],
+                ['+     1', '0'],
+                ['+     2', '0'],
+                ['+     3', '0'],
+                ['+     4', '0'],
+                ['+     5', '0'],
+                ['+     6', '0'],
+                ['+     7', '0'],
+                ['+     8', '0'],
+                ['+     9', '0'],
+                ['+     10', '0'],
+                ['+     11', '0'],
+                ['+     12', '0'],
+                ['+     13', '0'],
+                ['+     14', '0'],
+                ['+     15', '0'],
+                ['+     16', '0'],
+                ['+     17', '0'],
+                ['+     18', '0'],
+                ['+     19', '0'],
+                ['+     20', '0'],
+                ['+     21', '0'],
+                ['+     22', '0'],
+                ['+     23', '0'],
+                ['+     24', '0'],
+                ['+     25', '0'],
+                ['+     26', '0'],
+                ['+     27', '0'],
+                ['+     28', '0'],
+                ['+     29', '0'],
+                ['+     30', '0'],
+                ['+     31', '0'],
+                ['+     32', '0'],
+                ['+     33', '0'],
+                ['+     34', '0'],
+                ['+     35', '0'],
+                ['+     36', '0'],
+                ['+     37', '0'],
+                ['+     38', '0'],
+                ['+     39', '0'],
+                ['+     40', '0'],
+                ['+     41', '0'],
+                ['+     42', '0'],
+                ['+     43', '0'],
+                ['+     44', '0'],
+                ['+     45', '0'],
+                ['+     46', '0'],
+                ['+     47', '0'],
+                ['+     48', '0'],
+                ['+     49', '0'],
+                ['+     50', '0'],
+                ['+     51', '0'],
+                ['+     52', '0'],
+                ['+     53', '0'],
+                ['+     54', '0'],
+                ['+     55', '0'],
+                ['+     56', '0'],
+                ['+     57', '0'],
+                ['+     58', '0'],
+                ['+     59', '0'],
+                ['+     60', '0'],
+                ['+     61', '0'],
+                ['+     62', '0'],
+                ['+     63', '0'],
+                ['+     64', '0'],
+                ['+     65', '0'],
+                ['+     66', '0'],
+                ['+     67', '0'],
+                ['+     68', '0'],
+                ['+     69', '0'],
+                ['+     70', '0'],
+                ['+     71', '0'],
+                ['+     72', '0'],
+                ['+     73', '0'],
+                ['+     74', '0'],
+                ['+     75', '0'],
+                ['+     76', '0'],
+                ['+     77', '0'],
+                ['+     78', '0'],
+                ['+     79', '0'],
+                ['+     80', '0'],
+                ['+     81', '0'],
+                ['+     82', '0'],
+                ['+     83', '0'],
+                ['+     84', '0'],
+                ['+     85', '0'],
+                ['+     86', '0'],
+                ['+     87', '0'],
+                ['+     88', '0'],
+                ['+     89', '0'],
+                ['+     90', '0'],
+                ['+     91', '0'],
+                ['+     92', '0'],
+                ['+     93', '0'],
+                ['+     94', '0'],
+                ['+     95', '0'],
+                ['+     96', '0'],
+                ['+     97', '0'],
+                ['+     98', '0'],
+                ['+     99', '0'],
+                [
+                  '+     Only 100 properties are shown. React will not log more properties of this object.',
+                  '',
+                ],
+              ],
+              tooltipText: 'App',
+              track: 'Components ⚛',
+            },
+          },
+          end: 31,
+          start: 21,
+        },
+      ],
+    ]);
+  });
+});

filename: packages/shared/ReactPerformanceTrackProperties.js
patch: @@ -18,9 +18,13 @@ const EMPTY_ARRAY = 0;
 const COMPLEX_ARRAY = 1;
 const PRIMITIVE_ARRAY = 2; // Primitive values only
 const ENTRIES_ARRAY = 3; // Tuple arrays of string and value (like Headers, Map, etc)
+
+// Showing wider objects in the devtools is not useful.
+const OBJECT_WIDTH_LIMIT = 100;
+
 function getArrayKind(array: Object): 0 | 1 | 2 | 3 {
   let kind: 0 | 1 | 2 | 3 = EMPTY_ARRAY;
-  for (let i = 0; i < array.length; i++) {
+  for (let i = 0; i < array.length && i < OBJECT_WIDTH_LIMIT; i++) {
     const value = array[i];
     if (typeof value === 'object' && value !== null) {
       if (
@@ -55,10 +59,23 @@ export function addObjectToProperties(
   indent: number,
   prefix: string,
 ): void {
+  let addedProperties = 0;
   for (const key in object) {
     if (hasOwnProperty.call(object, key) && key[0] !== '_') {
+      addedProperties++;
       const value = object[key];
       addValueToProperties(key, value, properties, indent, prefix);
+      if (addedProperties >= OBJECT_WIDTH_LIMIT) {
+        properties.push([
+          prefix +
+            '\xa0\xa0'.repeat(indent) +
+            'Only ' +
+            OBJECT_WIDTH_LIMIT +
+            ' properties are shown. React will not log more properties of this object.',
+          '',
+        ]);
+        break;
+      }
     }
   }
 }
@@ -103,7 +120,9 @@ export function addValueToProperties(
             addValueToProperties('key', key, properties, indent + 1, prefix);
           }
           let hasChildren = false;
+          let addedProperties = 0;
           for (const propKey in props) {
+            addedProperties++;
             if (propKey === 'children') {
               if (
                 props.children != null &&
@@ -123,6 +142,10 @@ export function addValueToProperties(
                 prefix,
               );
             }
+
+            if (addedProperties >= OBJECT_WIDTH_LIMIT) {
+              break;
+            }
           }
           properties.push([
             '',
@@ -135,16 +158,21 @@ export function addValueToProperties(
         let objectName = objectToString.slice(8, objectToString.length - 1);
         if (objectName === 'Array') {
           const array: Array<any> = (value: any);
+          const didTruncate = array.length > OBJECT_WIDTH_LIMIT;
           const kind = getArrayKind(array);
           if (kind === PRIMITIVE_ARRAY || kind === EMPTY_ARRAY) {
-            desc = JSON.stringify(array);
+            desc = JSON.stringify(
+              didTruncate
+                ? array.slice(0, OBJECT_WIDTH_LIMIT).concat('…')
+                : array,
+            );
             break;
           } else if (kind === ENTRIES_ARRAY) {
             properties.push([
               prefix + '\xa0\xa0'.repeat(indent) + propertyName,
               '',
             ]);
-            for (let i = 0; i < array.length; i++) {
+            for (let i = 0; i < array.length && i < OBJECT_WIDTH_LIMIT; i++) {
               const entry = array[i];
               addValueToProperties(
                 entry[0],
@@ -154,6 +182,15 @@ export function addValueToProperties(
                 prefix,
               );
             }
+            if (didTruncate) {
+              addValueToProperties(
+                OBJECT_WIDTH_LIMIT.toString(),
+                '…',
+                properties,
+                indent + 1,
+                prefix,
+              );
+            }
             return;
           }
         }
@@ -254,13 +291,39 @@ export function addObjectDiffToProperties(
   // 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;
+  let prevPropertiesChecked = 0;
   for (const key in prev) {
+    if (prevPropertiesChecked > OBJECT_WIDTH_LIMIT) {
+      properties.push([
+        'Previous object has more than ' +
+          OBJECT_WIDTH_LIMIT +
+          ' properties. React will not attempt to diff objects with too many properties.',
+        '',
+      ]);
+      isDeeplyEqual = false;
+      break;
+    }
+
     if (!(key in next)) {
       properties.push([REMOVED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
       isDeeplyEqual = false;
     }
+    prevPropertiesChecked++;
   }
+
+  let nextPropertiesChecked = 0;
   for (const key in next) {
+    if (nextPropertiesChecked > OBJECT_WIDTH_LIMIT) {
+      properties.push([
+        'Next object has more than ' +
+          OBJECT_WIDTH_LIMIT +
+          ' properties. React will not attempt to diff objects with too many properties.',
+        '',
+      ]);
+      isDeeplyEqual = false;
+      break;
+    }
+
     if (key in prev) {
       const prevValue = prev[key];
       const nextValue = next[key];
@@ -368,6 +431,8 @@ export function addObjectDiffToProperties(
       properties.push([ADDED + '\xa0\xa0'.repeat(indent) + key, '\u2026']);
       isDeeplyEqual = false;
     }
+
+    nextPropertiesChecked++;
   }
   return isDeeplyEqual;
 }