[devtools] Allow inspecting cause, name, message, stack of Errors in props (#33023)
Sebastian "Sebbie" Silbermann committed
Apr 26, 2025 at 07:20 UTC
c498bfce8b9baa3dd21bd0d5124eb3a4549886f1
4 files changed
+100
-3
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+2
-2
@@ -906,8 +906,8 @@ describe('InspectedElement', () => {
906
},
907
"usedRejectedPromise": {
908
"reason": Dehydrated {
909
- "preview_short": Error,
910
- "preview_long": Error,
909
+ "preview_short": Error: test-error-do-not-surface,
910
+ "preview_long": Error: test-error-do-not-surface,
911
},
912
},
913
}
packages/react-devtools-shared/src/hydration.js
+63
-1
@@ -397,7 +397,7 @@ export function dehydrate(
397
return object;
398
}
399
400
- case 'class_instance':
400
+ case 'class_instance': {
401
isPathAllowedCheck = isPathAllowed(path);
402
403
if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
@@ -433,7 +433,69 @@ export function dehydrate(
433
unserializable.push(path);
434
435
return value;
436
+ }
437
+ case 'error': {
438
+ isPathAllowedCheck = isPathAllowed(path);
439
+
440
+ if (level >= LEVEL_THRESHOLD && !isPathAllowedCheck) {
441
+ return createDehydrated(type, true, data, cleaned, path);
442
+ }
443
+
444
+ const value: Unserializable = {
445
+ unserializable: true,
446
+ type,
447
+ readonly: true,
448
+ preview_short: formatDataForPreview(data, false),
449
+ preview_long: formatDataForPreview(data, true),
450
+ name: data.name,
451
+ };
452
+
453
+ // name, message, stack and cause are not enumerable yet still interesting.
454
+ value.message = dehydrate(
455
+ data.message,
456
+ cleaned,
457
+ unserializable,
458
+ path.concat(['message']),
459
+ isPathAllowed,
460
+ isPathAllowedCheck ? 1 : level + 1,
461
+ );
462
+ value.stack = dehydrate(
463
+ data.stack,
464
+ cleaned,
465
+ unserializable,
466
+ path.concat(['stack']),
467
+ isPathAllowed,
468
+ isPathAllowedCheck ? 1 : level + 1,
469
+ );
470
+
471
+ if ('cause' in data) {
472
+ value.cause = dehydrate(
473
+ data.cause,
474
+ cleaned,
475
+ unserializable,
476
+ path.concat(['cause']),
477
+ isPathAllowed,
478
+ isPathAllowedCheck ? 1 : level + 1,
479
+ );
480
+ }
481
+
482
+ getAllEnumerableKeys(data).forEach(key => {
483
+ const keyAsString = key.toString();
484
485
+ value[keyAsString] = dehydrate(
486
+ data[key],
487
+ cleaned,
488
+ unserializable,
489
+ path.concat([keyAsString]),
490
+ isPathAllowed,
491
+ isPathAllowedCheck ? 1 : level + 1,
492
+ );
493
+ });
494
+
495
+ unserializable.push(path);
496
+
497
+ return value;
498
+ }
499
case 'infinity':
500
case 'nan':
501
case 'undefined':
packages/react-devtools-shared/src/utils.js
+20
@@ -554,6 +554,7 @@ export type DataType =
554
| 'class_instance'
555
| 'data_view'
556
| 'date'
557
+ | 'error'
558
| 'function'
559
| 'html_all_collection'
560
| 'html_element'
@@ -573,6 +574,21 @@ export type DataType =
574
| 'undefined'
575
| 'unknown';
576
577
+function isError(data: Object): boolean {
578
+ // If it doesn't event look like an error, it won't be an actual error.
579
+ if ('name' in data && 'message' in data) {
580
+ while (data) {
581
+ // $FlowFixMe[method-unbinding]
582
+ if (Object.prototype.toString.call(data) === '[object Error]') {
583
+ return true;
584
+ }
585
+ data = Object.getPrototypeOf(data);
586
+ }
587
+ }
588
+
589
+ return false;
590
+}
591
+
592
/**
593
* Get a enhanced/artificial type string based on the object instance
594
*/
@@ -634,6 +650,8 @@ export function getDataType(data: Object): DataType {
650
return 'regexp';
651
} else if (typeof data.then === 'function') {
652
return 'thenable';
653
+ } else if (isError(data)) {
654
+ return 'error';
655
} else {
656
// $FlowFixMe[method-unbinding]
657
const toStringValue = Object.prototype.toString.call(data);
@@ -996,6 +1014,8 @@ export function formatDataForPreview(
1014
} else {
1015
return '{…}';
1016
}
1017
+ case 'error':
1018
+ return truncateForDisplay(String(data));
1019
case 'boolean':
1020
case 'number':
1021
case 'infinity':
packages/react-devtools-shell/src/app/Hydration/index.js
+15
@@ -130,6 +130,14 @@ const usedRejectedPromise = Promise.reject(
130
new Error('test-error-do-not-surface'),
131
);
132
133
+class DigestError extends Error {
134
+ digest: string;
135
+ constructor(message: string, options: any, digest: string) {
136
+ super(message, options);
137
+ this.digest = digest;
138
+ }
139
+}
140
+
141
export default function Hydration(): React.Node {
142
return (
143
<Fragment>
@@ -149,6 +157,13 @@ export default function Hydration(): React.Node {
157
usedFulfilledRichPromise={usedFulfilledRichPromise}
158
usedPendingPromise={usedPendingPromise}
159
usedRejectedPromise={usedRejectedPromise}
160
+ // eslint-disable-next-line react-internal/prod-error-codes
161
+ error={new Error('test')}
162
+ // eslint-disable-next-line react-internal/prod-error-codes
163
+ errorWithCause={new Error('one', {cause: new TypeError('two')})}
164
+ errorWithDigest={new DigestError('test', {}, 'some-digest')}
165
+ // $FlowFixMe[cannot-resolve-name] Flow doesn't know about DOMException
166
+ domexception={new DOMException('test')}
167
/>
168
<DeepHooks />
169
</Fragment>