Repository: facebook/react
Pull request: Surface backend errors during inspection in the frontend UI

Previously, if a component were to throw an error while being inspected, the backend would not return a response, causing the frontend to eventually timeout.

The fix for this is to catch the error and return it as a new error type response. This allows the frontend to show a more actionable message to the user.

Changed files:
filename: packages/react-devtools-shared/src/__tests__/inspectedElement-test.js
patch: @@ -35,6 +35,9 @@ describe('InspectedElement', () => {
   let legacyRender;
   let testRendererInstance;
 
+  let ErrorBoundary;
+  let errorBoundaryInstance;
+
   beforeEach(() => {
     utils = require('./utils');
     utils.beforeEachProfiling();
@@ -69,6 +72,23 @@ describe('InspectedElement', () => {
     testRendererInstance = TestRenderer.create(null, {
       unstable_isConcurrent: true,
     });
+
+    errorBoundaryInstance = null;
+
+    ErrorBoundary = class extends React.Component {
+      state = {error: null};
+      componentDidCatch(error) {
+        this.setState({error});
+      }
+      render() {
+        errorBoundaryInstance = this;
+
+        if (this.state.error) {
+          return null;
+        }
+        return this.props.children;
+      }
+    };
   });
 
   afterEach(() => {
@@ -109,7 +129,11 @@ describe('InspectedElement', () => {
 
   function noop() {}
 
-  async function inspectElementAtIndex(index, useCustomHook = noop) {
+  async function inspectElementAtIndex(
+    index,
+    useCustomHook = noop,
+    shouldThrow = false,
+  ) {
     let didFinish = false;
     let inspectedElement = null;
 
@@ -124,17 +148,21 @@ describe('InspectedElement', () => {
 
     await utils.actAsync(() => {
       testRendererInstance.update(
-        <Contexts
-          defaultSelectedElementID={id}
-          defaultSelectedElementIndex={index}>
-          <React.Suspense fallback={null}>
-            <Suspender id={id} index={index} />
-          </React.Suspense>
-        </Contexts>,
+        <ErrorBoundary>
+          <Contexts
+            defaultSelectedElementID={id}
+            defaultSelectedElementIndex={index}>
+            <React.Suspense fallback={null}>
+              <Suspender id={id} index={index} />
+            </React.Suspense>
+          </Contexts>
+        </ErrorBoundary>,
       );
     }, false);
 
-    expect(didFinish).toBe(true);
+    if (!shouldThrow) {
+      expect(didFinish).toBe(true);
+    }
 
     return inspectedElement;
   }
@@ -2069,6 +2097,37 @@ describe('InspectedElement', () => {
     expect(inspectedElement.rootType).toMatchInlineSnapshot(`"createRoot()"`);
   });
 
+  it('should gracefully surface backend errors on the frontend rather than timing out', async () => {
+    spyOn(console, 'error');
+
+    let shouldThrow = false;
+
+    const Example = () => {
+      const [count] = React.useState(0);
+
+      if (shouldThrow) {
+        throw Error('Expected');
+      } else {
+        return count;
+      }
+    };
+
+    await utils.actAsync(() => {
+      const container = document.createElement('div');
+      ReactDOM.createRoot(container).render(<Example />);
+    }, false);
+
+    shouldThrow = true;
+
+    const value = await inspectElementAtIndex(0, noop, true);
+
+    expect(value).toBe(null);
+
+    const error = errorBoundaryInstance.state.error;
+    expect(error.message).toBe('Expected');
+    expect(error.stack).toContain('inspectHooksOfFiber');
+  });
+
   describe('$r', () => {
     it('should support function components', async () => {
       const Example = () => {
@@ -2656,7 +2715,7 @@ describe('InspectedElement', () => {
 
   describe('error boundary', () => {
     it('can toggle error', async () => {
-      class ErrorBoundary extends React.Component<any> {
+      class LocalErrorBoundary extends React.Component<any> {
         state = {hasError: false};
         static getDerivedStateFromError(error) {
           return {hasError: true};
@@ -2666,13 +2725,14 @@ describe('InspectedElement', () => {
           return hasError ? 'has-error' : this.props.children;
         }
       }
+
       const Example = () => 'example';
 
       await utils.actAsync(() =>
         legacyRender(
-          <ErrorBoundary>
+          <LocalErrorBoundary>
             <Example />
-          </ErrorBoundary>,
+          </LocalErrorBoundary>,
           document.createElement('div'),
         ),
       );

filename: packages/react-devtools-shared/src/backend/renderer.js
patch: @@ -3471,7 +3471,20 @@ export function attach(
 
     hasElementUpdatedSinceLastInspected = false;
 
-    mostRecentlyInspectedElement = inspectElementRaw(id);
+    try {
+      mostRecentlyInspectedElement = inspectElementRaw(id);
+    } catch (error) {
+      console.error('Error inspecting element.\n\n', error);
+
+      return {
+        type: 'error',
+        id,
+        responseID: requestID,
+        message: error.message,
+        stack: error.stack,
+      };
+    }
+
     if (mostRecentlyInspectedElement === null) {
       return {
         id,

filename: packages/react-devtools-shared/src/backend/types.js
patch: @@ -267,10 +267,19 @@ export type InspectedElement = {|
   rendererVersion: string | null,
 |};
 
+export const InspectElementErrorType = 'error';
 export const InspectElementFullDataType = 'full-data';
 export const InspectElementNoChangeType = 'no-change';
 export const InspectElementNotFoundType = 'not-found';
 
+export type InspectElementError = {|
+  id: number,
+  responseID: number,
+  type: 'error',
+  message: string,
+  stack: string,
+|};
+
 export type InspectElementFullData = {|
   id: number,
   responseID: number,
@@ -299,6 +308,7 @@ export type InspectElementNotFound = {|
 |};
 
 export type InspectedElementPayload =
+  | InspectElementError
   | InspectElementFullData
   | InspectElementHydratedPath
   | InspectElementNoChange

filename: packages/react-devtools-shared/src/devtools/views/Components/types.js
patch: @@ -58,6 +58,7 @@ export type OwnersList = {|
 |};
 
 export type InspectedElementResponseType =
+  | 'error'
   | 'full-data'
   | 'hydrated-path'
   | 'no-change'

filename: packages/react-devtools-shared/src/inspectedElementMutableSource.js
patch: @@ -18,6 +18,7 @@ import {fillInPath} from 'react-devtools-shared/src/hydration';
 import type {LRUCache} from 'react-devtools-shared/src/types';
 import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
 import type {
+  InspectElementError,
   InspectElementFullData,
   InspectElementHydratedPath,
 } from 'react-devtools-shared/src/backend/types';
@@ -79,6 +80,15 @@ export function inspectElement({
 
     let inspectedElement;
     switch (type) {
+      case 'error':
+        const {message, stack} = ((data: any): InspectElementError);
+
+        // The backend's stack (where the error originated) is more meaningful than this stack.
+        const error = new Error(message);
+        error.stack = stack;
+
+        throw error;
+
       case 'no-change':
         // This is a no-op for the purposes of our cache.
         inspectedElement = inspectedElementCache.get(id);