Show different error boundary UI for timeouts than normal errors (#22483)
Brian Vaughn committed
Oct 1, 2021 at 15:03 UTC
5f3b376c56c6c523e7018f5128a768cbd67a8a6f
7 files changed
+146
-31
packages/react-devtools-shared/src/TimeoutError.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 TimeoutError 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, TimeoutError);
17
+ }
18
+
19
+ this.name = 'TimeoutError';
20
+ }
21
+}
packages/react-devtools-shared/src/backendAPI.js
+4
-3
@@ -10,6 +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';
14
15
import type {
16
InspectedElement as InspectedElementBackend,
@@ -102,6 +103,7 @@ export function inspectElement({
103
requestID,
104
'inspectedElement',
105
bridge,
106
+ `Timed out while inspecting element ${id}.`,
107
);
108
109
bridge.send('inspectElement', {
@@ -144,6 +146,7 @@ function getPromiseForRequestID<T>(
146
requestID: number,
147
eventType: $Keys<BackendEvents>,
148
bridge: FrontendBridge,
149
+ timeoutMessage: string,
150
): Promise<T> {
151
return new Promise((resolve, reject) => {
152
const cleanup = () => {
@@ -161,9 +164,7 @@ function getPromiseForRequestID<T>(
164
165
const onTimeout = () => {
166
cleanup();
164
- reject(
165
- new Error(`Timed out waiting for event '${eventType}' from bridge`),
166
- );
167
+ reject(new TimeoutError(timeoutMessage));
168
};
169
170
bridge.addListener(eventType, onInspectedElement);
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js
+38
-17
@@ -13,6 +13,8 @@ import Store from 'react-devtools-shared/src/devtools/store';
13
import ErrorView from './ErrorView';
14
import SearchingGitHubIssues from './SearchingGitHubIssues';
15
import SuspendingErrorView from './SuspendingErrorView';
16
+import TimeoutView from './TimeoutView';
17
+import TimeoutError from 'react-devtools-shared/src/TimeoutError';
18
19
type Props = {|
20
children: React$Node,
@@ -27,6 +29,7 @@ type State = {|
29
componentStack: string | null,
30
errorMessage: string | null,
31
hasError: boolean,
32
+ isTimeout: boolean,
33
|};
34
35
const InitialState: State = {
@@ -35,6 +38,7 @@ const InitialState: State = {
38
componentStack: null,
39
errorMessage: null,
40
hasError: false,
41
+ isTimeout: false,
42
};
43
44
export default class ErrorBoundary extends Component<Props, State> {
@@ -48,6 +52,8 @@ export default class ErrorBoundary extends Component<Props, State> {
52
? error.message
53
: String(error);
54
55
+ const isTimeout = error instanceof TimeoutError;
56
+
57
const callStack =
58
typeof error === 'object' &&
59
error !== null &&
@@ -62,6 +68,7 @@ export default class ErrorBoundary extends Component<Props, State> {
68
callStack,
69
errorMessage,
70
hasError: true,
71
+ isTimeout,
72
};
73
}
74
@@ -93,26 +100,40 @@ export default class ErrorBoundary extends Component<Props, State> {
100
componentStack,
101
errorMessage,
102
hasError,
103
+ isTimeout,
104
} = this.state;
105
106
if (hasError) {
99
- return (
100
- <ErrorView
101
- callStack={callStack}
102
- componentStack={componentStack}
103
- dismissError={
104
- canDismissProp || canDismissState ? this._dismissError : null
105
- }
106
- errorMessage={errorMessage}>
107
- <Suspense fallback={<SearchingGitHubIssues />}>
108
- <SuspendingErrorView
109
- callStack={callStack}
110
- componentStack={componentStack}
111
- errorMessage={errorMessage}
112
- />
113
- </Suspense>
114
- </ErrorView>
115
- );
107
+ if (isTimeout) {
108
+ return (
109
+ <TimeoutView
110
+ callStack={callStack}
111
+ componentStack={componentStack}
112
+ dismissError={
113
+ canDismissProp || canDismissState ? this._dismissError : null
114
+ }
115
+ errorMessage={errorMessage}
116
+ />
117
+ );
118
+ } else {
119
+ return (
120
+ <ErrorView
121
+ callStack={callStack}
122
+ componentStack={componentStack}
123
+ dismissError={
124
+ canDismissProp || canDismissState ? this._dismissError : null
125
+ }
126
+ errorMessage={errorMessage}>
127
+ <Suspense fallback={<SearchingGitHubIssues />}>
128
+ <SuspendingErrorView
129
+ callStack={callStack}
130
+ componentStack={componentStack}
131
+ errorMessage={errorMessage}
132
+ />
133
+ </Suspense>
134
+ </ErrorView>
135
+ );
136
+ }
137
}
138
139
return children;
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorView.js
+3
-3
@@ -32,7 +32,7 @@ export default function ErrorView({
32
{children}
33
<div className={styles.ErrorInfo}>
34
<div className={styles.HeaderRow}>
35
- <div className={styles.Header}>
35
+ <div className={styles.ErrorHeader}>
36
Uncaught Error: {errorMessage || ''}
37
</div>
38
{dismissError !== null && (
@@ -43,12 +43,12 @@ export default function ErrorView({
43
)}
44
</div>
45
{!!callStack && (
46
- <div className={styles.Stack}>
46
+ <div className={styles.ErrorStack}>
47
The error was thrown {callStack.trim()}
48
</div>
49
)}
50
{!!componentStack && (
51
- <div className={styles.Stack}>
51
+ <div className={styles.ErrorStack}>
52
The error occurred {componentStack.trim()}
53
</div>
54
)}
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/TimeoutView.js
new
+51
@@ -0,0 +1,51 @@
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 Button from '../Button';
12
+import ButtonIcon from '../ButtonIcon';
13
+import styles from './shared.css';
14
+
15
+type Props = {|
16
+ callStack: string | null,
17
+ children: React$Node,
18
+ componentStack: string | null,
19
+ dismissError: Function,
20
+ errorMessage: string | null,
21
+|};
22
+
23
+export default function TimeoutView({
24
+ callStack,
25
+ children,
26
+ componentStack,
27
+ dismissError = null,
28
+ errorMessage,
29
+}: Props) {
30
+ return (
31
+ <div className={styles.ErrorBoundary}>
32
+ {children}
33
+ <div className={styles.ErrorInfo}>
34
+ <div className={styles.HeaderRow}>
35
+ <div className={styles.TimeoutHeader}>
36
+ {errorMessage || 'Timed out waiting'}
37
+ </div>
38
+ <Button className={styles.CloseButton} onClick={dismissError}>
39
+ Retry
40
+ <ButtonIcon className={styles.CloseButtonIcon} type="close" />
41
+ </Button>
42
+ </div>
43
+ {!!componentStack && (
44
+ <div className={styles.TimeoutStack}>
45
+ The timeout occurred {componentStack.trim()}
46
+ </div>
47
+ )}
48
+ </div>
49
+ </div>
50
+ );
51
+}
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/shared.css
+24
-5
@@ -30,6 +30,7 @@
30
background-color: var(--color-background);
31
display: flex;
32
flex-direction: column;
33
+ border: 1px solid var(--color-border);
34
}
35
36
.ErrorInfo {
@@ -42,10 +43,10 @@
43
flex-direction: row;
44
font-size: var(--font-size-sans-large);
45
font-weight: bold;
45
- color: var(--color-error-text);
46
}
47
48
-.Header {
48
+.ErrorHeader,
49
+.TimeoutHeader {
50
flex: 1 1 auto;
51
overflow: hidden;
52
text-overflow: ellipsis;
@@ -53,17 +54,35 @@
54
min-width: 0;
55
}
56
56
-.Stack {
57
+.ErrorHeader {
58
+ color: var(--color-error-text);
59
+}
60
+.TimeoutHeader {
61
+ color: var(--color-text);
62
+}
63
+
64
+.ErrorStack,
65
+.TimeoutStack {
66
margin-top: 0.5rem;
67
white-space: pre-wrap;
68
font-family: var(--font-family-monospace);
69
font-size: var(--font-size-monospace-normal);
70
-webkit-font-smoothing: initial;
71
+ border-radius: 0.25rem;
72
+ padding: 0.5rem;
73
+ overflow: auto;
74
+}
75
+
76
+.ErrorStack {
77
background-color: var(--color-error-background);
78
border: 1px solid var(--color-error-border);
79
color: var(--color-error-text);
65
- border-radius: 0.25rem;
66
- padding: 0.5rem;
80
+}
81
+
82
+.TimeoutStack {
83
+ background-color: var(--color-console-warning-background);
84
+ color: var(--color-console-warning-text);
85
+ border: var(--color-console-warning-border)
86
}
87
88
.LoadingIcon {
packages/react-devtools-shared/src/inspectedElementCache.js
+5
-3
@@ -38,7 +38,7 @@ type ResolvedRecord<T> = {|
38
39
type RejectedRecord = {|
40
status: 2,
41
- value: string,
41
+ value: Error | string,
42
|};
43
44
type Record<T> = PendingRecord | ResolvedRecord<T> | RejectedRecord;
@@ -113,7 +113,9 @@ export function inspectElement(
113
if (rendererID == null) {
114
const rejectedRecord = ((newRecord: any): RejectedRecord);
115
rejectedRecord.status = Rejected;
116
- rejectedRecord.value = `Could not inspect element with id "${element.id}". No renderer found.`;
116
+ rejectedRecord.value = new Error(
117
+ `Could not inspect element with id "${element.id}". No renderer found.`,
118
+ );
119
120
map.set(element, record);
121
@@ -139,7 +141,7 @@ export function inspectElement(
141
142
const rejectedRecord = ((newRecord: any): RejectedRecord);
143
rejectedRecord.status = Rejected;
142
- rejectedRecord.value = `Could not inspect element with id "${element.id}". Error thrown:\n${error.message}`;
144
+ rejectedRecord.value = error;
145
146
wake();
147
},