Repository: facebook/react
Pull request: [DevTools] Allow Introspection of React Elements and React.lazy

With RSC it's common to get React.lazy objects in the children position. This first formats them nicely.

Then it adds introspection support for both lazy and elements.

Unfortunately because of quirks with the hydration mechanism we have to expose it under the name `_payload` instead of something direct. Also because the name "type" is taken we can't expose the type field on an element neither. That whole algorithm could use a rewrite.

<img width="422" height="137" alt="Screenshot 2025-08-07 at 11 37 03 PM" src="https://github.com/user-attachments/assets/a6f65f58-dbc4-4b8f-928b-d7f629fc51b2" />

<img width="516" height="275" alt="Screenshot 2025-08-07 at 11 36 36 PM" src="https://github.com/user-attachments/assets/650bafdb-a633-4d78-9487-a750a18074ce" />

For JSX an alternative or additional feature might be instead to jump to the first Instance that was rendered using that JSX. We know that based on the equality of the memoizedProps on the Fiber. It's just a matter of whether we do that eagerly or more lazily when you click but you may not have a match so would be nice to indicate that before you click.

Changed files:
filename: packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
patch: @@ -682,6 +682,7 @@ describe('InspectedElement', () => {
           object_with_symbol={objectWithSymbol}
           proxy={proxyInstance}
           react_element={<span />}
+          react_lazy={React.lazy(async () => ({default: 'foo'}))}
           regexp={/abc/giu}
           set={setShallow}
           set_of_sets={setOfSets}
@@ -780,9 +781,18 @@ describe('InspectedElement', () => {
           "preview_short": () => {},
           "preview_long": () => {},
         },
-        "react_element": Dehydrated {
-          "preview_short": <span />,
-          "preview_long": <span />,
+        "react_element": {
+          "key": null,
+          "props": Dehydrated {
+            "preview_short": {…},
+            "preview_long": {},
+          },
+        },
+        "react_lazy": {
+          "_payload": Dehydrated {
+            "preview_short": {…},
+            "preview_long": {_result: () => {}, _status: -1},
+          },
         },
         "regexp": Dehydrated {
           "preview_short": /abc/giu,
@@ -930,13 +940,13 @@ describe('InspectedElement', () => {
     const inspectedElement = await inspectElementAtIndex(0);
 
     expect(inspectedElement.props).toMatchInlineSnapshot(`
-    {
-      "unusedPromise": Dehydrated {
-        "preview_short": Promise,
-        "preview_long": Promise,
-      },
-    }
-  `);
+          {
+            "unusedPromise": Dehydrated {
+              "preview_short": Promise,
+              "preview_long": Promise,
+            },
+          }
+      `);
   });
 
   it('should not consume iterables while inspecting', async () => {

filename: packages/react-devtools-shared/src/__tests__/legacy/inspectElement-test.js
patch: @@ -289,9 +289,13 @@ describe('InspectedElementContext', () => {
             "preview_long": {boolean: true, number: 123, string: "abc"},
           },
         },
-        "react_element": Dehydrated {
-          "preview_short": <span />,
-          "preview_long": <span />,
+        "react_element": {
+          "key": null,
+          "props": Dehydrated {
+            "preview_short": {…},
+            "preview_long": {},
+          },
+          "ref": null,
         },
         "regexp": Dehydrated {
           "preview_short": /abc/giu,

filename: packages/react-devtools-shared/src/hydration.js
patch: @@ -16,6 +16,8 @@ import {
   setInObject,
 } from 'react-devtools-shared/src/utils';
 
+import {REACT_LEGACY_ELEMENT_TYPE} from 'shared/ReactSymbols';
+
 import type {
   DehydratedData,
   InspectedElementPath,
@@ -188,18 +190,103 @@ export function dehydrate(
         type,
       };
 
-    // React Elements aren't very inspector-friendly,
-    // and often contain private fields or circular references.
-    case 'react_element':
-      cleaned.push(path);
-      return {
-        inspectable: false,
+    case 'react_element': {
+      isPathAllowedCheck = isPathAllowed(path);
+
+      if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
+        cleaned.push(path);
+        return {
+          inspectable: true,
+          preview_short: formatDataForPreview(data, false),
+          preview_long: formatDataForPreview(data, true),
+          name: getDisplayNameForReactElement(data) || 'Unknown',
+          type,
+        };
+      }
+
+      const unserializableValue: Unserializable = {
+        unserializable: true,
+        type,
+        readonly: true,
         preview_short: formatDataForPreview(data, false),
         preview_long: formatDataForPreview(data, true),
         name: getDisplayNameForReactElement(data) || 'Unknown',
-        type,
       };
+      // TODO: We can't expose type because that name is already taken on Unserializable.
+      unserializableValue.key = dehydrate(
+        data.key,
+        cleaned,
+        unserializable,
+        path.concat(['key']),
+        isPathAllowed,
+        isPathAllowedCheck ? 1 : level + 1,
+      );
+      if (data.$$typeof === REACT_LEGACY_ELEMENT_TYPE) {
+        unserializableValue.ref = dehydrate(
+          data.ref,
+          cleaned,
+          unserializable,
+          path.concat(['ref']),
+          isPathAllowed,
+          isPathAllowedCheck ? 1 : level + 1,
+        );
+      }
+      unserializableValue.props = dehydrate(
+        data.props,
+        cleaned,
+        unserializable,
+        path.concat(['props']),
+        isPathAllowed,
+        isPathAllowedCheck ? 1 : level + 1,
+      );
 
+      unserializable.push(path);
+      return unserializableValue;
+    }
+    case 'react_lazy': {
+      isPathAllowedCheck = isPathAllowed(path);
+
+      const payload = data._payload;
+
+      if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
+        cleaned.push(path);
+        const inspectable =
+          payload !== null &&
+          typeof payload === 'object' &&
+          (payload._status === 1 ||
+            payload._status === 2 ||
+            payload.status === 'fulfilled' ||
+            payload.status === 'rejected');
+        return {
+          inspectable,
+          preview_short: formatDataForPreview(data, false),
+          preview_long: formatDataForPreview(data, true),
+          name: 'lazy()',
+          type,
+        };
+      }
+
+      const unserializableValue: Unserializable = {
+        unserializable: true,
+        type: type,
+        preview_short: formatDataForPreview(data, false),
+        preview_long: formatDataForPreview(data, true),
+        name: 'lazy()',
+      };
+      // Ideally we should alias these properties to something more readable but
+      // unfortunately because of how the hydration algorithm uses a single concept of
+      // "path" we can't alias the path.
+      unserializableValue._payload = dehydrate(
+        payload,
+        cleaned,
+        unserializable,
+        path.concat(['_payload']),
+        isPathAllowed,
+        isPathAllowedCheck ? 1 : level + 1,
+      );
+      unserializable.push(path);
+      return unserializableValue;
+    }
     // ArrayBuffers error if you try to inspect them.
     case 'array_buffer':
     case 'data_view':
@@ -309,6 +396,7 @@ export function dehydrate(
       isPathAllowedCheck = isPathAllowed(path);
 
       if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
+        cleaned.push(path);
         return {
           inspectable:
             data.status === 'fulfilled' || data.status === 'rejected',

filename: packages/react-devtools-shared/src/utils.js
patch: @@ -633,6 +633,7 @@ export type DataType =
   | 'thenable'
   | 'object'
   | 'react_element'
+  | 'react_lazy'
   | 'regexp'
   | 'string'
   | 'symbol'
@@ -686,11 +687,12 @@ export function getDataType(data: Object): DataType {
         return 'number';
       }
     case 'object':
-      if (
-        data.$$typeof === REACT_ELEMENT_TYPE ||
-        data.$$typeof === REACT_LEGACY_ELEMENT_TYPE
-      ) {
-        return 'react_element';
+      switch (data.$$typeof) {
+        case REACT_ELEMENT_TYPE:
+        case REACT_LEGACY_ELEMENT_TYPE:
+          return 'react_element';
+        case REACT_LAZY_TYPE:
+          return 'react_lazy';
       }
       if (isArray(data)) {
         return 'array';
@@ -906,6 +908,62 @@ export function formatDataForPreview(
       return `<${truncateForDisplay(
         getDisplayNameForReactElement(data) || 'Unknown',
       )} />`;
+    case 'react_lazy':
+      // To avoid actually initialize a lazy to cause a side-effect we make some assumptions
+      // about the structure of the payload even though that's not really part of the contract.
+      // In practice, this is really just coming from React.lazy helper or Flight.
+      const payload = data._payload;
+      if (payload !== null && typeof payload === 'object') {
+        if (payload._status === 0) {
+          // React.lazy constructor pending
+          return `pending lazy()`;
+        }
+        if (payload._status === 1 && payload._result != null) {
+          // React.lazy constructor fulfilled
+          if (showFormattedValue) {
+            const formatted = formatDataForPreview(
+              payload._result.default,
+              false,
+            );
+            return `fulfilled lazy() {${truncateForDisplay(formatted)}}`;
+          } else {
+            return `fulfilled lazy() {…}`;
+          }
+        }
+        if (payload._status === 2) {
+          // React.lazy constructor rejected
+          if (showFormattedValue) {
+            const formatted = formatDataForPreview(payload._result, false);
+            return `rejected lazy() {${truncateForDisplay(formatted)}}`;
+          } else {
+            return `rejected lazy() {…}`;
+          }
+        }
+        if (payload.status === 'pending' || payload.status === 'blocked') {
+          // React Flight pending
+          return `pending lazy()`;
+        }
+        if (payload.status === 'fulfilled') {
+          // React Flight fulfilled
+          if (showFormattedValue) {
+            const formatted = formatDataForPreview(payload.value, false);
+            return `fulfilled lazy() {${truncateForDisplay(formatted)}}`;
+          } else {
+            return `fulfilled lazy() {…}`;
+          }
+        }
+        if (payload.status === 'rejected') {
+          // React Flight rejected
+          if (showFormattedValue) {
+            const formatted = formatDataForPreview(payload.reason, false);
+            return `rejected lazy() {${truncateForDisplay(formatted)}}`;
+          } else {
+            return `rejected lazy() {…}`;
+          }
+        }
+      }
+      // Some form of uninitialized
+      return 'lazy()';
     case 'array_buffer':
       return `ArrayBuffer(${data.byteLength})`;
     case 'data_view':