Surface backend errors during inspection in the frontend UI (#22546)
Brian Vaughn committed
Oct 13, 2021 at 10:35 UTC
8ee4ff88361eff8e8a8aeec32ba3315ad2065131
5 files changed
+107
-13
packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
+72
-12
@@ -35,6 +35,9 @@ describe('InspectedElement', () => {
35
let legacyRender;
36
let testRendererInstance;
37
38
+ let ErrorBoundary;
39
+ let errorBoundaryInstance;
40
+
41
beforeEach(() => {
42
utils = require('./utils');
43
utils.beforeEachProfiling();
@@ -69,6 +72,23 @@ describe('InspectedElement', () => {
72
testRendererInstance = TestRenderer.create(null, {
73
unstable_isConcurrent: true,
74
});
75
+
76
+ errorBoundaryInstance = null;
77
+
78
+ ErrorBoundary = class extends React.Component {
79
+ state = {error: null};
80
+ componentDidCatch(error) {
81
+ this.setState({error});
82
+ }
83
+ render() {
84
+ errorBoundaryInstance = this;
85
+
86
+ if (this.state.error) {
87
+ return null;
88
+ }
89
+ return this.props.children;
90
+ }
91
+ };
92
});
93
94
afterEach(() => {
@@ -109,7 +129,11 @@ describe('InspectedElement', () => {
129
130
function noop() {}
131
112
- async function inspectElementAtIndex(index, useCustomHook = noop) {
132
+ async function inspectElementAtIndex(
133
+ index,
134
+ useCustomHook = noop,
135
+ shouldThrow = false,
136
+ ) {
137
let didFinish = false;
138
let inspectedElement = null;
139
@@ -124,17 +148,21 @@ describe('InspectedElement', () => {
148
149
await utils.actAsync(() => {
150
testRendererInstance.update(
127
- <Contexts
128
- defaultSelectedElementID={id}
129
- defaultSelectedElementIndex={index}>
130
- <React.Suspense fallback={null}>
131
- <Suspender id={id} index={index} />
132
- </React.Suspense>
133
- </Contexts>,
151
+ <ErrorBoundary>
152
+ <Contexts
153
+ defaultSelectedElementID={id}
154
+ defaultSelectedElementIndex={index}>
155
+ <React.Suspense fallback={null}>
156
+ <Suspender id={id} index={index} />
157
+ </React.Suspense>
158
+ </Contexts>
159
+ </ErrorBoundary>,
160
);
161
}, false);
162
137
- expect(didFinish).toBe(true);
163
+ if (!shouldThrow) {
164
+ expect(didFinish).toBe(true);
165
+ }
166
167
return inspectedElement;
168
}
@@ -2069,6 +2097,37 @@ describe('InspectedElement', () => {
2097
expect(inspectedElement.rootType).toMatchInlineSnapshot(`"createRoot()"`);
2098
});
2099
2100
+ it('should gracefully surface backend errors on the frontend rather than timing out', async () => {
2101
+ spyOn(console, 'error');
2102
+
2103
+ let shouldThrow = false;
2104
+
2105
+ const Example = () => {
2106
+ const [count] = React.useState(0);
2107
+
2108
+ if (shouldThrow) {
2109
+ throw Error('Expected');
2110
+ } else {
2111
+ return count;
2112
+ }
2113
+ };
2114
+
2115
+ await utils.actAsync(() => {
2116
+ const container = document.createElement('div');
2117
+ ReactDOM.createRoot(container).render(<Example />);
2118
+ }, false);
2119
+
2120
+ shouldThrow = true;
2121
+
2122
+ const value = await inspectElementAtIndex(0, noop, true);
2123
+
2124
+ expect(value).toBe(null);
2125
+
2126
+ const error = errorBoundaryInstance.state.error;
2127
+ expect(error.message).toBe('Expected');
2128
+ expect(error.stack).toContain('inspectHooksOfFiber');
2129
+ });
2130
+
2131
describe('$r', () => {
2132
it('should support function components', async () => {
2133
const Example = () => {
@@ -2656,7 +2715,7 @@ describe('InspectedElement', () => {
2715
2716
describe('error boundary', () => {
2717
it('can toggle error', async () => {
2659
- class ErrorBoundary extends React.Component<any> {
2718
+ class LocalErrorBoundary extends React.Component<any> {
2719
state = {hasError: false};
2720
static getDerivedStateFromError(error) {
2721
return {hasError: true};
@@ -2666,13 +2725,14 @@ describe('InspectedElement', () => {
2725
return hasError ? 'has-error' : this.props.children;
2726
}
2727
}
2728
+
2729
const Example = () => 'example';
2730
2731
await utils.actAsync(() =>
2732
legacyRender(
2673
- <ErrorBoundary>
2733
+ <LocalErrorBoundary>
2734
<Example />
2675
- </ErrorBoundary>,
2735
+ </LocalErrorBoundary>,
2736
document.createElement('div'),
2737
),
2738
);
packages/react-devtools-shared/src/backend/renderer.js
+14
-1
@@ -3471,7 +3471,20 @@ export function attach(
3471
3472
hasElementUpdatedSinceLastInspected = false;
3473
3474
- mostRecentlyInspectedElement = inspectElementRaw(id);
3474
+ try {
3475
+ mostRecentlyInspectedElement = inspectElementRaw(id);
3476
+ } catch (error) {
3477
+ console.error('Error inspecting element.\n\n', error);
3478
+
3479
+ return {
3480
+ type: 'error',
3481
+ id,
3482
+ responseID: requestID,
3483
+ message: error.message,
3484
+ stack: error.stack,
3485
+ };
3486
+ }
3487
+
3488
if (mostRecentlyInspectedElement === null) {
3489
return {
3490
id,
packages/react-devtools-shared/src/backend/types.js
+10
@@ -267,10 +267,19 @@ export type InspectedElement = {|
267
rendererVersion: string | null,
268
|};
269
270
+export const InspectElementErrorType = 'error';
271
export const InspectElementFullDataType = 'full-data';
272
export const InspectElementNoChangeType = 'no-change';
273
export const InspectElementNotFoundType = 'not-found';
274
275
+export type InspectElementError = {|
276
+ id: number,
277
+ responseID: number,
278
+ type: 'error',
279
+ message: string,
280
+ stack: string,
281
+|};
282
+
283
export type InspectElementFullData = {|
284
id: number,
285
responseID: number,
@@ -299,6 +308,7 @@ export type InspectElementNotFound = {|
308
|};
309
310
export type InspectedElementPayload =
311
+ | InspectElementError
312
| InspectElementFullData
313
| InspectElementHydratedPath
314
| InspectElementNoChange
packages/react-devtools-shared/src/devtools/views/Components/types.js
+1
@@ -58,6 +58,7 @@ export type OwnersList = {|
58
|};
59
60
export type InspectedElementResponseType =
61
+ | 'error'
62
| 'full-data'
63
| 'hydrated-path'
64
| 'no-change'
packages/react-devtools-shared/src/inspectedElementMutableSource.js
+10
@@ -18,6 +18,7 @@ import {fillInPath} from 'react-devtools-shared/src/hydration';
18
import type {LRUCache} from 'react-devtools-shared/src/types';
19
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
20
import type {
21
+ InspectElementError,
22
InspectElementFullData,
23
InspectElementHydratedPath,
24
} from 'react-devtools-shared/src/backend/types';
@@ -79,6 +80,15 @@ export function inspectElement({
80
81
let inspectedElement;
82
switch (type) {
83
+ case 'error':
84
+ const {message, stack} = ((data: any): InspectElementError);
85
+
86
+ // The backend's stack (where the error originated) is more meaningful than this stack.
87
+ const error = new Error(message);
88
+ error.stack = stack;
89
+
90
+ throw error;
91
+
92
case 'no-change':
93
// This is a no-op for the purposes of our cache.
94
inspectedElement = inspectedElementCache.get(id);