DevTools: Support mulitple DevTools instances per page (#22949)

This is being done so that we can embed DevTools within the new React (beta) docs.

The primary changes here are to `react-devtools-inline/backend`:
* Add a new `createBridge` API
* Add an option to the `activate` method to support passing in the custom bridge object.

The `react-devtools-inline` README has been updated to include these new methods.

To verify these changes, this commit also updates the test shell to add a new entry-point for multiple DevTools.

This commit also replaces two direct calls to `window.postMessage()` with `bridge.send()` (and adds the related Flow types).
This commit is contained in:
Brian Vaughn
2021-12-14 12:16:16 -05:00
committed by GitHub
parent 5757919256
commit 911f92a44d
16 changed files with 336 additions and 143 deletions

View File

@@ -56,7 +56,7 @@ const iframe = document.getElementById(frameID);
const contentWindow = iframe.contentWindow;
// This returns a React component that can be rendered into your app.
// <DevTools {...props} />
// e.g. render(<DevTools {...props} />);
const DevTools = initialize(contentWindow);
```
@@ -177,32 +177,47 @@ Below is an example of an advanced integration with a website like [Replay.io](h
```js
import {
createBridge,
activate as activateBackend,
createBridge as createBackendBridge,
initialize as initializeBackend,
} from 'react-devtools-inline/backend';
import {
createBridge as createFrontendBridge,
createStore,
initialize as createDevTools,
} from "react-devtools-inline/frontend";
} from 'react-devtools-inline/frontend';
// Custom Wall implementation enables serializing data
// using an API other than window.postMessage()
// DevTools uses "message" events and window.postMessage() by default,
// but we can override this behavior by creating a custom "Wall" object.
// For example...
const wall = {
emit() {},
_listeners: [],
listen(listener) {
wall._listener = listener;
wall._listeners.push(listener);
},
async send(event, payload) {
const response = await fetch(...).json();
wall._listener(response);
send(event, payload) {
wall._listeners.forEach(listener => listener({event, payload}));
},
};
// Create a Bridge and Store that use the custom Wall.
// Initialize the DevTools backend before importing React (or any other packages that might import React).
initializeBackend(contentWindow);
// Prepare DevTools for rendering.
// To use the custom Wall we've created, we need to also create our own "Bridge" and "Store" objects.
const bridge = createBridge(target, wall);
const store = createStore(bridge);
const DevTools = createDevTools(target, { bridge, store });
// Render DevTools with it.
<DevTools {...otherProps} />;
// You can render DevTools now:
const root = createRoot(container);
root.render(<DevTools {...otherProps} />);
// Lastly, let the DevTools backend know that the frontend is ready.
// To use the custom Wall we've created, we need to also pass in the "Bridge".
activateBackend(contentWindow, {
bridge: createBackendBridge(contentWindow, wall),
});
```
## Local development

View File

@@ -5,83 +5,57 @@ import Bridge from 'react-devtools-shared/src/bridge';
import {initBackend} from 'react-devtools-shared/src/backend';
import {installHook} from 'react-devtools-shared/src/hook';
import setupNativeStyleEditor from 'react-devtools-shared/src/backend/NativeStyleEditor/setupNativeStyleEditor';
import {
MESSAGE_TYPE_GET_SAVED_PREFERENCES,
MESSAGE_TYPE_SAVED_PREFERENCES,
} from './constants';
function startActivation(contentWindow: window) {
const {parent} = contentWindow;
import type {BackendBridge} from 'react-devtools-shared/src/bridge';
import type {Wall} from 'react-devtools-shared/src/types';
const onMessage = ({data}) => {
switch (data.type) {
case MESSAGE_TYPE_SAVED_PREFERENCES:
// This is the only message we're listening for,
// so it's safe to cleanup after we've received it.
contentWindow.removeEventListener('message', onMessage);
function startActivation(contentWindow: window, bridge: BackendBridge) {
const onSavedPreferences = data => {
// This is the only message we're listening for,
// so it's safe to cleanup after we've received it.
bridge.removeListener('savedPreferences', onSavedPreferences);
const {
appendComponentStack,
breakOnConsoleErrors,
componentFilters,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
} = data;
const {
appendComponentStack,
breakOnConsoleErrors,
componentFilters,
showInlineWarningsAndErrors,
hideConsoleLogsInStrictMode,
} = data;
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
contentWindow.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
contentWindow.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
contentWindow.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
contentWindow.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = hideConsoleLogsInStrictMode;
contentWindow.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
contentWindow.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
contentWindow.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
contentWindow.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
contentWindow.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = hideConsoleLogsInStrictMode;
// TRICKY
// The backend entry point may be required in the context of an iframe or the parent window.
// If it's required within the parent window, store the saved values on it as well,
// since the injected renderer interface will read from window.
// Technically we don't need to store them on the contentWindow in this case,
// but it doesn't really hurt anything to store them there too.
if (contentWindow !== window) {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = hideConsoleLogsInStrictMode;
}
finishActivation(contentWindow);
break;
default:
break;
// TRICKY
// The backend entry point may be required in the context of an iframe or the parent window.
// If it's required within the parent window, store the saved values on it as well,
// since the injected renderer interface will read from window.
// Technically we don't need to store them on the contentWindow in this case,
// but it doesn't really hurt anything to store them there too.
if (contentWindow !== window) {
window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = appendComponentStack;
window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = breakOnConsoleErrors;
window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = componentFilters;
window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = showInlineWarningsAndErrors;
window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = hideConsoleLogsInStrictMode;
}
finishActivation(contentWindow, bridge);
};
contentWindow.addEventListener('message', onMessage);
bridge.addListener('savedPreferences', onSavedPreferences);
// The backend may be unable to read saved preferences directly,
// because they are stored in localStorage within the context of the extension (on the frontend).
// Instead it relies on the extension to pass preferences through.
// Because we might be in a sandboxed iframe, we have to ask for them by way of postMessage().
parent.postMessage({type: MESSAGE_TYPE_GET_SAVED_PREFERENCES}, '*');
bridge.send('getSavedPreferences');
}
function finishActivation(contentWindow: window) {
const {parent} = contentWindow;
const bridge = new Bridge({
listen(fn) {
const onMessage = event => {
fn(event.data);
};
contentWindow.addEventListener('message', onMessage);
return () => {
contentWindow.removeEventListener('message', onMessage);
};
},
send(event: string, payload: any, transferable?: Array<any>) {
parent.postMessage({event, payload}, '*', transferable);
},
});
function finishActivation(contentWindow: window, bridge: BackendBridge) {
const agent = new Agent(bridge);
const hook = contentWindow.__REACT_DEVTOOLS_GLOBAL_HOOK__;
@@ -100,8 +74,45 @@ function finishActivation(contentWindow: window) {
}
}
export function activate(contentWindow: window): void {
startActivation(contentWindow);
export function activate(
contentWindow: window,
{
bridge,
}: {|
bridge?: BackendBridge,
|} = {},
): void {
if (bridge == null) {
bridge = createBridge(contentWindow);
}
startActivation(contentWindow, bridge);
}
export function createBridge(
contentWindow: window,
wall?: Wall,
): BackendBridge {
const {parent} = contentWindow;
if (wall == null) {
wall = {
listen(fn) {
const onMessage = ({data}) => {
fn(data);
};
contentWindow.addEventListener('message', onMessage);
return () => {
contentWindow.removeEventListener('message', onMessage);
};
},
send(event: string, payload: any, transferable?: Array<any>) {
parent.postMessage({event, payload}, '*', transferable);
},
};
}
return (new Bridge(wall): BackendBridge);
}
export function initialize(contentWindow: window): void {

View File

@@ -1,6 +0,0 @@
/** @flow */
export const MESSAGE_TYPE_GET_SAVED_PREFERENCES =
'React::DevTools::getSavedPreferences';
export const MESSAGE_TYPE_SAVED_PREFERENCES =
'React::DevTools::savedPreferences';

View File

@@ -12,10 +12,6 @@ import {
getShowInlineWarningsAndErrors,
getHideConsoleLogsInStrictMode,
} from 'react-devtools-shared/src/utils';
import {
MESSAGE_TYPE_GET_SAVED_PREFERENCES,
MESSAGE_TYPE_SAVED_PREFERENCES,
} from './constants';
import type {Wall} from 'react-devtools-shared/src/types';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
@@ -68,49 +64,40 @@ export function initialize(
store?: Store,
|} = {},
): React.AbstractComponent<Props, mixed> {
const onGetSavedPreferencesMessage = ({data, source}) => {
if (source === 'react-devtools-content-script') {
// Ignore messages from the DevTools browser extension.
}
switch (data.type) {
case MESSAGE_TYPE_GET_SAVED_PREFERENCES:
// This is the only message we're listening for,
// so it's safe to cleanup after we've received it.
window.removeEventListener('message', onGetSavedPreferencesMessage);
// The renderer interface can't read saved preferences directly,
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass them through.
contentWindow.postMessage(
{
type: MESSAGE_TYPE_SAVED_PREFERENCES,
appendComponentStack: getAppendComponentStack(),
breakOnConsoleErrors: getBreakOnConsoleErrors(),
componentFilters: getSavedComponentFilters(),
showInlineWarningsAndErrors: getShowInlineWarningsAndErrors(),
hideConsoleLogsInStrictMode: getHideConsoleLogsInStrictMode(),
},
'*',
);
break;
default:
break;
}
};
window.addEventListener('message', onGetSavedPreferencesMessage);
if (bridge == null) {
bridge = createBridge(contentWindow);
}
// Type refinement.
const frontendBridge = ((bridge: any): FrontendBridge);
if (store == null) {
store = createStore(bridge);
store = createStore(frontendBridge);
}
const onGetSavedPreferences = () => {
// This is the only message we're listening for,
// so it's safe to cleanup after we've received it.
frontendBridge.removeListener('getSavedPreferences', onGetSavedPreferences);
const data = {
appendComponentStack: getAppendComponentStack(),
breakOnConsoleErrors: getBreakOnConsoleErrors(),
componentFilters: getSavedComponentFilters(),
showInlineWarningsAndErrors: getShowInlineWarningsAndErrors(),
hideConsoleLogsInStrictMode: getHideConsoleLogsInStrictMode(),
};
// The renderer interface can't read saved preferences directly,
// because they are stored in localStorage within the context of the extension.
// Instead it relies on the extension to pass them through.
frontendBridge.send('savedPreferences', data);
};
frontendBridge.addListener('getSavedPreferences', onGetSavedPreferences);
const ForwardRef = forwardRef<Props, mixed>((props, ref) => (
<DevTools ref={ref} bridge={bridge} store={store} {...props} />
<DevTools ref={ref} bridge={frontendBridge} store={store} {...props} />
));
ForwardRef.displayName = 'DevTools';

View File

@@ -225,6 +225,9 @@ export default class Agent extends EventEmitter<{|
bridge.send('profilingStatus', true);
}
// Send the Bridge protocol after initialization in case the frontend has already requested it.
this._bridge.send('bridgeProtocol', currentBridgeProtocol);
// Notify the frontend if the backend supports the Storage API (e.g. localStorage).
// If not, features like reload-and-profile will not work correctly and must be disabled.
let isBackendStorageAPISupported = false;

View File

@@ -176,10 +176,19 @@ type UpdateConsolePatchSettingsParams = {|
browserTheme: BrowserTheme,
|};
type SavedPreferencesParams = {|
appendComponentStack: boolean,
breakOnConsoleErrors: boolean,
componentFilters: Array<ComponentFilter>,
showInlineWarningsAndErrors: boolean,
hideConsoleLogsInStrictMode: boolean,
|};
export type BackendEvents = {|
bridgeProtocol: [BridgeProtocol],
extensionBackendInitialized: [],
fastRefreshScheduled: [],
getSavedPreferences: [],
inspectedElement: [InspectedElementPayload],
isBackendStorageAPISupported: [boolean],
isSynchronousXHRSupported: [boolean],
@@ -223,6 +232,7 @@ type FrontendEvents = {|
profilingData: [ProfilingDataBackend],
reloadAndProfile: [boolean],
renamePath: [RenamePath],
savedPreferences: [SavedPreferencesParams],
selectFiber: [number],
setTraceUpdatesEnabled: [boolean],
shutdown: [],
@@ -277,7 +287,9 @@ class Bridge<
this._wallUnlisten =
wall.listen((message: Message) => {
(this: any).emit(message.event, message.payload);
if (message && message.event) {
(this: any).emit(message.event, message.payload);
}
}) || null;
// Temporarily support older standalone front-ends sending commands to newer embedded backends.

View File

@@ -64,6 +64,6 @@
<!-- This script installs the hook, injects the backend, and renders the DevTools UI -->
<!-- In DEV mode, this file is served by the Webpack dev server -->
<!-- For production builds, it's built by Webpack and uploaded from the local file system -->
<script src="dist/devtools.js"></script>
<script src="dist/app-devtools.js"></script>
</body>
</html>

View File

@@ -0,0 +1,57 @@
<!doctype html>
<html>
<head>
<meta charset="utf8">
<title>React DevTools</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
}
body {
display: flex;
flex-direction: row;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
margin: 0;
padding: 0;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol;
font-size: 12px;
line-height: 1.5;
}
.column {
display: flex;
flex-direction: column;
flex: 1 1 50%;
}
.column:first-of-type {
border-right: 1px solid #3d424a;
}
.iframe {
height: 50%;
flex: 0 0 50%;
border: none;
}
.devtools {
height: 50%;
flex: 0 0 50%;
}
</style>
</head>
<body>
<div class="column left-column">
<iframe id="iframe-left" class="iframe"></iframe>
<div id="devtools-left" class="devtools"></div>
</div>
<div class="column">
<iframe id="iframe-right" class="iframe"></iframe>
<div id="devtools-right" class="devtools"></div>
</div>
<script src="dist/multi-devtools.js"></script>
</body>
</html>

View File

@@ -1,5 +0,0 @@
{
"name": "react-devtools-experimental",
"alias": ["react-devtools-experimental"],
"files": ["index.html", "dist"]
}

View File

@@ -3,9 +3,9 @@
"name": "react-devtools-shell",
"version": "0.0.0",
"scripts": {
"build": "cross-env NODE_ENV=development cross-env TARGET=remote webpack --config webpack.config.js",
"deploy": "yarn run build && now deploy && now alias react-devtools-experimental",
"start": "cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open"
"start": "yarn start:app",
"start:app": "cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open-page app.html",
"start:multi": "cross-env NODE_ENV=development cross-env TARGET=local webpack-dev-server --open-page multi.html"
},
"dependencies": {
"immutable": "^4.0.0-rc.12",

View File

@@ -60,7 +60,7 @@ function hookNamesModuleLoaderFunction() {
return import('react-devtools-inline/hookNames');
}
inject('dist/app.js', () => {
inject('dist/app-index.js', () => {
initDevTools({
connect(cb) {
const root = createRoot(container);

View File

@@ -0,0 +1,71 @@
import * as React from 'react';
import {createRoot} from 'react-dom';
import {
activate as activateBackend,
createBridge as createBackendBridge,
initialize as initializeBackend,
} from 'react-devtools-inline/backend';
import {
createBridge as createFrontendBridge,
createStore,
initialize as createDevTools,
} from 'react-devtools-inline/frontend';
import {__DEBUG__} from 'react-devtools-shared/src/constants';
function inject(contentDocument, sourcePath, callback) {
const script = contentDocument.createElement('script');
script.onload = callback;
script.src = sourcePath;
((contentDocument.body: any): HTMLBodyElement).appendChild(script);
}
function init(appIframe, devtoolsContainer, appSource) {
const {contentDocument, contentWindow} = appIframe;
// Wire each DevTools instance directly to its app.
// By default, DevTools dispatches "message" events on the window,
// but this means that only one instance of DevTools can live on a page.
const wall = {
_listeners: [],
listen(listener) {
if (__DEBUG__) {
console.log('[Shell] Wall.listen()');
}
wall._listeners.push(listener);
},
send(event, payload) {
if (__DEBUG__) {
console.log('[Shell] Wall.send()', {event, payload});
}
wall._listeners.forEach(listener => listener({event, payload}));
},
};
const backendBridge = createBackendBridge(contentWindow, wall);
initializeBackend(contentWindow);
const frontendBridge = createFrontendBridge(contentWindow, wall);
const store = createStore(frontendBridge);
const DevTools = createDevTools(contentWindow, {
bridge: frontendBridge,
store,
});
inject(contentDocument, appSource, () => {
createRoot(devtoolsContainer).render(<DevTools />);
});
activateBackend(contentWindow, {bridge: backendBridge});
}
const appIframeLeft = document.getElementById('iframe-left');
const appIframeRight = document.getElementById('iframe-right');
const devtoolsContainerLeft = document.getElementById('devtools-left');
const devtoolsContainerRight = document.getElementById('devtools-right');
init(appIframeLeft, devtoolsContainerLeft, 'dist/multi-left.js');
init(appIframeRight, devtoolsContainerRight, 'dist/multi-right.js');

View File

@@ -0,0 +1,19 @@
import * as React from 'react';
import {useState} from 'react';
import {createRoot} from 'react-dom';
function createContainer() {
const container = document.createElement('div');
((document.body: any): HTMLBodyElement).appendChild(container);
return container;
}
function StatefulCounter() {
const [count, setCount] = useState(0);
const handleClick = () => setCount(count + 1);
return <button onClick={handleClick}>Count {count}</button>;
}
createRoot(createContainer()).render(<StatefulCounter />);

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import {useLayoutEffect, useRef, useState} from 'react';
import {render} from 'react-dom';
function createContainer() {
const container = document.createElement('div');
((document.body: any): HTMLBodyElement).appendChild(container);
return container;
}
function EffectWithState() {
const [didMount, setDidMount] = useState(0);
const renderCountRef = useRef(0);
renderCountRef.current++;
useLayoutEffect(() => {
if (!didMount) {
setDidMount(true);
}
}, [didMount]);
return (
<ul>
<li>Rendered {renderCountRef.current} times</li>
{didMount && <li>Mounted!</li>}
</ul>
);
}
render(<EffectWithState />, createContainer());

View File

@@ -42,8 +42,11 @@ const config = {
mode: __DEV__ ? 'development' : 'production',
devtool: __DEV__ ? 'cheap-source-map' : 'source-map',
entry: {
app: './src/app/index.js',
devtools: './src/devtools.js',
'app-index': './src/app/index.js',
'app-devtools': './src/app/devtools.js',
'multi-left': './src/multi/left.js',
'multi-devtools': './src/multi/devtools.js',
'multi-right': './src/multi/right.js',
},
node: {
// source-maps package has a dependency on 'fs'

View File

@@ -11,14 +11,7 @@
"bin": {
"react-devtools": "./bin.js"
},
"files": [
"bin.js",
"build-info.json",
"app.html",
"app.js",
"index.js",
"icons"
],
"files": [],
"scripts": {
"start": "node bin.js"
},