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

DevTools: Add Bridge protocol version backend/frontend (#21331)

Add an explicit Bridge protocol version to the frontend and backend components as well as a check during initialization to ensure that both are compatible. If not, the frontend will display either upgrade or downgrade instructions. Note that only the `react-devtools-core` (React Native) and `react-devtools-inline` (Code Sandbox) packages implement this check. Browser extensions inject their own backend and so the check is unnecessary. (Arguably the `react-devtools-inline` check is also unlikely to be necessary _but_ has been added as an extra guard for use cases such as Replay.io.)

Brian Vaughn committed Apr 27, 2021 at 17:26 UTC 8e2bb3e89c8c3c8b64b3aa94e0bb524775ef4522
11 files changed +314 -3
packages/react-devtools-core/src/standalone.js
+4 -1
@@ -217,7 +217,10 @@ function initialize(socket: WebSocket) {
217 socket.close();
218 });
219
220 - store = new Store(bridge, {supportsNativeInspection: false});
220 + store = new Store(bridge, {
221 + checkBridgeProtocolCompatibility: true,
222 + supportsNativeInspection: false,
223 + });
224
225 log('Connected');
226 reload();
packages/react-devtools-inline/src/frontend.js
+4 -1
@@ -21,7 +21,10 @@ import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
21 import type {Props} from 'react-devtools-shared/src/devtools/views/DevTools';
22
23 export function createStore(bridge: FrontendBridge): Store {
24 - return new Store(bridge, {supportsTraceUpdates: true});
24 + return new Store(bridge, {
25 + checkBridgeProtocolCompatibility: true,
26 + supportsTraceUpdates: true,
27 + });
28 }
29
30 export function createBridge(
packages/react-devtools-shared/src/backend/agent.js
+6
@@ -26,6 +26,7 @@ import {
26 toggleEnabled as setTraceUpdatesEnabled,
27 } from './views/TraceUpdates';
28 import {patch as patchConsole, unpatch as unpatchConsole} from './console';
29 +import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge';
30
31 import type {BackendBridge} from 'react-devtools-shared/src/bridge';
32 import type {
@@ -176,6 +177,7 @@ export default class Agent extends EventEmitter<{|
177 bridge.addListener('clearWarningsForFiberID', this.clearWarningsForFiberID);
178 bridge.addListener('copyElementPath', this.copyElementPath);
179 bridge.addListener('deletePath', this.deletePath);
180 + bridge.addListener('getBridgeProtocol', this.getBridgeProtocol);
181 bridge.addListener('getProfilingData', this.getProfilingData);
182 bridge.addListener('getProfilingStatus', this.getProfilingStatus);
183 bridge.addListener('getOwnersList', this.getOwnersList);
@@ -308,6 +310,10 @@ export default class Agent extends EventEmitter<{|
310 return null;
311 }
312
313 + getBridgeProtocol = () => {
314 + this._bridge.send('bridgeProtocol', currentBridgeProtocol);
315 + };
316 +
317 getProfilingData = ({rendererID}: {|rendererID: RendererID|}) => {
318 const renderer = this._rendererInterfaces[rendererID];
319 if (renderer == null) {
packages/react-devtools-shared/src/bridge.js
+45
@@ -20,6 +20,49 @@ import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-share
20
21 const BATCH_DURATION = 100;
22
23 +// This message specifies the version of the DevTools protocol currently supported by the backend,
24 +// as well as the earliest NPM version (e.g. "4.13.0") that protocol is supported by on the frontend.
25 +// This enables an older frontend to display an upgrade message to users for a newer, unsupported backend.
26 +export type BridgeProtocol = {|
27 + // Version supported by the current frontend/backend.
28 + version: number,
29 +
30 + // NPM version range that also supports this version.
31 + // Note that 'maxNpmVersion' is only set when the version is bumped.
32 + minNpmVersion: string,
33 + maxNpmVersion: string | null,
34 +|};
35 +
36 +// Bump protocol version whenever a backwards breaking change is made
37 +// in the messages sent between BackendBridge and FrontendBridge.
38 +// This mapping is embedded in both frontend and backend builds.
39 +//
40 +// The backend protocol will always be the latest entry in the BRIDGE_PROTOCOL array.
41 +//
42 +// When an older frontend connects to a newer backend,
43 +// the backend can send the minNpmVersion and the frontend can display an NPM upgrade prompt.
44 +//
45 +// When a newer frontend connects with an older protocol version,
46 +// the frontend can use the embedded minNpmVersion/maxNpmVersion values to display a downgrade prompt.
47 +export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
48 + // This version technically never existed,
49 + // but a backwards breaking change was added in 4.11,
50 + // so the safest guess to downgrade the frontend would be to version 4.10.
51 + {
52 + version: 0,
53 + minNpmVersion: '<4.11.0',
54 + maxNpmVersion: '<4.11.0',
55 + },
56 + {
57 + version: 1,
58 + minNpmVersion: '4.13.0',
59 + maxNpmVersion: null,
60 + },
61 +];
62 +
63 +export const currentBridgeProtocol: BridgeProtocol =
64 + BRIDGE_PROTOCOL[BRIDGE_PROTOCOL.length - 1];
65 +
66 type ElementAndRendererID = {|id: number, rendererID: RendererID|};
67
68 type Message = {|
@@ -119,6 +162,7 @@ type UpdateConsolePatchSettingsParams = {|
162 |};
163
164 export type BackendEvents = {|
165 + bridgeProtocol: [BridgeProtocol],
166 extensionBackendInitialized: [],
167 inspectedElement: [InspectedElementPayload],
168 isBackendStorageAPISupported: [boolean],
@@ -150,6 +194,7 @@ type FrontendEvents = {|
194 clearWarningsForFiberID: [ElementAndRendererID],
195 copyElementPath: [CopyElementPathParams],
196 deletePath: [DeletePath],
197 + getBridgeProtocol: [],
198 getOwnersList: [ElementAndRendererID],
199 getProfilingData: [{|rendererID: RendererID|}],
200 getProfilingStatus: [],
packages/react-devtools-shared/src/devtools/store.js
+65 -1
@@ -29,10 +29,17 @@ import {localStorageGetItem, localStorageSetItem} from '../storage';
29 import {__DEBUG__} from '../constants';
30 import {printStore} from './utils';
31 import ProfilerStore from './ProfilerStore';
32 +import {
33 + BRIDGE_PROTOCOL,
34 + currentBridgeProtocol,
35 +} from 'react-devtools-shared/src/bridge';
36
37 import type {Element} from './views/Components/types';
38 import type {ComponentFilter, ElementType} from '../types';
35 -import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
39 +import type {
40 + FrontendBridge,
41 + BridgeProtocol,
42 +} from 'react-devtools-shared/src/bridge';
43
44 const debug = (methodName, ...args) => {
45 if (__DEBUG__) {
@@ -51,6 +58,7 @@ const LOCAL_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =
58 'React::DevTools::recordChangeDescriptions';
59
60 type Config = {|
61 + checkBridgeProtocolCompatibility?: boolean,
62 isProfiling?: boolean,
63 supportsNativeInspection?: boolean,
64 supportsReloadAndProfile?: boolean,
@@ -76,6 +84,7 @@ export default class Store extends EventEmitter<{|
84 supportsNativeStyleEditor: [],
85 supportsProfiling: [],
86 supportsReloadAndProfile: [],
87 + unsupportedBridgeProtocolDetected: [],
88 unsupportedRendererVersionDetected: [],
89 |}> {
90 _bridge: FrontendBridge;
@@ -119,6 +128,10 @@ export default class Store extends EventEmitter<{|
128
129 _nativeStyleEditorValidAttributes: $ReadOnlyArray<string> | null = null;
130
131 + // Older backends don't support an explicit bridge protocol,
132 + // so we should timeout eventually and show a downgrade message.
133 + _onBridgeProtocolTimeoutID: TimeoutID | null = null;
134 +
135 // Map of element (id) to the set of elements (ids) it owns.
136 // This map enables getOwnersListForElement() to avoid traversing the entire tree.
137 _ownersMap: Map<number, Set<number>> = new Map();
@@ -147,6 +160,7 @@ export default class Store extends EventEmitter<{|
160 _supportsReloadAndProfile: boolean = false;
161 _supportsTraceUpdates: boolean = false;
162
163 + _unsupportedBridgeProtocol: BridgeProtocol | null = null;
164 _unsupportedRendererVersionDetected: boolean = false;
165
166 // Total number of visible elements (within all roots).
@@ -217,6 +231,20 @@ export default class Store extends EventEmitter<{|
231 );
232
233 this._profilerStore = new ProfilerStore(bridge, this, isProfiling);
234 +
235 + // Verify that the frontend version is compatible with the connected backend.
236 + // See github.com/facebook/react/issues/21326
237 + if (config != null && config.checkBridgeProtocolCompatibility) {
238 + // Older backends don't support an explicit bridge protocol,
239 + // so we should timeout eventually and show a downgrade message.
240 + this._onBridgeProtocolTimeoutID = setTimeout(
241 + this.onBridgeProtocolTimeout,
242 + 10000,
243 + );
244 +
245 + bridge.addListener('bridgeProtocol', this.onBridgeProtocol);
246 + bridge.send('getBridgeProtocol');
247 + }
248 }
249
250 // This is only used in tests to avoid memory leaks.
@@ -385,6 +413,10 @@ export default class Store extends EventEmitter<{|
413 return this._supportsTraceUpdates;
414 }
415
416 + get unsupportedBridgeProtocol(): BridgeProtocol | null {
417 + return this._unsupportedBridgeProtocol;
418 + }
419 +
420 get unsupportedRendererVersionDetected(): boolean {
421 return this._unsupportedRendererVersionDetected;
422 }
@@ -1168,6 +1200,12 @@ export default class Store extends EventEmitter<{|
1200 'unsupportedRendererVersion',
1201 this.onBridgeUnsupportedRendererVersion,
1202 );
1203 + bridge.removeListener('bridgeProtocol', this.onBridgeProtocol);
1204 +
1205 + if (this._onBridgeProtocolTimeoutID !== null) {
1206 + clearTimeout(this._onBridgeProtocolTimeoutID);
1207 + this._onBridgeProtocolTimeoutID = null;
1208 + }
1209 };
1210
1211 onBackendStorageAPISupported = (isBackendStorageAPISupported: boolean) => {
@@ -1187,4 +1225,30 @@ export default class Store extends EventEmitter<{|
1225
1226 this.emit('unsupportedRendererVersionDetected');
1227 };
1228 +
1229 + onBridgeProtocol = (bridgeProtocol: BridgeProtocol) => {
1230 + if (this._onBridgeProtocolTimeoutID !== null) {
1231 + clearTimeout(this._onBridgeProtocolTimeoutID);
1232 + this._onBridgeProtocolTimeoutID = null;
1233 + }
1234 +
1235 + if (bridgeProtocol.version !== currentBridgeProtocol.version) {
1236 + this._unsupportedBridgeProtocol = bridgeProtocol;
1237 + } else {
1238 + // If we should happen to get a response after timing out...
1239 + this._unsupportedBridgeProtocol = null;
1240 + }
1241 +
1242 + this.emit('unsupportedBridgeProtocolDetected');
1243 + };
1244 +
1245 + onBridgeProtocolTimeout = () => {
1246 + this._onBridgeProtocolTimeoutID = null;
1247 +
1248 + // If we timed out, that indicates the backend predates the bridge protocol,
1249 + // so we can set a fake version (0) to trigger the downgrade message.
1250 + this._unsupportedBridgeProtocol = BRIDGE_PROTOCOL[0];
1251 +
1252 + this.emit('unsupportedBridgeProtocolDetected');
1253 + };
1254 }
packages/react-devtools-shared/src/devtools/views/DevTools.js
+2
@@ -25,6 +25,7 @@ import ViewElementSourceContext from './Components/ViewElementSourceContext';
25 import {ProfilerContextController} from './Profiler/ProfilerContext';
26 import {ModalDialogContextController} from './ModalDialog';
27 import ReactLogo from './ReactLogo';
28 +import UnsupportedBridgeProtocolDialog from './UnsupportedBridgeProtocolDialog';
29 import UnsupportedVersionDialog from './UnsupportedVersionDialog';
30 import WarnIfLegacyBackendDetected from './WarnIfLegacyBackendDetected';
31 import {useLocalStorage} from './hooks';
@@ -226,6 +227,7 @@ export default function DevTools({
227 </TreeContextController>
228 </ViewElementSourceContext.Provider>
229 </SettingsContextController>
230 + <UnsupportedBridgeProtocolDialog />
231 {warnIfLegacyBackendDetected && <WarnIfLegacyBackendDetected />}
232 {warnIfUnsupportedVersionDetected && <UnsupportedVersionDialog />}
233 </ModalDialogContextController>
packages/react-devtools-shared/src/devtools/views/Settings/SettingsContext.js
+7
@@ -383,6 +383,13 @@ export function updateThemeVariables(
383 updateStyleHelper(theme, 'color-expand-collapse-toggle', documentElements);
384 updateStyleHelper(theme, 'color-link', documentElements);
385 updateStyleHelper(theme, 'color-modal-background', documentElements);
386 + updateStyleHelper(
387 + theme,
388 + 'color-bridge-version-npm-background',
389 + documentElements,
390 + );
391 + updateStyleHelper(theme, 'color-bridge-version-npm-text', documentElements);
392 + updateStyleHelper(theme, 'color-bridge-version-number', documentElements);
393 updateStyleHelper(
394 theme,
395 'color-primitive-hook-badge-background',
packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.css new
+37
@@ -0,0 +1,37 @@
1 +.Column {
2 + display: flex;
3 + flex-direction: column;
4 +}
5 +
6 +.Title {
7 + font-size: var(--font-size-sans-large);
8 + margin-bottom: 0.5rem;
9 +}
10 +
11 +.ReleaseNotesLink {
12 + color: var(--color-button-active);
13 +}
14 +
15 +.Version {
16 + color: var(--color-bridge-version-number);
17 + font-weight: bold;
18 +}
19 +
20 +.NpmCommand {
21 + display: flex;
22 + justify-content: space-between;
23 + padding: 0.25rem 0.25rem 0.25rem 0.5rem;
24 + background-color: var(--color-bridge-version-npm-background);
25 + color: var(--color-bridge-version-npm-text);
26 + margin: 0;
27 + font-family: var(--font-family-monospace);
28 + font-size: var(--font-size-monospace-large);
29 +}
30 +
31 +.Paragraph {
32 + margin: 0.5rem 0;
33 +}
34 +
35 +.Link {
36 + color: var(--color-link);
37 +}
\ No newline at end of file
packages/react-devtools-shared/src/devtools/views/UnsupportedBridgeProtocolDialog.js new
+132
@@ -0,0 +1,132 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import * as React from 'react';
11 +import {Fragment, useContext, useEffect} from 'react';
12 +import {ModalDialogContext} from './ModalDialog';
13 +import {StoreContext} from './context';
14 +import {currentBridgeProtocol} from 'react-devtools-shared/src/bridge';
15 +import Button from './Button';
16 +import ButtonIcon from './ButtonIcon';
17 +import {copy} from 'clipboard-js';
18 +import styles from './UnsupportedBridgeProtocolDialog.css';
19 +
20 +import type {BridgeProtocol} from 'react-devtools-shared/src/bridge';
21 +
22 +const DEVTOOLS_VERSION = process.env.DEVTOOLS_VERSION;
23 +const INSTRUCTIONS_FB_URL = 'https://fburl.com/devtools-bridge-protocol';
24 +
25 +export default function UnsupportedBridgeProtocolDialog(_: {||}) {
26 + const {dispatch, isVisible} = useContext(ModalDialogContext);
27 + const store = useContext(StoreContext);
28 +
29 + useEffect(() => {
30 + const updateDialog = () => {
31 + if (!isVisible) {
32 + if (store.unsupportedBridgeProtocol !== null) {
33 + dispatch({
34 + canBeDismissed: false,
35 + type: 'SHOW',
36 + content: (
37 + <DialogContent
38 + unsupportedBridgeProtocol={store.unsupportedBridgeProtocol}
39 + />
40 + ),
41 + });
42 + }
43 + } else {
44 + if (store.unsupportedBridgeProtocol === null) {
45 + dispatch({type: 'HIDE'});
46 + }
47 + }
48 + };
49 +
50 + updateDialog();
51 +
52 + store.addListener('unsupportedBridgeProtocolDetected', updateDialog);
53 + return () => {
54 + store.removeListener('unsupportedBridgeProtocolDetected', updateDialog);
55 + };
56 + }, [isVisible, store]);
57 +
58 + return null;
59 +}
60 +
61 +function DialogContent({
62 + unsupportedBridgeProtocol,
63 +}: {|
64 + unsupportedBridgeProtocol: BridgeProtocol,
65 +|}) {
66 + const {version, minNpmVersion, maxNpmVersion} = unsupportedBridgeProtocol;
67 +
68 + let instructions;
69 + if (maxNpmVersion === null) {
70 + const upgradeInstructions = `npm i -g react-devtools@^${minNpmVersion}`;
71 + instructions = (
72 + <>
73 + <p className={styles.Paragraph}>
74 + To fix this, upgrade the DevTools NPM package:
75 + </p>
76 + <pre className={styles.NpmCommand}>
77 + {upgradeInstructions}
78 + <Button
79 + onClick={() => copy(upgradeInstructions)}
80 + title="Copy upgrade command to clipboard">
81 + <ButtonIcon type="copy" />
82 + </Button>
83 + </pre>
84 + </>
85 + );
86 + } else {
87 + const downgradeInstructions = `npm i -g react-devtools@${maxNpmVersion}`;
88 + instructions = (
89 + <>
90 + <p className={styles.Paragraph}>
91 + To fix this, downgrade the DevTools NPM package:
92 + </p>
93 + <pre className={styles.NpmCommand}>
94 + {downgradeInstructions}
95 + <Button
96 + onClick={() => copy(downgradeInstructions)}
97 + title="Copy downgrade command to clipboard">
98 + <ButtonIcon type="copy" />
99 + </Button>
100 + </pre>
101 + </>
102 + );
103 + }
104 +
105 + return (
106 + <Fragment>
107 + <div className={styles.Column}>
108 + <div className={styles.Title}>Unsupported DevTools backend version</div>
109 + <p className={styles.Paragraph}>
110 + You are running <code>react-devtools</code> version{' '}
111 + <span className={styles.Version}>{DEVTOOLS_VERSION}</span>.
112 + </p>
113 + <p className={styles.Paragraph}>
114 + This requires bridge protocol{' '}
115 + <span className={styles.Version}>
116 + version {currentBridgeProtocol.version}
117 + </span>
118 + . However the current backend version uses bridge protocol{' '}
119 + <span className={styles.Version}>version {version}</span>.
120 + </p>
121 + {instructions}
122 + <p className={styles.Paragraph}>
123 + Or{' '}
124 + <a className={styles.Link} href={INSTRUCTIONS_FB_URL} target="_blank">
125 + click here
126 + </a>{' '}
127 + for more information.
128 + </p>
129 + </div>
130 + </Fragment>
131 + );
132 +}
packages/react-devtools-shared/src/devtools/views/root.css
+6
@@ -69,6 +69,9 @@
69 --light-color-expand-collapse-toggle: #777d88;
70 --light-color-link: #0000ff;
71 --light-color-modal-background: rgba(255, 255, 255, 0.75);
72 + --light-color-bridge-version-npm-background: #eff0f1;
73 + --light-color-bridge-version-npm-text: #000000;
74 + --light-color-bridge-version-number: #0088fa;
75 --light-color-primitive-hook-badge-background: #e5e5e5;
76 --light-color-primitive-hook-badge-text: #5f6673;
77 --light-color-record-active: #fc3a4b;
@@ -158,6 +161,9 @@
161 --dark-color-expand-collapse-toggle: #8f949d;
162 --dark-color-link: #61dafb;
163 --dark-color-modal-background: rgba(0, 0, 0, 0.75);
164 + --dark-color-bridge-version-npm-background: rgba(0, 0, 0, 0.25);
165 + --dark-color-bridge-version-npm-text: #ffffff;
166 + --dark-color-bridge-version-number: yellow;
167 --dark-color-primitive-hook-badge-background: rgba(0, 0, 0, 0.25);
168 --dark-color-primitive-hook-badge-text: rgba(255, 255, 255, 0.7);
169 --dark-color-record-active: #fc3a4b;
packages/react-devtools/app.js
+6
@@ -32,6 +32,12 @@ app.on('ready', function() {
32 },
33 });
34
35 + // https://stackoverflow.com/questions/32402327/
36 + mainWindow.webContents.on('new-window', function(event, url) {
37 + event.preventDefault();
38 + require('electron').shell.openExternal(url);
39 + });
40 +
41 // and load the index.html of the app.
42 mainWindow.loadURL('file://' + __dirname + '/app.html'); // eslint-disable-line no-path-concat
43 mainWindow.webContents.executeJavaScript(