Repository: facebook/react
Pull request: Store throws a specific Error type (UnsupportedBridgeOperationError)

When this Error type is detected, DevTools shows a custom error overlay with upgrade/downgrade instructions.

Relates to #24142

## Before
<img width="778" alt="Screen Shot 2022-03-23 at 3 50 01 PM" src="https://user-images.githubusercontent.com/29597/159790360-0ae347f2-818d-4080-9cd6-9d56d8d678b5.png">

## After
<img width="830" alt="Screen Shot 2022-03-23 at 4 21 49 PM" src="https://user-images.githubusercontent.com/29597/159790363-7c8a89aa-a6cb-4ce9-8a1d-dd27ee2fe8e6.png">


Changed files:
filename: packages/react-devtools-shared/src/UnsupportedBridgeOperationError.js
patch: @@ -0,0 +1,21 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+export default class UnsupportedBridgeOperationError extends Error {
+  constructor(message: string) {
+    super(message);
+
+    // Maintains proper stack trace for where our error was thrown (only available on V8)
+    if (Error.captureStackTrace) {
+      Error.captureStackTrace(this, UnsupportedBridgeOperationError);
+    }
+
+    this.name = 'UnsupportedBridgeOperationError';
+  }
+}

filename: packages/react-devtools-shared/src/devtools/store.js
patch: @@ -44,6 +44,7 @@ import type {
   FrontendBridge,
   BridgeProtocol,
 } from 'react-devtools-shared/src/bridge';
+import UnsupportedBridgeOperationError from 'react-devtools-shared/src/UnsupportedBridgeOperationError';
 
 const debug = (methodName, ...args) => {
   if (__DEBUG__) {
@@ -1252,7 +1253,9 @@ export default class Store extends EventEmitter<{|
           break;
         default:
           this._throwAndEmitError(
-            Error(`Unsupported Bridge operation "${operation}"`),
+            new UnsupportedBridgeOperationError(
+              `Unsupported Bridge operation "${operation}"`,
+            ),
           );
       }
     }

filename: packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js
patch: @@ -10,10 +10,12 @@
 import * as React from 'react';
 import {Component, Suspense} from 'react';
 import Store from 'react-devtools-shared/src/devtools/store';
+import UnsupportedBridgeOperationView from './UnsupportedBridgeOperationView';
 import ErrorView from './ErrorView';
 import SearchingGitHubIssues from './SearchingGitHubIssues';
 import SuspendingErrorView from './SuspendingErrorView';
 import TimeoutView from './TimeoutView';
+import UnsupportedBridgeOperationError from 'react-devtools-shared/src/UnsupportedBridgeOperationError';
 import TimeoutError from 'react-devtools-shared/src/TimeoutError';
 import {logEvent} from 'react-devtools-shared/src/Logger';
 
@@ -30,6 +32,7 @@ type State = {|
   componentStack: string | null,
   errorMessage: string | null,
   hasError: boolean,
+  isUnsupportedBridgeOperationError: boolean,
   isTimeout: boolean,
 |};
 
@@ -39,6 +42,7 @@ const InitialState: State = {
   componentStack: null,
   errorMessage: null,
   hasError: false,
+  isUnsupportedBridgeOperationError: false,
   isTimeout: false,
 };
 
@@ -54,6 +58,8 @@ export default class ErrorBoundary extends Component<Props, State> {
         : null;
 
     const isTimeout = error instanceof TimeoutError;
+    const isUnsupportedBridgeOperationError =
+      error instanceof UnsupportedBridgeOperationError;
 
     const callStack =
       typeof error === 'object' &&
@@ -69,6 +75,7 @@ export default class ErrorBoundary extends Component<Props, State> {
       callStack,
       errorMessage,
       hasError: true,
+      isUnsupportedBridgeOperationError,
       isTimeout,
     };
   }
@@ -102,6 +109,7 @@ export default class ErrorBoundary extends Component<Props, State> {
       componentStack,
       errorMessage,
       hasError,
+      isUnsupportedBridgeOperationError,
       isTimeout,
     } = this.state;
 
@@ -117,6 +125,14 @@ export default class ErrorBoundary extends Component<Props, State> {
             errorMessage={errorMessage}
           />
         );
+      } else if (isUnsupportedBridgeOperationError) {
+        return (
+          <UnsupportedBridgeOperationView
+            callStack={callStack}
+            componentStack={componentStack}
+            errorMessage={errorMessage}
+          />
+        );
       } else {
         return (
           <ErrorView
@@ -125,7 +141,10 @@ export default class ErrorBoundary extends Component<Props, State> {
             dismissError={
               canDismissProp || canDismissState ? this._dismissError : null
             }
-            errorMessage={errorMessage}>
+            errorMessage={errorMessage}
+            isUnsupportedBridgeOperationError={
+              isUnsupportedBridgeOperationError
+            }>
             <Suspense fallback={<SearchingGitHubIssues />}>
               <SuspendingErrorView
                 callStack={callStack}

filename: packages/react-devtools-shared/src/devtools/views/ErrorBoundary/UnsupportedBridgeOperationView.js
patch: @@ -0,0 +1,50 @@
+/**
+ * Copyright (c) Facebook, Inc. and its affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @flow
+ */
+
+import * as React from 'react';
+import styles from './shared.css';
+
+type Props = {|
+  callStack: string | null,
+  children: React$Node,
+  componentStack: string | null,
+  errorMessage: string | null,
+|};
+
+export default function UnsupportedBridgeOperationView({
+  callStack,
+  children,
+  componentStack,
+  errorMessage,
+}: Props) {
+  return (
+    <div className={styles.ErrorBoundary}>
+      {children}
+      <div className={styles.ErrorInfo}>
+        <div className={styles.HeaderRow}>
+          <div className={styles.ErrorHeader}>
+            {errorMessage || 'Bridge protocol mismatch'}
+          </div>
+        </div>
+        <div className={styles.InfoBox}>
+          An incompatible version of <code>react-devtools-core</code> has been
+          embedded in a renderer like React Native. To fix this, update the{' '}
+          <code>react-devtools-core</code> package within the React Native
+          application, or downgrade the <code>react-devtools</code> package you
+          use to open the DevTools UI.
+        </div>
+        {!!callStack && (
+          <div className={styles.ErrorStack}>
+            The error was thrown {callStack.trim()}
+          </div>
+        )}
+      </div>
+    </div>
+  );
+}

filename: packages/react-devtools-shared/src/devtools/views/ErrorBoundary/shared.css
patch: @@ -121,4 +121,13 @@
 
 .CloseButtonIcon {
   margin-left: 0.25rem;
+}
+
+.InfoBox {
+  margin-top: 0.5rem;
+  background: var(--color-console-warning-background);
+  border: 1px solid var(--color-console-warning-border);
+  padding: 0.25rem 0.5rem;
+  border-radius: 0.5rem;
+  color: var(--color-console-warning-text);
 }
\ No newline at end of file