diff --git a/packages/react-devtools-core/src/standalone.js b/packages/react-devtools-core/src/standalone.js index e31b43d724d1b..9f18ef5b1f5dd 100644 --- a/packages/react-devtools-core/src/standalone.js +++ b/packages/react-devtools-core/src/standalone.js @@ -217,7 +217,10 @@ function initialize(socket: WebSocket) { socket.close(); }); - store = new Store(bridge, {supportsNativeInspection: false}); + store = new Store(bridge, { + checkBridgeProtocolCompatibility: true, + supportsNativeInspection: false, + }); log('Connected'); reload(); diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index a4c7f61f223f0..af4b0330d75f8 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -21,7 +21,10 @@ import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type {Props} from 'react-devtools-shared/src/devtools/views/DevTools'; export function createStore(bridge: FrontendBridge): Store { - return new Store(bridge, {supportsTraceUpdates: true}); + return new Store(bridge, { + checkBridgeProtocolCompatibility: true, + supportsTraceUpdates: true, + }); } export function createBridge( diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index 86c35ae1b49bd..e665235271cad 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -26,6 +26,7 @@ import { toggleEnabled as setTraceUpdatesEnabled, } from './views/TraceUpdates'; import {patch as patchConsole, unpatch as unpatchConsole} from './console'; +import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge'; import type {BackendBridge} from 'react-devtools-shared/src/bridge'; import type { @@ -176,6 +177,7 @@ export default class Agent extends EventEmitter<{| bridge.addListener('clearWarningsForFiberID', this.clearWarningsForFiberID); bridge.addListener('copyElementPath', this.copyElementPath); bridge.addListener('deletePath', this.deletePath); + bridge.addListener('getBridgeProtocol', this.getBridgeProtocol); bridge.addListener('getProfilingData', this.getProfilingData); bridge.addListener('getProfilingStatus', this.getProfilingStatus); bridge.addListener('getOwnersList', this.getOwnersList); @@ -308,6 +310,10 @@ export default class Agent extends EventEmitter<{| return null; } + getBridgeProtocol = () => { + this._bridge.send('bridgeProtocol', currentBridgeProtocol); + }; + getProfilingData = ({rendererID}: {|rendererID: RendererID|}) => { const renderer = this._rendererInterfaces[rendererID]; if (renderer == null) { diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index d3b669bd42871..9acd7607e4698 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -20,6 +20,49 @@ import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-share const BATCH_DURATION = 100; +// This message specifies the version of the DevTools protocol currently supported by the backend, +// as well as the earliest NPM version (e.g. "4.13.0") that protocol is supported by on the frontend. +// This enables an older frontend to display an upgrade message to users for a newer, unsupported backend. +export type BridgeProtocol = {| + // Version supported by the current frontend/backend. + version: number, + + // NPM version range that also supports this version. + // Note that 'maxNpmVersion' is only set when the version is bumped. + minNpmVersion: string, + maxNpmVersion: string | null, +|}; + +// Bump protocol version whenever a backwards breaking change is made +// in the messages sent between BackendBridge and FrontendBridge. +// This mapping is embedded in both frontend and backend builds. +// +// The backend protocol will always be the latest entry in the BRIDGE_PROTOCOL array. +// +// When an older frontend connects to a newer backend, +// the backend can send the minNpmVersion and the frontend can display an NPM upgrade prompt. +// +// When a newer frontend connects with an older protocol version, +// the frontend can use the embedded minNpmVersion/maxNpmVersion values to display a downgrade prompt. +export const BRIDGE_PROTOCOL: Array = [ + // This version technically never existed, + // but a backwards breaking change was added in 4.11, + // so the safest guess to downgrade the frontend would be to version 4.10. + { + version: 0, + minNpmVersion: '<4.11.0', + maxNpmVersion: '<4.11.0', + }, + { + version: 1, + minNpmVersion: '4.13.0', + maxNpmVersion: null, + }, +]; + +export const currentBridgeProtocol: BridgeProtocol = + BRIDGE_PROTOCOL[BRIDGE_PROTOCOL.length - 1]; + type ElementAndRendererID = {|id: number, rendererID: RendererID|}; type Message = {| @@ -119,6 +162,7 @@ type UpdateConsolePatchSettingsParams = {| |}; export type BackendEvents = {| + bridgeProtocol: [BridgeProtocol], extensionBackendInitialized: [], inspectedElement: [InspectedElementPayload], isBackendStorageAPISupported: [boolean], @@ -150,6 +194,7 @@ type FrontendEvents = {| clearWarningsForFiberID: [ElementAndRendererID], copyElementPath: [CopyElementPathParams], deletePath: [DeletePath], + getBridgeProtocol: [], getOwnersList: [ElementAndRendererID], getProfilingData: [{|rendererID: RendererID|}], getProfilingStatus: [], diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index 5fa4bff344da9..3458975e92bc5 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -29,10 +29,17 @@ import {localStorageGetItem, localStorageSetItem} from '../storage'; import {__DEBUG__} from '../constants'; import {printStore} from './utils'; import ProfilerStore from './ProfilerStore'; +import { + BRIDGE_PROTOCOL, + currentBridgeProtocol, +} from 'react-devtools-shared/src/bridge'; import type {Element} from './views/Components/types'; import type {ComponentFilter, ElementType} from '../types'; -import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; +import type { + FrontendBridge, + BridgeProtocol, +} from 'react-devtools-shared/src/bridge'; const debug = (methodName, ...args) => { if (__DEBUG__) { @@ -51,6 +58,7 @@ const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY = 'React::DevTools::recordChangeDescriptions'; type Config = {| + checkBridgeProtocolCompatibility?: boolean, isProfiling?: boolean, supportsNativeInspection?: boolean, supportsReloadAndProfile?: boolean, @@ -76,6 +84,8 @@ export default class Store extends EventEmitter<{| supportsNativeStyleEditor: [], supportsProfiling: [], supportsReloadAndProfile: [], + unsupportedBridgeProtocolDetected: [], + unsupportedRendererVersionDetected: [], unsupportedRendererVersionDetected: [], |}> { _bridge: FrontendBridge; @@ -119,6 +129,10 @@ export default class Store extends EventEmitter<{| _nativeStyleEditorValidAttributes: $ReadOnlyArray | null = null; + // Older backends don't support an explicit bridge protocol, + // so we should timeout eventually and show a downgrade message. + _onBridgeProtocolTimeoutID: TimeoutID | null = null; + // Map of element (id) to the set of elements (ids) it owns. // This map enables getOwnersListForElement() to avoid traversing the entire tree. _ownersMap: Map> = new Map(); @@ -147,6 +161,7 @@ export default class Store extends EventEmitter<{| _supportsReloadAndProfile: boolean = false; _supportsTraceUpdates: boolean = false; + _unsupportedBridgeProtocol: BridgeProtocol | null = null; _unsupportedRendererVersionDetected: boolean = false; // Total number of visible elements (within all roots). @@ -217,6 +232,20 @@ export default class Store extends EventEmitter<{| ); this._profilerStore = new ProfilerStore(bridge, this, isProfiling); + + // Verify that the frontend version is compatible with the connected backend. + // See github.com/facebook/react/issues/21326 + if (config != null && config.checkBridgeProtocolCompatibility) { + // Older backends don't support an explicit bridge protocol, + // so we should timeout eventually and show a downgrade message. + this._onBridgeProtocolTimeoutID = setTimeout( + this.onBridgeProtocolTimeout, + 10000, + ); + + bridge.addListener('bridgeProtocol', this.onBridgeProtocol); + bridge.send('getBridgeProtocol'); + } } // This is only used in tests to avoid memory leaks. @@ -385,6 +414,10 @@ export default class Store extends EventEmitter<{| return this._supportsTraceUpdates; } + get unsupportedBridgeProtocol(): BridgeProtocol | null { + return this._unsupportedBridgeProtocol; + } + get unsupportedRendererVersionDetected(): boolean { return this._unsupportedRendererVersionDetected; } @@ -1168,6 +1201,12 @@ export default class Store extends EventEmitter<{| 'unsupportedRendererVersion', this.onBridgeUnsupportedRendererVersion, ); + bridge.removeListener('bridgeProtocol', this.onBridgeProtocol); + + if (this._onBridgeProtocolTimeoutID !== null) { + clearTimeout(this._onBridgeProtocolTimeoutID); + this._onBridgeProtocolTimeoutID = null; + } }; onBackendStorageAPISupported = (isBackendStorageAPISupported: boolean) => { @@ -1187,4 +1226,30 @@ export default class Store extends EventEmitter<{| this.emit('unsupportedRendererVersionDetected'); }; + + onBridgeProtocol = (bridgeProtocol: BridgeProtocol) => { + if (this._onBridgeProtocolTimeoutID !== null) { + clearTimeout(this._onBridgeProtocolTimeoutID); + this._onBridgeProtocolTimeoutID = null; + } + + if (bridgeProtocol.version !== currentBridgeProtocol.version) { + this._unsupportedBridgeProtocol = bridgeProtocol; + } else { + // If we should happen to get a response after timing out... + this._unsupportedBridgeProtocol = null; + } + + this.emit('unsupportedBridgeProtocolDetected'); + }; + + onBridgeProtocolTimeout = () => { + this._onBridgeProtocolTimeoutID = null; + + // If we timed out, that indicates the backend predates the bridge protocol, + // so we can set a fake version (0) to trigger the downgrade message. + this._unsupportedBridgeProtocol = BRIDGE_PROTOCOL[0]; + + this.emit('unsupportedBridgeProtocolDetected'); + }; } diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index cb5c36cf680bc..a7ac1c4397990 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -25,6 +25,7 @@ import ViewElementSourceContext from './Components/ViewElementSourceContext'; import {ProfilerContextController} from './Profiler/ProfilerContext'; import {ModalDialogContextController} from './ModalDialog'; import ReactLogo from './ReactLogo'; +import UnsupportedBridgeProtocolDialog from './UnsupportedBridgeProtocolDialog'; import UnsupportedVersionDialog from './UnsupportedVersionDialog'; import WarnIfLegacyBackendDetected from './WarnIfLegacyBackendDetected'; import {useLocalStorage} from './hooks'; @@ -226,6 +227,7 @@ export default function DevTools({ + {warnIfLegacyBackendDetected && } {warnIfUnsupportedVersionDetected && } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js index b6cd278df59dc..93d7bfd9d500d 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js @@ -383,6 +383,13 @@ export function updateThemeVariables( updateStyleHelper(theme, 'color-expand-collapse-toggle', documentElements); updateStyleHelper(theme, 'color-link', documentElements); updateStyleHelper(theme, 'color-modal-background', documentElements); + updateStyleHelper( + theme, + 'color-bridge-version-npm-background', + documentElements, + ); + updateStyleHelper(theme, 'color-bridge-version-npm-text', documentElements); + updateStyleHelper(theme, 'color-bridge-version-number', documentElements); updateStyleHelper( theme, 'color-primitive-hook-badge-background', diff --git a/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.css b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.css new file mode 100644 index 0000000000000..ae4d73fd78142 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.css @@ -0,0 +1,37 @@ +.Column { + display: flex; + flex-direction: column; +} + +.Title { + font-size: var(--font-size-sans-large); + margin-bottom: 0.5rem; +} + +.ReleaseNotesLink { + color: var(--color-button-active); +} + +.Version { + color: var(--color-bridge-version-number); + font-weight: bold; +} + +.NpmCommand { + display: flex; + justify-content: space-between; + padding: 0.25rem 0.25rem 0.25rem 0.5rem; + background-color: var(--color-bridge-version-npm-background); + color: var(--color-bridge-version-npm-text); + margin: 0; + font-family: var(--font-family-monospace); + font-size: var(--font-size-monospace-large); +} + +.Paragraph { + margin: 0.5rem 0; +} + +.Link { + color: var(--color-link); +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js new file mode 100644 index 0000000000000..b5c1e7e8baeb9 --- /dev/null +++ b/packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js @@ -0,0 +1,132 @@ +/** + * 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 {Fragment, useContext, useEffect} from 'react'; +import {ModalDialogContext} from './ModalDialog'; +import {StoreContext} from './context'; +import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge'; +import Button from './Button'; +import ButtonIcon from './ButtonIcon'; +import {copy} from 'clipboard-js'; +import styles from './UnsupportedBridgeProtocolDialog.css'; + +import type {BridgeProtocol} from 'react-devtools-shared/src/bridge'; + +const DEVTOOLS_VERSION = process.env.DEVTOOLS_VERSION; +const INSTRUCTIONS_FB_URL = 'https://fburl.com/devtools-bridge-protocol'; + +export default function UnsupportedBridgeProtocolDialog(_: {||}) { + const {dispatch, isVisible} = useContext(ModalDialogContext); + const store = useContext(StoreContext); + + useEffect(() => { + const updateDialog = () => { + if (!isVisible) { + if (store.unsupportedBridgeProtocol !== null) { + dispatch({ + canBeDismissed: false, + type: 'SHOW', + content: ( + + ), + }); + } + } else { + if (store.unsupportedBridgeProtocol === null) { + dispatch({type: 'HIDE'}); + } + } + }; + + updateDialog(); + + store.addListener('unsupportedBridgeProtocolDetected', updateDialog); + return () => { + store.removeListener('unsupportedBridgeProtocolDetected', updateDialog); + }; + }, [isVisible, store]); + + return null; +} + +function DialogContent({ + unsupportedBridgeProtocol, +}: {| + unsupportedBridgeProtocol: BridgeProtocol, +|}) { + const {version, minNpmVersion, maxNpmVersion} = unsupportedBridgeProtocol; + + let instructions; + if (maxNpmVersion === null) { + const upgradeInstructions = `npm i -g react-devtools@^${minNpmVersion}`; + instructions = ( + <> +

+ To fix this, upgrade the DevTools NPM package: +

+
+          {upgradeInstructions}
+          
+        
+ + ); + } else { + const downgradeInstructions = `npm i -g react-devtools@${maxNpmVersion}`; + instructions = ( + <> +

+ To fix this, downgrade the DevTools NPM package: +

+
+          {downgradeInstructions}
+          
+        
+ + ); + } + + return ( + +
+
Unsupported DevTools backend version
+

+ You are running react-devtools version{' '} + {DEVTOOLS_VERSION}. +

+

+ This requires bridge protocol{' '} + + version {currentBridgeProtocol.version} + + . However the current backend version uses bridge protocol{' '} + version {version}. +

+ {instructions} +

+ Or{' '} + + click here + {' '} + for more information. +

+
+
+ ); +} diff --git a/packages/react-devtools-shared/src/devtools/views/root.css b/packages/react-devtools-shared/src/devtools/views/root.css index 6d03a9519a23c..8b3db6ee0772b 100644 --- a/packages/react-devtools-shared/src/devtools/views/root.css +++ b/packages/react-devtools-shared/src/devtools/views/root.css @@ -69,6 +69,9 @@ --light-color-expand-collapse-toggle: #777d88; --light-color-link: #0000ff; --light-color-modal-background: rgba(255, 255, 255, 0.75); + --light-color-bridge-version-npm-background: #eff0f1; + --light-color-bridge-version-npm-text: #000000; + --light-color-bridge-version-number: #0088fa; --light-color-primitive-hook-badge-background: #e5e5e5; --light-color-primitive-hook-badge-text: #5f6673; --light-color-record-active: #fc3a4b; @@ -158,6 +161,9 @@ --dark-color-expand-collapse-toggle: #8f949d; --dark-color-link: #61dafb; --dark-color-modal-background: rgba(0, 0, 0, 0.75); + --dark-color-bridge-version-npm-background: rgba(0, 0, 0, 0.25); + --dark-color-bridge-version-npm-text: #ffffff; + --dark-color-bridge-version-number: yellow; --dark-color-primitive-hook-badge-background: rgba(0, 0, 0, 0.25); --dark-color-primitive-hook-badge-text: rgba(255, 255, 255, 0.7); --dark-color-record-active: #fc3a4b; diff --git a/packages/react-devtools/app.js b/packages/react-devtools/app.js index 38304b4ffadda..ef3c43470fb08 100644 --- a/packages/react-devtools/app.js +++ b/packages/react-devtools/app.js @@ -32,6 +32,12 @@ app.on('ready', function() { }, }); + // https://stackoverflow.com/questions/32402327/ + mainWindow.webContents.on('new-window', function(event, url) { + event.preventDefault(); + require('electron').shell.openExternal(url); + }); + // and load the index.html of the app. mainWindow.loadURL('file://' + __dirname + '/app.html'); // eslint-disable-line no-path-concat mainWindow.webContents.executeJavaScript(