Skip to content

Commit 8ee4ff8

Browse files
author
Brian Vaughn
authored
Surface backend errors during inspection in the frontend UI (#22546)
1 parent 1e247ff commit 8ee4ff8

File tree

5 files changed

+99
-5
lines changed

5 files changed

+99
-5
lines changed

packages/react-devtools-shared/src/__tests__/inspectedElement-test.js

Lines changed: 65 additions & 5 deletions
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -35,6 +35,9 @@ describe('InspectedElement', () => {
35
let legacyRender;
35
let legacyRender;
36
let testRendererInstance;
36
let testRendererInstance;
37

37

38+
let ErrorBoundary;
39+
let errorBoundaryInstance;
40+
38
beforeEach(() => {
41
beforeEach(() => {
39
utils = require('./utils');
42
utils = require('./utils');
40
utils.beforeEachProfiling();
43
utils.beforeEachProfiling();
@@ -69,6 +72,23 @@ describe('InspectedElement', () => {
69
testRendererInstance = TestRenderer.create(null, {
72
testRendererInstance = TestRenderer.create(null, {
70
unstable_isConcurrent: true,
73
unstable_isConcurrent: true,
71
});
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+
};
72
});
92
});
73

93

74
afterEach(() => {
94
afterEach(() => {
@@ -109,7 +129,11 @@ describe('InspectedElement', () => {
109

129

110
function noop() {}
130
function noop() {}
111

131

112-
async function inspectElementAtIndex(index, useCustomHook = noop) {
132+
async function inspectElementAtIndex(
133+
index,
134+
useCustomHook = noop,
135+
shouldThrow = false,
136+
) {
113
let didFinish = false;
137
let didFinish = false;
114
let inspectedElement = null;
138
let inspectedElement = null;
115

139

@@ -124,17 +148,21 @@ describe('InspectedElement', () => {
124

148

125
await utils.actAsync(() => {
149
await utils.actAsync(() => {
126
testRendererInstance.update(
150
testRendererInstance.update(
151+
<ErrorBoundary>
127
<Contexts
152
<Contexts
128
defaultSelectedElementID={id}
153
defaultSelectedElementID={id}
129
defaultSelectedElementIndex={index}>
154
defaultSelectedElementIndex={index}>
130
<React.Suspense fallback={null}>
155
<React.Suspense fallback={null}>
131
<Suspender id={id} index={index} />
156
<Suspender id={id} index={index} />
132
</React.Suspense>
157
</React.Suspense>
133-
</Contexts>,
158+
</Contexts>
159+
</ErrorBoundary>,
134
);
160
);
135
}, false);
161
}, false);
136

162

163+
if (!shouldThrow) {
137
expect(didFinish).toBe(true);
164
expect(didFinish).toBe(true);
165+
}
138

166

139
return inspectedElement;
167
return inspectedElement;
140
}
168
}
@@ -2069,6 +2097,37 @@ describe('InspectedElement', () => {
2069
expect(inspectedElement.rootType).toMatchInlineSnapshot(`"createRoot()"`);
2097
expect(inspectedElement.rootType).toMatchInlineSnapshot(`"createRoot()"`);
2070
});
2098
});
2071

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+
2072
describe('$r', () => {
2131
describe('$r', () => {
2073
it('should support function components', async () => {
2132
it('should support function components', async () => {
2074
const Example = () => {
2133
const Example = () => {
@@ -2656,7 +2715,7 @@ describe('InspectedElement', () => {
2656

2715

2657
describe('error boundary', () => {
2716
describe('error boundary', () => {
2658
it('can toggle error', async () => {
2717
it('can toggle error', async () => {
2659-
class ErrorBoundary extends React.Component<any> {
2718+
class LocalErrorBoundary extends React.Component<any> {
2660
state = {hasError: false};
2719
state = {hasError: false};
2661
static getDerivedStateFromError(error) {
2720
static getDerivedStateFromError(error) {
2662
return {hasError: true};
2721
return {hasError: true};
@@ -2666,13 +2725,14 @@ describe('InspectedElement', () => {
2666
return hasError ? 'has-error' : this.props.children;
2725
return hasError ? 'has-error' : this.props.children;
2667
}
2726
}
2668
}
2727
}
2728+
2669
const Example = () => 'example';
2729
const Example = () => 'example';
2670

2730

2671
await utils.actAsync(() =>
2731
await utils.actAsync(() =>
2672
legacyRender(
2732
legacyRender(
2673-
<ErrorBoundary>
2733+
<LocalErrorBoundary>
2674
<Example />
2734
<Example />
2675-
</ErrorBoundary>,
2735+
</LocalErrorBoundary>,
2676
document.createElement('div'),
2736
document.createElement('div'),
2677
),
2737
),
2678
);
2738
);

packages/react-devtools-shared/src/backend/renderer.js

Lines changed: 13 additions & 0 deletions
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -3471,7 +3471,20 @@ export function attach(
3471

3471

3472
hasElementUpdatedSinceLastInspected = false;
3472
hasElementUpdatedSinceLastInspected = false;
3473

3473

3474+
try {
3474
mostRecentlyInspectedElement = inspectElementRaw(id);
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+
3475
if (mostRecentlyInspectedElement === null) {
3488
if (mostRecentlyInspectedElement === null) {
3476
return {
3489
return {
3477
id,
3490
id,

packages/react-devtools-shared/src/backend/types.js

Lines changed: 10 additions & 0 deletions
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -267,10 +267,19 @@ export type InspectedElement = {|
267
rendererVersion: string | null,
267
rendererVersion: string | null,
268
|};
268
|};
269

269

270+
export const InspectElementErrorType = 'error';
270
export const InspectElementFullDataType = 'full-data';
271
export const InspectElementFullDataType = 'full-data';
271
export const InspectElementNoChangeType = 'no-change';
272
export const InspectElementNoChangeType = 'no-change';
272
export const InspectElementNotFoundType = 'not-found';
273
export const InspectElementNotFoundType = 'not-found';
273

274

275+
export type InspectElementError = {|
276+
id: number,
277+
responseID: number,
278+
type: 'error',
279+
message: string,
280+
stack: string,
281+
|};
282+
274
export type InspectElementFullData = {|
283
export type InspectElementFullData = {|
275
id: number,
284
id: number,
276
responseID: number,
285
responseID: number,
@@ -299,6 +308,7 @@ export type InspectElementNotFound = {|
299
|};
308
|};
300

309

301
export type InspectedElementPayload =
310
export type InspectedElementPayload =
311+
| InspectElementError
302
| InspectElementFullData
312
| InspectElementFullData
303
| InspectElementHydratedPath
313
| InspectElementHydratedPath
304
| InspectElementNoChange
314
| InspectElementNoChange

packages/react-devtools-shared/src/devtools/views/Components/types.js

Lines changed: 1 addition & 0 deletions
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -58,6 +58,7 @@ export type OwnersList = {|
58
|};
58
|};
59

59

60
export type InspectedElementResponseType =
60
export type InspectedElementResponseType =
61+
| 'error'
61
| 'full-data'
62
| 'full-data'
62
| 'hydrated-path'
63
| 'hydrated-path'
63
| 'no-change'
64
| 'no-change'

packages/react-devtools-shared/src/inspectedElementMutableSource.js

Lines changed: 10 additions & 0 deletions
Original file line numberOriginal file lineDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {fillInPath} from 'react-devtools-shared/src/hydration';
18
import type {LRUCache} from 'react-devtools-shared/src/types';
18
import type {LRUCache} from 'react-devtools-shared/src/types';
19
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
19
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
20
import type {
20
import type {
21+
InspectElementError,
21
InspectElementFullData,
22
InspectElementFullData,
22
InspectElementHydratedPath,
23
InspectElementHydratedPath,
23
} from 'react-devtools-shared/src/backend/types';
24
} from 'react-devtools-shared/src/backend/types';
@@ -79,6 +80,15 @@ export function inspectElement({
79

80

80
let inspectedElement;
81
let inspectedElement;
81
switch (type) {
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+
82
case 'no-change':
92
case 'no-change':
83
// This is a no-op for the purposes of our cache.
93
// This is a no-op for the purposes of our cache.
84
inspectedElement = inspectedElementCache.get(id);
94
inspectedElement = inspectedElementCache.get(id);

0 commit comments

Comments
 (0)