Repository: facebook/react
Pull request: [devtools] Allow inspecting cause, name, message, stack of Errors in props

## Summary

These are not enumerable properties (which is why we ommitted those previously) yet they're still interesting. I feel like for Errors special casing is warranted.

After:
![CleanShot 2025-04-25 at 18 10 54@2x](https://github.com/user-attachments/assets/440f2715-f4bf-4f53-b6db-737986de7cc5)

Before:
![CleanShot 2025-04-25 at 18 12 17@2x](https://github.com/user-attachments/assets/5d8516be-2f8a-4ada-a29a-e65e5b9d38e7)



## How did you test this change?

- added showcase in shell
- local build 


Changed files:
filename: packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
patch: @@ -906,8 +906,8 @@ describe('InspectedElement', () => {
         },
         "usedRejectedPromise": {
           "reason": Dehydrated {
-            "preview_short": Error,
-            "preview_long": Error,
+            "preview_short": Error: test-error-do-not-surface,
+            "preview_long": Error: test-error-do-not-surface,
           },
         },
       }

filename: packages/react-devtools-shared/src/hydration.js
patch: @@ -397,7 +397,7 @@ export function dehydrate(
         return object;
       }
 
-    case 'class_instance':
+    case 'class_instance': {
       isPathAllowedCheck = isPathAllowed(path);
 
       if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
@@ -433,7 +433,69 @@ export function dehydrate(
       unserializable.push(path);
 
       return value;
+    }
+    case 'error': {
+      isPathAllowedCheck = isPathAllowed(path);
+
+      if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
+        return createDehydrated(type, true, data, cleaned, path);
+      }
+
+      const value: Unserializable = {
+        unserializable: true,
+        type,
+        readonly: true,
+        preview_short: formatDataForPreview(data, false),
+        preview_long: formatDataForPreview(data, true),
+        name: data.name,
+      };
+
+      // name, message, stack and cause are not enumerable yet still interesting.
+      value.message = dehydrate(
+        data.message,
+        cleaned,
+        unserializable,
+        path.concat(['message']),
+        isPathAllowed,
+        isPathAllowedCheck ? 1 : level + 1,
+      );
+      value.stack = dehydrate(
+        data.stack,
+        cleaned,
+        unserializable,
+        path.concat(['stack']),
+        isPathAllowed,
+        isPathAllowedCheck ? 1 : level + 1,
+      );
+
+      if ('cause' in data) {
+        value.cause = dehydrate(
+          data.cause,
+          cleaned,
+          unserializable,
+          path.concat(['cause']),
+          isPathAllowed,
+          isPathAllowedCheck ? 1 : level + 1,
+        );
+      }
+
+      getAllEnumerableKeys(data).forEach(key => {
+        const keyAsString = key.toString();
 
+        value[keyAsString] = dehydrate(
+          data[key],
+          cleaned,
+          unserializable,
+          path.concat([keyAsString]),
+          isPathAllowed,
+          isPathAllowedCheck ? 1 : level + 1,
+        );
+      });
+
+      unserializable.push(path);
+
+      return value;
+    }
     case 'infinity':
     case 'nan':
     case 'undefined':

filename: packages/react-devtools-shared/src/utils.js
patch: @@ -554,6 +554,7 @@ export type DataType =
   | 'class_instance'
   | 'data_view'
   | 'date'
+  | 'error'
   | 'function'
   | 'html_all_collection'
   | 'html_element'
@@ -573,6 +574,21 @@ export type DataType =
   | 'undefined'
   | 'unknown';
 
+function isError(data: Object): boolean {
+  // If it doesn't event look like an error, it won't be an actual error.
+  if ('name' in data && 'message' in data) {
+    while (data) {
+      // $FlowFixMe[method-unbinding]
+      if (Object.prototype.toString.call(data) === '[object Error]') {
+        return true;
+      }
+      data = Object.getPrototypeOf(data);
+    }
+  }
+
+  return false;
+}
+
 /**
  * Get a enhanced/artificial type string based on the object instance
  */
@@ -634,6 +650,8 @@ export function getDataType(data: Object): DataType {
         return 'regexp';
       } else if (typeof data.then === 'function') {
         return 'thenable';
+      } else if (isError(data)) {
+        return 'error';
       } else {
         // $FlowFixMe[method-unbinding]
         const toStringValue = Object.prototype.toString.call(data);
@@ -996,6 +1014,8 @@ export function formatDataForPreview(
       } else {
         return '{…}';
       }
+    case 'error':
+      return truncateForDisplay(String(data));
     case 'boolean':
     case 'number':
     case 'infinity':

filename: packages/react-devtools-shell/src/app/Hydration/index.js
patch: @@ -130,6 +130,14 @@ const usedRejectedPromise = Promise.reject(
   new Error('test-error-do-not-surface'),
 );
 
+class DigestError extends Error {
+  digest: string;
+  constructor(message: string, options: any, digest: string) {
+    super(message, options);
+    this.digest = digest;
+  }
+}
+
 export default function Hydration(): React.Node {
   return (
     <Fragment>
@@ -149,6 +157,13 @@ export default function Hydration(): React.Node {
         usedFulfilledRichPromise={usedFulfilledRichPromise}
         usedPendingPromise={usedPendingPromise}
         usedRejectedPromise={usedRejectedPromise}
+        // eslint-disable-next-line react-internal/prod-error-codes
+        error={new Error('test')}
+        // eslint-disable-next-line react-internal/prod-error-codes
+        errorWithCause={new Error('one', {cause: new TypeError('two')})}
+        errorWithDigest={new DigestError('test', {}, 'some-digest')}
+        // $FlowFixMe[cannot-resolve-name] Flow doesn't know about DOMException
+        domexception={new DOMException('test')}
       />
       <DeepHooks />
     </Fragment>