@samitouri / QOS-React-2 / commits / 8ee4ff8836

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);