[React DevTools] Improve DevTools UI when Inspecting a user Component that Throws an Error (#24248)
* [ReactDevTools] custom view for errors occur in user's code * [ReactDevTools] show message for unsupported feature * fix bad import * fix typo * fix issues from rebasing * prettier * sync error names * sync error name with upstream * fix lint & better comment * fix error message for test * better error message per review * add missing file * remove dead enum & provide component name in error message * better error message * better user facing error message
Mengdi "Monday" Chen committed
May 5, 2022 at 20:17 UTC
e531a4a62d0b88301ac06d4efd3f5a30faa03c94
10 files changed
+201
-13
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+1
-1
@@ -2145,7 +2145,7 @@ describe('InspectedElement', () => {
2145
expect(value).toBe(null);
2146
2147
const error = errorBoundaryInstance.state.error;
2148
- expect(error.message).toBe('Error rendering inspected component');
2148
+ expect(error.message).toBe('Expected');
2149
expect(error.stack).toContain('inspectHooksOfFiber');
2150
});
2151
packages/react-devtools-shared/src/backend/renderer.js
+48
@@ -3607,10 +3607,58 @@ export function attach(
3607
try {
3608
mostRecentlyInspectedElement = inspectElementRaw(id);
3609
} catch (error) {
3610
+ // the error name is synced with ReactDebugHooks
3611
+ if (error.name === 'ReactDebugToolsRenderError') {
3612
+ let message = 'Error rendering inspected element.';
3613
+ let stack;
3614
+ // Log error & cause for user to debug
3615
+ console.error(message + '\n\n', error);
3616
+ if (error.cause != null) {
3617
+ const fiber = findCurrentFiberUsingSlowPathById(id);
3618
+ const componentName =
3619
+ fiber != null ? getDisplayNameForFiber(fiber) : null;
3620
+ console.error(
3621
+ 'React DevTools encountered an error while trying to inspect hooks. ' +
3622
+ 'This is most likely caused by an error in current inspected component' +
3623
+ (componentName != null ? `: "${componentName}".` : '.') +
3624
+ '\nThe error thrown in the component is: \n\n',
3625
+ error.cause,
3626
+ );
3627
+ if (error.cause instanceof Error) {
3628
+ message = error.cause.message || message;
3629
+ stack = error.cause.stack;
3630
+ }
3631
+ }
3632
+
3633
+ return {
3634
+ type: 'error',
3635
+ errorType: 'user',
3636
+ id,
3637
+ responseID: requestID,
3638
+ message,
3639
+ stack,
3640
+ };
3641
+ }
3642
+
3643
+ // the error name is synced with ReactDebugHooks
3644
+ if (error.name === 'ReactDebugToolsUnsupportedHookError') {
3645
+ return {
3646
+ type: 'error',
3647
+ errorType: 'unknown-hook',
3648
+ id,
3649
+ responseID: requestID,
3650
+ message:
3651
+ 'Unsupported hook in the react-debug-tools package: ' +
3652
+ error.message,
3653
+ };
3654
+ }
3655
+
3656
+ // Log Uncaught Error
3657
console.error('Error inspecting element.\n\n', error);
3658
3659
return {
3660
type: 'error',
3661
+ errorType: 'uncaught',
3662
id,
3663
responseID: requestID,
3664
message: error.message,
packages/react-devtools-shared/src/backend/types.js
+2
-1
@@ -289,8 +289,9 @@ export type InspectElementError = {|
289
id: number,
290
responseID: number,
291
type: 'error',
292
+ errorType: 'user' | 'unknown-hook' | 'uncaught',
293
message: string,
293
- stack: string,
294
+ stack?: string,
295
|};
296
297
export type InspectElementFullData = {|
packages/react-devtools-shared/src/backendAPI.js
+1
-1
@@ -10,7 +10,7 @@
10
import {hydrate, fillInPath} from 'react-devtools-shared/src/hydration';
11
import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils';
12
import Store from 'react-devtools-shared/src/devtools/store';
13
-import TimeoutError from 'react-devtools-shared/src/TimeoutError';
13
+import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';
14
15
import type {
16
InspectedElement as InspectedElementBackend,
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/CaughtErrorView.js
new
+44
@@ -0,0 +1,44 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+import * as React from 'react';
11
+import styles from './shared.css';
12
+
13
+type Props = {|
14
+ callStack: string | null,
15
+ children: React$Node,
16
+ info: React$Node | null,
17
+ componentStack: string | null,
18
+ errorMessage: string,
19
+|};
20
+
21
+export default function CaughtErrorView({
22
+ callStack,
23
+ children,
24
+ info,
25
+ componentStack,
26
+ errorMessage,
27
+}: Props) {
28
+ return (
29
+ <div className={styles.ErrorBoundary}>
30
+ {children}
31
+ <div className={styles.ErrorInfo}>
32
+ <div className={styles.HeaderRow}>
33
+ <div className={styles.ErrorHeader}>{errorMessage}</div>
34
+ </div>
35
+ {!!info && <div className={styles.InfoBox}>{info}</div>}
36
+ {!!callStack && (
37
+ <div className={styles.ErrorStack}>
38
+ The error was thrown {callStack.trim()}
39
+ </div>
40
+ )}
41
+ </div>
42
+ </div>
43
+ );
44
+}
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js
+46
-5
@@ -15,8 +15,11 @@ import ErrorView from './ErrorView';
15
import SearchingGitHubIssues from './SearchingGitHubIssues';
16
import SuspendingErrorView from './SuspendingErrorView';
17
import TimeoutView from './TimeoutView';
18
+import CaughtErrorView from './CaughtErrorView';
19
import UnsupportedBridgeOperationError from 'react-devtools-shared/src/UnsupportedBridgeOperationError';
19
-import TimeoutError from 'react-devtools-shared/src/TimeoutError';
20
+import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError';
21
+import UserError from 'react-devtools-shared/src/errors/UserError';
22
+import UnknownHookError from 'react-devtools-shared/src/errors/UnknownHookError';
23
import {logEvent} from 'react-devtools-shared/src/Logger';
24
25
type Props = {|
@@ -34,6 +37,8 @@ type State = {|
37
hasError: boolean,
38
isUnsupportedBridgeOperationError: boolean,
39
isTimeout: boolean,
40
+ isUserError: boolean,
41
+ isUnknownHookError: boolean,
42
|};
43
44
const InitialState: State = {
@@ -44,6 +49,8 @@ const InitialState: State = {
49
hasError: false,
50
isUnsupportedBridgeOperationError: false,
51
isTimeout: false,
52
+ isUserError: false,
53
+ isUnknownHookError: false,
54
};
55
56
export default class ErrorBoundary extends Component<Props, State> {
@@ -58,6 +65,8 @@ export default class ErrorBoundary extends Component<Props, State> {
65
: null;
66
67
const isTimeout = error instanceof TimeoutError;
68
+ const isUserError = error instanceof UserError;
69
+ const isUnknownHookError = error instanceof UnknownHookError;
70
const isUnsupportedBridgeOperationError =
71
error instanceof UnsupportedBridgeOperationError;
72
@@ -76,7 +85,9 @@ export default class ErrorBoundary extends Component<Props, State> {
85
errorMessage,
86
hasError: true,
87
isUnsupportedBridgeOperationError,
88
+ isUnknownHookError,
89
isTimeout,
90
+ isUserError,
91
};
92
}
93
@@ -111,6 +122,8 @@ export default class ErrorBoundary extends Component<Props, State> {
122
hasError,
123
isUnsupportedBridgeOperationError,
124
isTimeout,
125
+ isUserError,
126
+ isUnknownHookError,
127
} = this.state;
128
129
if (hasError) {
@@ -133,6 +146,37 @@ export default class ErrorBoundary extends Component<Props, State> {
146
errorMessage={errorMessage}
147
/>
148
);
149
+ } else if (isUserError) {
150
+ return (
151
+ <CaughtErrorView
152
+ callStack={callStack}
153
+ componentStack={componentStack}
154
+ errorMessage={errorMessage || 'Error occured in inspected element'}
155
+ info={
156
+ <>
157
+ React DevTools encountered an error while trying to inspect the
158
+ hooks. This is most likely caused by a developer error in the
159
+ currently inspected element. Please see your console for logged
160
+ error.
161
+ </>
162
+ }
163
+ />
164
+ );
165
+ } else if (isUnknownHookError) {
166
+ return (
167
+ <CaughtErrorView
168
+ callStack={callStack}
169
+ componentStack={componentStack}
170
+ errorMessage={errorMessage || 'Encountered an unknown hook'}
171
+ info={
172
+ <>
173
+ React DevTools encountered an unknown hook. This is probably
174
+ because the react-debug-tools package is out of date. To fix,
175
+ upgrade the React DevTools to the most recent version.
176
+ </>
177
+ }
178
+ />
179
+ );
180
} else {
181
return (
182
<ErrorView
@@ -141,10 +185,7 @@ export default class ErrorBoundary extends Component<Props, State> {
185
dismissError={
186
canDismissProp || canDismissState ? this._dismissError : null
187
}
144
- errorMessage={errorMessage}
145
- isUnsupportedBridgeOperationError={
146
- isUnsupportedBridgeOperationError
147
- }>
188
+ errorMessage={errorMessage}>
189
<Suspense fallback={<SearchingGitHubIssues />}>
190
<SuspendingErrorView
191
callStack={callStack}
packages/react-devtools-shared/src/errors/TimeoutError.js
renamed
packages/react-devtools-shared/src/errors/UnknownHookError.js
new
+21
@@ -0,0 +1,21 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+export default class UnknownHookError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+
14
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, UnknownHookError);
17
+ }
18
+
19
+ this.name = 'UnknownHookError';
20
+ }
21
+}
packages/react-devtools-shared/src/errors/UserError.js
new
+21
@@ -0,0 +1,21 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+export default class UserError extends Error {
11
+ constructor(message: string) {
12
+ super(message);
13
+
14
+ // Maintains proper stack trace for where our error was thrown (only available on V8)
15
+ if (Error.captureStackTrace) {
16
+ Error.captureStackTrace(this, UserError);
17
+ }
18
+
19
+ this.name = 'UserError';
20
+ }
21
+}
packages/react-devtools-shared/src/inspectedElementMutableSource.js
+17
-5
@@ -27,6 +27,8 @@ import type {
27
InspectedElement as InspectedElementFrontend,
28
InspectedElementResponseType,
29
} from 'react-devtools-shared/src/devtools/views/Components/types';
30
+import UserError from 'react-devtools-shared/src/errors/UserError';
31
+import UnknownHookError from 'react-devtools-shared/src/errors/UnknownHookError';
32
33
// Maps element ID to inspected data.
34
// We use an LRU for this rather than a WeakMap because of how the "no-change" optimization works.
@@ -80,14 +82,24 @@ export function inspectElement({
82
83
let inspectedElement;
84
switch (type) {
83
- case 'error':
84
- const {message, stack} = ((data: any): InspectElementError);
85
-
85
+ case 'error': {
86
+ const {message, stack, errorType} = ((data: any): InspectElementError);
87
+
88
+ // create a different error class for each error type
89
+ // and keep useful information from backend.
90
+ let error;
91
+ if (errorType === 'user') {
92
+ error = new UserError(message);
93
+ } else if (errorType === 'unknown-hook') {
94
+ error = new UnknownHookError(message);
95
+ } else {
96
+ error = new Error(message);
97
+ }
98
// The backend's stack (where the error originated) is more meaningful than this stack.
87
- const error = new Error(message);
88
- error.stack = stack;
99
+ error.stack = stack || error.stack;
100
101
throw error;
102
+ }
103
104
case 'no-change':
105
// This is a no-op for the purposes of our cache.