diff --git a/packages/react-devtools-inline/README.md b/packages/react-devtools-inline/README.md index b7b3f8373adc9..dff2ce2a06261 100644 --- a/packages/react-devtools-inline/README.md +++ b/packages/react-devtools-inline/README.md @@ -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. -// +// e.g. render(); 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. -; +// You can render DevTools now: +const root = createRoot(container); +root.render(); + +// 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 diff --git a/packages/react-devtools-inline/src/backend.js b/packages/react-devtools-inline/src/backend.js index a6466d82709fc..c0c87871df117 100644 --- a/packages/react-devtools-inline/src/backend.js +++ b/packages/react-devtools-inline/src/backend.js @@ -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; - - 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); - - 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; - - // 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; +import type {BackendBridge} from 'react-devtools-shared/src/bridge'; +import type {Wall} from 'react-devtools-shared/src/types'; + +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; + + 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, 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) { - 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) { + parent.postMessage({event, payload}, '*', transferable); + }, + }; + } + + return (new Bridge(wall): BackendBridge); } export function initialize(contentWindow: window): void { diff --git a/packages/react-devtools-inline/src/constants.js b/packages/react-devtools-inline/src/constants.js deleted file mode 100644 index cfa443cc04806..0000000000000 --- a/packages/react-devtools-inline/src/constants.js +++ /dev/null @@ -1,6 +0,0 @@ -/** @flow */ - -export const MESSAGE_TYPE_GET_SAVED_PREFERENCES = - 'React::DevTools::getSavedPreferences'; -export const MESSAGE_TYPE_SAVED_PREFERENCES = - 'React::DevTools::savedPreferences'; diff --git a/packages/react-devtools-inline/src/frontend.js b/packages/react-devtools-inline/src/frontend.js index 065bb1238715e..0e90c884667a6 100644 --- a/packages/react-devtools-inline/src/frontend.js +++ b/packages/react-devtools-inline/src/frontend.js @@ -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 { - 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, ref) => ( - + )); ForwardRef.displayName = 'DevTools'; diff --git a/packages/react-devtools-shared/src/backend/agent.js b/packages/react-devtools-shared/src/backend/agent.js index fdd46bf9c88f0..f31e3a6c38a7b 100644 --- a/packages/react-devtools-shared/src/backend/agent.js +++ b/packages/react-devtools-shared/src/backend/agent.js @@ -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; diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index 68891e8c18d4b..cdeec7aebfc4b 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -176,10 +176,19 @@ type UpdateConsolePatchSettingsParams = {| browserTheme: BrowserTheme, |}; +type SavedPreferencesParams = {| + appendComponentStack: boolean, + breakOnConsoleErrors: boolean, + componentFilters: Array, + 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. diff --git a/packages/react-devtools-shell/index.html b/packages/react-devtools-shell/app.html similarity index 97% rename from packages/react-devtools-shell/index.html rename to packages/react-devtools-shell/app.html index 410dc5bdc2abd..7bd183891a99b 100644 --- a/packages/react-devtools-shell/index.html +++ b/packages/react-devtools-shell/app.html @@ -64,6 +64,6 @@ - + \ No newline at end of file diff --git a/packages/react-devtools-shell/multi.html b/packages/react-devtools-shell/multi.html new file mode 100644 index 0000000000000..57b25c3760767 --- /dev/null +++ b/packages/react-devtools-shell/multi.html @@ -0,0 +1,57 @@ + + + + + React DevTools + + + + +
+ +
+
+
+ +
+
+ + + + \ No newline at end of file diff --git a/packages/react-devtools-shell/now.json b/packages/react-devtools-shell/now.json deleted file mode 100644 index 7c0805c3b1656..0000000000000 --- a/packages/react-devtools-shell/now.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "react-devtools-experimental", - "alias": ["react-devtools-experimental"], - "files": ["index.html", "dist"] -} diff --git a/packages/react-devtools-shell/package.json b/packages/react-devtools-shell/package.json index 0d995bdfb07fc..776085ec06183 100644 --- a/packages/react-devtools-shell/package.json +++ b/packages/react-devtools-shell/package.json @@ -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", diff --git a/packages/react-devtools-shell/src/devtools.js b/packages/react-devtools-shell/src/app/devtools.js similarity index 98% rename from packages/react-devtools-shell/src/devtools.js rename to packages/react-devtools-shell/src/app/devtools.js index 7b4171851c1be..a4e21c81d772f 100644 --- a/packages/react-devtools-shell/src/devtools.js +++ b/packages/react-devtools-shell/src/app/devtools.js @@ -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); diff --git a/packages/react-devtools-shell/src/multi/devtools.js b/packages/react-devtools-shell/src/multi/devtools.js new file mode 100644 index 0000000000000..6b1313fa7a1d5 --- /dev/null +++ b/packages/react-devtools-shell/src/multi/devtools.js @@ -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(); + }); + + 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'); diff --git a/packages/react-devtools-shell/src/multi/left.js b/packages/react-devtools-shell/src/multi/left.js new file mode 100644 index 0000000000000..aaf380f39a6da --- /dev/null +++ b/packages/react-devtools-shell/src/multi/left.js @@ -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 ; +} + +createRoot(createContainer()).render(); diff --git a/packages/react-devtools-shell/src/multi/right.js b/packages/react-devtools-shell/src/multi/right.js new file mode 100644 index 0000000000000..f4d08632e75f0 --- /dev/null +++ b/packages/react-devtools-shell/src/multi/right.js @@ -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 ( +
    +
  • Rendered {renderCountRef.current} times
  • + {didMount &&
  • Mounted!
  • } +
+ ); +} + +render(, createContainer()); diff --git a/packages/react-devtools-shell/webpack.config.js b/packages/react-devtools-shell/webpack.config.js index fb3df7240fb3e..4f690bb0e3c2d 100644 --- a/packages/react-devtools-shell/webpack.config.js +++ b/packages/react-devtools-shell/webpack.config.js @@ -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' diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index ca66824859ca4..d36aeff05f584 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -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" },