From 64fc6602339ad6d969fc756ca0eca0da8647af77 Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Tue, 29 Aug 2023 12:09:26 +0100 Subject: [PATCH] refactor: refactored devtools browser extension scripts to improve port management and service worker lifetime (#27215) Fixes https://github.com/facebook/react/issues/27119, https://github.com/facebook/react/issues/27185. Fixed: - React DevTools now works as expected when user performs in-tab navigation, previously it was just stuck. https://github.com/facebook/react/assets/28902667/b11c5f84-7155-47a5-8b5a-7e90baca5347 - When user closes browser DevTools panel, we now do some cleanup to disconnect ports and emit shutdown event for bridge. This should fix the issue with registering duplicated fibers with the same id in Store. Changed: - We reconnect proxy port once in 25 seconds, in order to [keep service worker alive](https://developer.chrome.com/docs/extensions/whatsnew/#m110-sw-idle). - Instead of unregistering dynamically injected content scripts, wen now get list of already registered scripts and filter them out from scripts that we want to inject again, see dynamicallyInjectContentScripts.js. - Split `main.js` and `background.js` into multiple files. Tested on Chromium and Firefox browsers. --- .../firefox/manifest.json | 6 +- .../src/background.js | 245 -------- .../dynamicallyInjectContentScripts.js | 53 ++ .../src/background/index.js | 257 ++++++++ .../src/background/injectProxy.js | 12 + .../background/setExtensionIconAndPopup.js | 26 + .../src/background/tabsManager.js | 45 ++ .../{ => contentScripts}/backendManager.js | 2 +- .../src/contentScripts/prepareInjection.js | 2 +- .../src/contentScripts/proxy.js | 72 ++- .../react-devtools-extensions/src/main.js | 562 ------------------ .../src/main/cloneStyleTags.js | 21 + .../src/main/elementSelection.js | 42 ++ .../src/main/getProfilingFlags.js | 23 + .../src/main/index.js | 525 ++++++++++++++++ .../src/main/injectBackendManager.js | 30 + .../src/main/registerEventsLogger.js | 18 + .../src/main/requestAnimationFramePolyfill.js | 17 + .../src/main/syncSavedPreferences.js | 38 ++ .../webpack.config.js | 6 +- .../react-devtools-shared/src/constants.js | 3 + .../src/devtools/views/DevTools.js | 1 + 22 files changed, 1159 insertions(+), 847 deletions(-) delete mode 100644 packages/react-devtools-extensions/src/background.js create mode 100644 packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js create mode 100644 packages/react-devtools-extensions/src/background/index.js create mode 100644 packages/react-devtools-extensions/src/background/injectProxy.js create mode 100644 packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js create mode 100644 packages/react-devtools-extensions/src/background/tabsManager.js rename packages/react-devtools-extensions/src/{ => contentScripts}/backendManager.js (98%) delete mode 100644 packages/react-devtools-extensions/src/main.js create mode 100644 packages/react-devtools-extensions/src/main/cloneStyleTags.js create mode 100644 packages/react-devtools-extensions/src/main/elementSelection.js create mode 100644 packages/react-devtools-extensions/src/main/getProfilingFlags.js create mode 100644 packages/react-devtools-extensions/src/main/index.js create mode 100644 packages/react-devtools-extensions/src/main/injectBackendManager.js create mode 100644 packages/react-devtools-extensions/src/main/registerEventsLogger.js create mode 100644 packages/react-devtools-extensions/src/main/requestAnimationFramePolyfill.js create mode 100644 packages/react-devtools-extensions/src/main/syncSavedPreferences.js diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index 50289ec468d6f..558c8f021f7d3 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -6,7 +6,7 @@ "applications": { "gecko": { "id": "@react-devtools", - "strict_min_version": "55.0" + "strict_min_version": "102.0" } }, "icons": { @@ -41,7 +41,9 @@ "file:///*", "http://*/*", "https://*/*", - "clipboardWrite" + "clipboardWrite", + "scripting", + "devtools" ], "content_scripts": [ { diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js deleted file mode 100644 index 302f55ae8e531..0000000000000 --- a/packages/react-devtools-extensions/src/background.js +++ /dev/null @@ -1,245 +0,0 @@ -/* global chrome */ - -'use strict'; - -import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from './utils'; - -const ports = {}; - -async function dynamicallyInjectContentScripts() { - const contentScriptsToInject = [ - { - id: 'hook', - matches: [''], - js: ['build/installHook.js'], - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - { - id: 'renderer', - matches: [''], - js: ['build/renderer.js'], - runAt: 'document_start', - world: chrome.scripting.ExecutionWorld.MAIN, - }, - ]; - - try { - // For some reason dynamically injected scripts might be already registered - // Registering them again will fail, which will result into - // __REACT_DEVTOOLS_GLOBAL_HOOK__ hook not being injected - - // Not specifying ids, because Chrome throws an error - // if id of non-injected script is provided - await chrome.scripting.unregisterContentScripts(); - - // equivalent logic for Firefox is in prepareInjection.js - // Manifest V3 method of injecting content script - // TODO(hoxyq): migrate Firefox to V3 manifests - // Note: the "world" option in registerContentScripts is only available in Chrome v102+ - // It's critical since it allows us to directly run scripts on the "main" world on the page - // "document_start" allows it to run before the page's scripts - // so the hook can be detected by react reconciler - await chrome.scripting.registerContentScripts(contentScriptsToInject); - } catch (error) { - console.error(error); - } -} - -if (!IS_FIREFOX) { - dynamicallyInjectContentScripts(); -} - -chrome.runtime.onConnect.addListener(function (port) { - let tab = null; - let name = null; - if (isNumeric(port.name)) { - tab = port.name; - name = 'devtools'; - installProxy(+port.name); - } else { - tab = port.sender.tab.id; - name = 'content-script'; - } - - if (!ports[tab]) { - ports[tab] = { - devtools: null, - 'content-script': null, - }; - } - ports[tab][name] = port; - - if (ports[tab].devtools && ports[tab]['content-script']) { - doublePipe(ports[tab].devtools, ports[tab]['content-script'], tab); - } -}); - -function isNumeric(str: string): boolean { - return +str + '' === str; -} - -function installProxy(tabId: number) { - if (IS_FIREFOX) { - chrome.tabs.executeScript(tabId, {file: '/build/proxy.js'}, function () {}); - } else { - chrome.scripting.executeScript({ - target: {tabId: tabId}, - files: ['/build/proxy.js'], - }); - } -} - -function doublePipe(one, two, tabId) { - one.onMessage.addListener(lOne); - function lOne(message) { - try { - two.postMessage(message); - } catch (e) { - if (__DEV__) { - console.log(`Broken pipe ${tabId}: `, e); - } - shutdown(); - } - } - two.onMessage.addListener(lTwo); - function lTwo(message) { - try { - one.postMessage(message); - } catch (e) { - if (__DEV__) { - console.log(`Broken pipe ${tabId}: `, e); - } - shutdown(); - } - } - function shutdown() { - one.onMessage.removeListener(lOne); - two.onMessage.removeListener(lTwo); - one.disconnect(); - two.disconnect(); - // clean up so that we can rebuild the double pipe if the page is reloaded - ports[tabId] = null; - } - one.onDisconnect.addListener(shutdown); - two.onDisconnect.addListener(shutdown); -} - -function setIconAndPopup(reactBuildType, tabId) { - const action = IS_FIREFOX ? chrome.browserAction : chrome.action; - action.setIcon({ - tabId: tabId, - path: { - '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`), - '32': chrome.runtime.getURL(`icons/32-${reactBuildType}.png`), - '48': chrome.runtime.getURL(`icons/48-${reactBuildType}.png`), - '128': chrome.runtime.getURL(`icons/128-${reactBuildType}.png`), - }, - }); - action.setPopup({ - tabId: tabId, - popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`), - }); -} - -function isRestrictedBrowserPage(url) { - return !url || new URL(url).protocol === 'chrome:'; -} - -function checkAndHandleRestrictedPageIfSo(tab) { - if (tab && isRestrictedBrowserPage(tab.url)) { - setIconAndPopup('restricted', tab.id); - } -} - -// update popup page of any existing open tabs, if they are restricted browser pages. -// we can't update for any other types (prod,dev,outdated etc) -// as the content script needs to be injected at document_start itself for those kinds of detection -// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed -if (!IS_FIREFOX) { - chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); - chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) => - checkAndHandleRestrictedPageIfSo(tab), - ); -} - -// Listen to URL changes on the active tab and update the DevTools icon. -chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { - if (IS_FIREFOX) { - // We don't properly detect protected URLs in Firefox at the moment. - // However we can reset the DevTools icon to its loading state when the URL changes. - // It will be updated to the correct icon by the onMessage callback below. - if (tab.active && changeInfo.status === 'loading') { - setIconAndPopup('disabled', tabId); - } - } else { - // Don't reset the icon to the loading state for Chrome or Edge. - // The onUpdated callback fires more frequently for these browsers, - // often after onMessage has been called. - checkAndHandleRestrictedPageIfSo(tab); - } -}); - -chrome.runtime.onMessage.addListener((request, sender) => { - const tab = sender.tab; - // sender.tab.id from content script points to the tab that injected the content script - if (tab) { - const id = tab.id; - // This is sent from the hook content script. - // It tells us a renderer has attached. - if (request.hasDetectedReact) { - setIconAndPopup(request.reactBuildType, id); - } else { - const devtools = ports[id]?.devtools; - switch (request.payload?.type) { - case 'fetch-file-with-cache-complete': - case 'fetch-file-with-cache-error': - // Forward the result of fetch-in-page requests back to the extension. - devtools?.postMessage(request); - break; - // This is sent from the backend manager running on a page - case 'react-devtools-required-backends': - const backendsToDownload = []; - request.payload.versions.forEach(version => { - if (EXTENSION_CONTAINED_VERSIONS.includes(version)) { - if (!IS_FIREFOX) { - // equivalent logic for Firefox is in prepareInjection.js - chrome.scripting.executeScript({ - target: {tabId: id}, - files: [`/build/react_devtools_backend_${version}.js`], - world: chrome.scripting.ExecutionWorld.MAIN, - }); - } - } else { - backendsToDownload.push(version); - } - }); - // Request the necessary backends in the extension DevTools UI - // TODO: handle this message in main.js to build the UI - devtools?.postMessage({ - payload: { - type: 'react-devtools-additional-backends', - versions: backendsToDownload, - }, - }); - break; - } - } - } - // sender.tab.id from devtools page may not exist, or point to the undocked devtools window - // so we use the payload to get the tab id - if (request.payload?.tabId) { - const tabId = request.payload?.tabId; - // This is sent from the devtools page when it is ready for injecting the backend - if (request.payload.type === 'react-devtools-inject-backend-manager') { - if (!IS_FIREFOX) { - // equivalent logic for Firefox is in prepareInjection.js - chrome.scripting.executeScript({ - target: {tabId}, - files: ['/build/backendManager.js'], - world: chrome.scripting.ExecutionWorld.MAIN, - }); - } - } - } -}); diff --git a/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js new file mode 100644 index 0000000000000..4f22b70bcfe9f --- /dev/null +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -0,0 +1,53 @@ +/* global chrome */ + +import {IS_FIREFOX} from '../utils'; + +async function dynamicallyInjectContentScripts() { + const contentScriptsToInject = [ + { + id: '@react-devtools/hook', + js: ['build/installHook.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_start', + world: chrome.scripting.ExecutionWorld.MAIN, + }, + { + id: '@react-devtools/renderer', + js: ['build/renderer.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_start', + world: chrome.scripting.ExecutionWorld.MAIN, + }, + ]; + + try { + const alreadyRegisteredContentScripts = + await chrome.scripting.getRegisteredContentScripts(); + + const scriptsToInjectNow = contentScriptsToInject.filter( + scriptToInject => + !alreadyRegisteredContentScripts.some( + registeredScript => registeredScript.id === scriptToInject.id, + ), + ); + + if (scriptsToInjectNow.length) { + // equivalent logic for Firefox is in prepareInjection.js + // Manifest V3 method of injecting content script + // TODO(hoxyq): migrate Firefox to V3 manifests + // Note: the "world" option in registerContentScripts is only available in Chrome v102+ + // It's critical since it allows us to directly run scripts on the "main" world on the page + // "document_start" allows it to run before the page's scripts + // so the hook can be detected by react reconciler + await chrome.scripting.registerContentScripts(scriptsToInjectNow); + } + } catch (error) { + console.error(error); + } +} + +if (!IS_FIREFOX) { + dynamicallyInjectContentScripts(); +} diff --git a/packages/react-devtools-extensions/src/background/index.js b/packages/react-devtools-extensions/src/background/index.js new file mode 100644 index 0000000000000..d826957aabf6f --- /dev/null +++ b/packages/react-devtools-extensions/src/background/index.js @@ -0,0 +1,257 @@ +/* global chrome */ + +'use strict'; + +import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils'; + +import './dynamicallyInjectContentScripts'; +import './tabsManager'; +import setExtensionIconAndPopup from './setExtensionIconAndPopup'; +import injectProxy from './injectProxy'; + +/* + { + [tabId]: { + extension: ExtensionPort, + proxy: ProxyPort, + disconnectPipe: Function, + }, + ... + } + */ +const ports = {}; + +function registerTab(tabId) { + if (!ports[tabId]) { + ports[tabId] = { + extension: null, + proxy: null, + disconnectPipe: null, + }; + } +} + +function registerExtensionPort(port, tabId) { + ports[tabId].extension = port; + + port.onDisconnect.addListener(() => { + // This should delete disconnectPipe from ports dictionary + ports[tabId].disconnectPipe?.(); + + delete ports[tabId].extension; + + const proxyPort = ports[tabId].proxy; + if (proxyPort) { + // Do not disconnect proxy port, we will inject this content script again + // If extension port has disconnected, it probably means that user did in-tab navigation + clearReconnectionTimeout(proxyPort); + + proxyPort.postMessage({ + source: 'react-devtools-service-worker', + stop: true, + }); + } + }); +} + +function registerProxyPort(port, tabId) { + ports[tabId].proxy = port; + + // In case proxy port was disconnected from the other end, from content script + // This can happen if content script was detached, when user does in-tab navigation + // Or if when we notify proxy port to stop reconnecting, when extension port dies + // This listener should never be called when we call port.shutdown() from this (background/index.js) script + port.onDisconnect.addListener(() => { + ports[tabId].disconnectPipe?.(); + + delete ports[tabId].proxy; + }); + + port._reconnectionTimeoutId = setTimeout( + reconnectProxyPort, + 25_000, + port, + tabId, + ); +} + +function clearReconnectionTimeout(port) { + if (port._reconnectionTimeoutId) { + clearTimeout(port._reconnectionTimeoutId); + delete port._reconnectionTimeoutId; + } +} + +function reconnectProxyPort(port, tabId) { + // IMPORTANT: port.onDisconnect will only be emitted if disconnect() was called from the other end + // We need to do it manually here if we disconnect proxy port from service worker + ports[tabId].disconnectPipe?.(); + + // It should be reconnected automatically by proxy content script, look at proxy.js + port.disconnect(); +} + +function isNumeric(str: string): boolean { + return +str + '' === str; +} + +chrome.runtime.onConnect.addListener(async port => { + if (port.name === 'proxy') { + // Proxy content script is executed in tab, so it should have it specified. + const tabId = port.sender.tab.id; + + registerTab(tabId); + registerProxyPort(port, tabId); + + connectExtensionAndProxyPorts( + ports[tabId].extension, + ports[tabId].proxy, + tabId, + ); + + return; + } + + if (isNumeric(port.name)) { + // Extension port doesn't have tab id specified, because its sender is the extension. + const tabId = +port.name; + + registerTab(tabId); + registerExtensionPort(port, tabId); + + injectProxy(tabId); + + return; + } + + // I am not sure if we should throw here + console.warn(`Unknown port ${port.name} connected`); +}); + +function connectExtensionAndProxyPorts(extensionPort, proxyPort, tabId) { + if (!extensionPort) { + throw new Error( + `Attempted to connect ports, when extension port is not present`, + ); + } + + if (!proxyPort) { + throw new Error( + `Attempted to connect ports, when proxy port is not present`, + ); + } + + if (ports[tabId].disconnectPipe) { + throw new Error( + `Attempted to connect already connected ports for tab with id ${tabId}`, + ); + } + + function extensionPortMessageListener(message) { + try { + proxyPort.postMessage(message); + } catch (e) { + if (__DEV__) { + console.log(`Broken pipe ${tabId}: `, e); + } + + disconnectListener(); + } + } + + function proxyPortMessageListener(message) { + try { + extensionPort.postMessage(message); + } catch (e) { + if (__DEV__) { + console.log(`Broken pipe ${tabId}: `, e); + } + + disconnectListener(); + } + } + + function disconnectListener() { + extensionPort.onMessage.removeListener(extensionPortMessageListener); + proxyPort.onMessage.removeListener(proxyPortMessageListener); + + // We handle disconnect() calls manually, based on each specific case + // No need to disconnect other port here + + delete ports[tabId].disconnectPipe; + } + + ports[tabId].disconnectPipe = disconnectListener; + + extensionPort.onMessage.addListener(extensionPortMessageListener); + proxyPort.onMessage.addListener(proxyPortMessageListener); + + extensionPort.onDisconnect.addListener(disconnectListener); + proxyPort.onDisconnect.addListener(disconnectListener); +} + +chrome.runtime.onMessage.addListener((message, sender) => { + const tab = sender.tab; + // sender.tab.id from content script points to the tab that injected the content script + if (tab) { + const id = tab.id; + // This is sent from the hook content script. + // It tells us a renderer has attached. + if (message.hasDetectedReact) { + setExtensionIconAndPopup(message.reactBuildType, id); + } else { + const extensionPort = ports[id]?.extension; + + switch (message.payload?.type) { + case 'fetch-file-with-cache-complete': + case 'fetch-file-with-cache-error': + // Forward the result of fetch-in-page requests back to the extension. + extensionPort?.postMessage(message); + break; + // This is sent from the backend manager running on a page + case 'react-devtools-required-backends': + const backendsToDownload = []; + message.payload.versions.forEach(version => { + if (EXTENSION_CONTAINED_VERSIONS.includes(version)) { + if (!IS_FIREFOX) { + // equivalent logic for Firefox is in prepareInjection.js + chrome.scripting.executeScript({ + target: {tabId: id}, + files: [`/build/react_devtools_backend_${version}.js`], + world: chrome.scripting.ExecutionWorld.MAIN, + }); + } + } else { + backendsToDownload.push(version); + } + }); + + // Request the necessary backends in the extension DevTools UI + // TODO: handle this message in index.js to build the UI + extensionPort?.postMessage({ + payload: { + type: 'react-devtools-additional-backends', + versions: backendsToDownload, + }, + }); + break; + } + } + } + + // This is sent from the devtools page when it is ready for injecting the backend + if (message?.payload?.type === 'react-devtools-inject-backend-manager') { + // sender.tab.id from devtools page may not exist, or point to the undocked devtools window + // so we use the payload to get the tab id + const tabId = message.payload.tabId; + + if (tabId && !IS_FIREFOX) { + // equivalent logic for Firefox is in prepareInjection.js + chrome.scripting.executeScript({ + target: {tabId}, + files: ['/build/backendManager.js'], + world: chrome.scripting.ExecutionWorld.MAIN, + }); + } + } +}); diff --git a/packages/react-devtools-extensions/src/background/injectProxy.js b/packages/react-devtools-extensions/src/background/injectProxy.js new file mode 100644 index 0000000000000..1f38ce416c556 --- /dev/null +++ b/packages/react-devtools-extensions/src/background/injectProxy.js @@ -0,0 +1,12 @@ +/* global chrome */ + +// We keep this logic in background, because Firefox doesn't allow using these APIs +// from extension page script +function injectProxy(tabId: number) { + chrome.scripting.executeScript({ + target: {tabId}, + files: ['/build/proxy.js'], + }); +} + +export default injectProxy; diff --git a/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js new file mode 100644 index 0000000000000..11caa35e2011b --- /dev/null +++ b/packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js @@ -0,0 +1,26 @@ +/* global chrome */ + +'use strict'; + +import {IS_FIREFOX} from 'react-devtools-extensions/src/utils'; + +function setExtensionIconAndPopup(reactBuildType, tabId) { + const action = IS_FIREFOX ? chrome.browserAction : chrome.action; + + action.setIcon({ + tabId, + path: { + '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`), + '32': chrome.runtime.getURL(`icons/32-${reactBuildType}.png`), + '48': chrome.runtime.getURL(`icons/48-${reactBuildType}.png`), + '128': chrome.runtime.getURL(`icons/128-${reactBuildType}.png`), + }, + }); + + action.setPopup({ + tabId, + popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`), + }); +} + +export default setExtensionIconAndPopup; diff --git a/packages/react-devtools-extensions/src/background/tabsManager.js b/packages/react-devtools-extensions/src/background/tabsManager.js new file mode 100644 index 0000000000000..15c78f090ac1f --- /dev/null +++ b/packages/react-devtools-extensions/src/background/tabsManager.js @@ -0,0 +1,45 @@ +/* global chrome */ + +'use strict'; + +import {IS_FIREFOX} from 'react-devtools-extensions/src/utils'; + +import setExtensionIconAndPopup from './setExtensionIconAndPopup'; + +function isRestrictedBrowserPage(url) { + return !url || new URL(url).protocol === 'chrome:'; +} + +function checkAndHandleRestrictedPageIfSo(tab) { + if (tab && isRestrictedBrowserPage(tab.url)) { + setExtensionIconAndPopup('restricted', tab.id); + } +} + +// update popup page of any existing open tabs, if they are restricted browser pages. +// we can't update for any other types (prod,dev,outdated etc) +// as the content script needs to be injected at document_start itself for those kinds of detection +// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed +if (!IS_FIREFOX) { + chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo)); + chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) => + checkAndHandleRestrictedPageIfSo(tab), + ); +} + +// Listen to URL changes on the active tab and update the DevTools icon. +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => { + if (IS_FIREFOX) { + // We don't properly detect protected URLs in Firefox at the moment. + // However, we can reset the DevTools icon to its loading state when the URL changes. + // It will be updated to the correct icon by the onMessage callback below. + if (tab.active && changeInfo.status === 'loading') { + setExtensionIconAndPopup('disabled', tabId); + } + } else { + // Don't reset the icon to the loading state for Chrome or Edge. + // The onUpdated callback fires more frequently for these browsers, + // often after onMessage has been called. + checkAndHandleRestrictedPageIfSo(tab); + } +}); diff --git a/packages/react-devtools-extensions/src/backendManager.js b/packages/react-devtools-extensions/src/contentScripts/backendManager.js similarity index 98% rename from packages/react-devtools-extensions/src/backendManager.js rename to packages/react-devtools-extensions/src/contentScripts/backendManager.js index a77ca2f9601c6..e9d2082828599 100644 --- a/packages/react-devtools-extensions/src/backendManager.js +++ b/packages/react-devtools-extensions/src/contentScripts/backendManager.js @@ -12,7 +12,7 @@ import type { ReactRenderer, } from 'react-devtools-shared/src/backend/types'; import {hasAssignedBackend} from 'react-devtools-shared/src/backend/utils'; -import {COMPACT_VERSION_NAME} from './utils'; +import {COMPACT_VERSION_NAME} from 'react-devtools-extensions/src/utils'; let welcomeHasInitialized = false; diff --git a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js index a62cce3903f46..44bdb5d6df792 100644 --- a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js +++ b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js @@ -5,7 +5,7 @@ import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/ import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils'; -// We run scripts on the page via the service worker (backgroud.js) for +// We run scripts on the page via the service worker (background/index.js) for // Manifest V3 extensions (Chrome & Edge). // We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld diff --git a/packages/react-devtools-extensions/src/contentScripts/proxy.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js index 3bf4bf5cab445..76c915dca4189 100644 --- a/packages/react-devtools-extensions/src/contentScripts/proxy.js +++ b/packages/react-devtools-extensions/src/contentScripts/proxy.js @@ -2,9 +2,23 @@ 'use strict'; -let backendDisconnected: boolean = false; +let port = null; let backendInitialized: boolean = false; +connectPort(); +sayHelloToBackendManager(); + +// The backend waits to install the global hook until notified by the content script. +// In the event of a page reload, the content script might be loaded before the backend manager is injected. +// Because of this we need to poll the backend manager until it has been initialized. +const intervalID = setInterval(() => { + if (backendInitialized) { + clearInterval(intervalID); + } else { + sayHelloToBackendManager(); + } +}, 500); + function sayHelloToBackendManager() { window.postMessage( { @@ -16,6 +30,18 @@ function sayHelloToBackendManager() { } function handleMessageFromDevtools(message) { + if (message.source === 'react-devtools-service-worker' && message.stop) { + window.removeEventListener('message', handleMessageFromPage); + + // Calling disconnect here should not emit onDisconnect event inside this script + // This port will not attempt to reconnect again + // It will connect only once this content script will be injected again + port?.disconnect(); + port = null; + + return; + } + window.postMessage( { source: 'react-devtools-content-script', @@ -33,6 +59,7 @@ function handleMessageFromPage(event) { port.postMessage(event.data.payload); } + // This is a message from the backend manager if (event.data.source === 'react-devtools-backend-manager') { chrome.runtime.sendMessage({ @@ -43,42 +70,21 @@ function handleMessageFromPage(event) { } function handleDisconnect() { - backendDisconnected = true; - window.removeEventListener('message', handleMessageFromPage); + port = null; - window.postMessage( - { - source: 'react-devtools-content-script', - payload: { - type: 'event', - event: 'shutdown', - }, - }, - '*', - ); + connectPort(); } -// proxy from main page to devtools (via the background page) -const port = chrome.runtime.connect({ - name: 'content-script', -}); -port.onMessage.addListener(handleMessageFromDevtools); -port.onDisconnect.addListener(handleDisconnect); +// Creates port from application page to the React DevTools' service worker +// Which then connects it with extension port +function connectPort() { + port = chrome.runtime.connect({ + name: 'proxy', + }); -window.addEventListener('message', handleMessageFromPage); + window.addEventListener('message', handleMessageFromPage); -sayHelloToBackendManager(); - -// The backend waits to install the global hook until notified by the content script. -// In the event of a page reload, the content script might be loaded before the backend manager is injected. -// Because of this we need to poll the backend manager until it has been initialized. -if (!backendInitialized) { - const intervalID = setInterval(() => { - if (backendInitialized || backendDisconnected) { - clearInterval(intervalID); - } else { - sayHelloToBackendManager(); - } - }, 500); + port.onMessage.addListener(handleMessageFromDevtools); + port.onDisconnect.addListener(handleDisconnect); } diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js deleted file mode 100644 index 56907fd6ffb8a..0000000000000 --- a/packages/react-devtools-extensions/src/main.js +++ /dev/null @@ -1,562 +0,0 @@ -/* global chrome */ - -import {createElement} from 'react'; -import {flushSync} from 'react-dom'; -import {createRoot} from 'react-dom/client'; -import Bridge from 'react-devtools-shared/src/bridge'; -import Store from 'react-devtools-shared/src/devtools/store'; -import {IS_CHROME, IS_EDGE, getBrowserTheme} from './utils'; -import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger'; -import { - getAppendComponentStack, - getBreakOnConsoleErrors, - getSavedComponentFilters, - getShowInlineWarningsAndErrors, - getHideConsoleLogsInStrictMode, -} from 'react-devtools-shared/src/utils'; -import { - localStorageGetItem, - localStorageRemoveItem, - localStorageSetItem, -} from 'react-devtools-shared/src/storage'; -import DevTools from 'react-devtools-shared/src/devtools/views/DevTools'; -import { - __DEBUG__, - LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, -} from 'react-devtools-shared/src/constants'; -import {logEvent} from 'react-devtools-shared/src/Logger'; - -const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = - 'React::DevTools::supportsProfiling'; - -// rAF never fires on devtools_page (because it's in the background) -// https://bugs.chromium.org/p/chromium/issues/detail?id=1241986#c31 -// Since we render React elements here, we need to polyfill it with setTimeout -// The polyfill is based on https://gist.github.com/jalbam/5fe05443270fa6d8136238ec72accbc0 -const FRAME_TIME = 16; -let lastTime = 0; -window.requestAnimationFrame = function (callback, element) { - const now = window.performance.now(); - const nextTime = Math.max(lastTime + FRAME_TIME, now); - return setTimeout(function () { - callback((lastTime = nextTime)); - }, nextTime - now); -}; -window.cancelAnimationFrame = clearTimeout; - -let panelCreated = false; - -// The renderer interface can't read saved component filters directly, -// because they are stored in localStorage within the context of the extension. -// Instead it relies on the extension to pass filters through. -function syncSavedPreferences() { - chrome.devtools.inspectedWindow.eval( - `window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify( - getAppendComponentStack(), - )}; - window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify( - getBreakOnConsoleErrors(), - )}; - window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify( - getSavedComponentFilters(), - )}; - window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify( - getShowInlineWarningsAndErrors(), - )}; - window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = ${JSON.stringify( - getHideConsoleLogsInStrictMode(), - )}; - window.__REACT_DEVTOOLS_BROWSER_THEME__ = ${JSON.stringify( - getBrowserTheme(), - )};`, - ); -} - -syncSavedPreferences(); - -function createPanelIfReactLoaded() { - if (panelCreated) { - return; - } - - chrome.devtools.inspectedWindow.eval( - 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0', - function (pageHasReact, error) { - if (!pageHasReact || panelCreated) { - return; - } - - panelCreated = true; - - clearInterval(loadCheckInterval); - - let bridge = null; - let store = null; - - let profilingData = null; - - let componentsPortalContainer = null; - let profilerPortalContainer = null; - - let cloneStyleTags = null; - let mostRecentOverrideTab = null; - let render = null; - let root = null; - - const tabId = chrome.devtools.inspectedWindow.tabId; - - registerDevToolsEventLogger('extension', async () => { - // TODO: after we upgrade to Manifest V3, chrome.tabs.query returns a Promise - // without the callback. - return new Promise(resolve => { - chrome.tabs.query({active: true, currentWindow: true}, tabs => { - resolve({ - page_url: tabs[0]?.url, - }); - }); - }); - }); - - function initBridgeAndStore() { - const port = chrome.runtime.connect({ - name: String(tabId), - }); - // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, - // so it makes no sense to handle it here. - - bridge = new Bridge({ - listen(fn) { - const listener = message => fn(message); - // Store the reference so that we unsubscribe from the same object. - const portOnMessage = port.onMessage; - portOnMessage.addListener(listener); - return () => { - portOnMessage.removeListener(listener); - }; - }, - send(event: string, payload: any, transferable?: Array) { - port.postMessage({event, payload}, transferable); - }, - }); - bridge.addListener('reloadAppForProfiling', () => { - localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); - chrome.devtools.inspectedWindow.eval('window.location.reload();'); - }); - bridge.addListener('syncSelectionToNativeElementsPanel', () => { - setBrowserSelectionFromReact(); - }); - - // This flag lets us tip the Store off early that we expect to be profiling. - // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, - // after a user has clicked the "reload and profile" button. - let isProfiling = false; - let supportsProfiling = false; - if ( - localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true' - ) { - supportsProfiling = true; - isProfiling = true; - localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); - } - - if (store !== null) { - profilingData = store.profilerStore.profilingData; - } - - bridge.addListener('extensionBackendInitialized', () => { - // Initialize the renderer's trace-updates setting. - // This handles the case of navigating to a new page after the DevTools have already been shown. - bridge.send( - 'setTraceUpdatesEnabled', - localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === - 'true', - ); - }); - - store = new Store(bridge, { - isProfiling, - supportsReloadAndProfile: IS_CHROME || IS_EDGE, - supportsProfiling, - // At this time, the timeline can only parse Chrome performance profiles. - supportsTimeline: IS_CHROME, - supportsTraceUpdates: true, - }); - if (!isProfiling) { - store.profilerStore.profilingData = profilingData; - } - - // Initialize the backend only once the Store has been initialized. - // Otherwise the Store may miss important initial tree op codes. - if (IS_CHROME || IS_EDGE) { - chrome.runtime.sendMessage({ - source: 'react-devtools-main', - payload: { - type: 'react-devtools-inject-backend-manager', - tabId, - }, - }); - } else { - // Firefox does not support executing script in ExecutionWorld.MAIN from content script. - // see prepareInjection.js - chrome.devtools.inspectedWindow.eval( - `window.postMessage({ source: 'react-devtools-inject-backend-manager' }, '*');`, - function (response, evalError) { - if (evalError) { - console.error(evalError); - } - }, - ); - } - - const viewAttributeSourceFunction = (id, path) => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to find the specified attribute, - // and store it as a global variable on the window. - bridge.send('viewAttributeSource', {id, path, rendererID}); - - setTimeout(() => { - // Ask Chrome to display the location of the attribute, - // assuming the renderer found a match. - chrome.devtools.inspectedWindow.eval(` - if (window.$attribute != null) { - inspect(window.$attribute); - } - `); - }, 100); - } - }; - - const viewElementSourceFunction = id => { - const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - // Ask the renderer interface to determine the component function, - // and store it as a global variable on the window - bridge.send('viewElementSource', {id, rendererID}); - - setTimeout(() => { - // Ask Chrome to display the location of the component function, - // or a render method if it is a Class (ideally Class instance, not type) - // assuming the renderer found one. - chrome.devtools.inspectedWindow.eval(` - if (window.$type != null) { - if ( - window.$type && - window.$type.prototype && - window.$type.prototype.isReactComponent - ) { - // inspect Component.render, not constructor - inspect(window.$type.prototype.render); - } else { - // inspect Functional Component - inspect(window.$type); - } - } - `); - }, 100); - } - }; - - const viewUrlSourceFunction = (url, line, col) => { - chrome.devtools.panels.openResource(url, line, col); - }; - - let debugIDCounter = 0; - - // For some reason in Firefox, chrome.runtime.sendMessage() from a content script - // never reaches the chrome.runtime.onMessage event listener. - let fetchFileWithCaching = null; - if (IS_CHROME) { - const fetchFromNetworkCache = (url, resolve, reject) => { - // Debug ID allows us to avoid re-logging (potentially long) URL strings below, - // while also still associating (potentially) interleaved logs with the original request. - let debugID = null; - - if (__DEBUG__) { - debugID = debugIDCounter++; - console.log(`[main] fetchFromNetworkCache(${debugID})`, url); - } - - chrome.devtools.network.getHAR(harLog => { - for (let i = 0; i < harLog.entries.length; i++) { - const entry = harLog.entries[i]; - if (url === entry.request.url) { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`, - url, - ); - } - - entry.getContent(content => { - if (content) { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, - ); - } - - resolve(content); - } else { - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`, - content, - ); - } - - // Edge case where getContent() returned null; fall back to fetch. - fetchFromPage(url, resolve, reject); - } - }); - - return; - } - } - - if (__DEBUG__) { - console.log( - `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`, - ); - } - - // No matching URL found; fall back to fetch. - fetchFromPage(url, resolve, reject); - }); - }; - - const fetchFromPage = (url, resolve, reject) => { - if (__DEBUG__) { - console.log('[main] fetchFromPage()', url); - } - - function onPortMessage({payload, source}) { - if (source === 'react-devtools-content-script') { - switch (payload?.type) { - case 'fetch-file-with-cache-complete': - chrome.runtime.onMessage.removeListener(onPortMessage); - resolve(payload.value); - break; - case 'fetch-file-with-cache-error': - chrome.runtime.onMessage.removeListener(onPortMessage); - reject(payload.value); - break; - } - } - } - - chrome.runtime.onMessage.addListener(onPortMessage); - - chrome.devtools.inspectedWindow.eval(` - window.postMessage({ - source: 'react-devtools-extension', - payload: { - type: 'fetch-file-with-cache', - url: "${url}", - }, - }); - `); - }; - - // Fetching files from the extension won't make use of the network cache - // for resources that have already been loaded by the page. - // This helper function allows the extension to request files to be fetched - // by the content script (running in the page) to increase the likelihood of a cache hit. - fetchFileWithCaching = url => { - return new Promise((resolve, reject) => { - // Try fetching from the Network cache first. - // If DevTools was opened after the page started loading, we may have missed some requests. - // So fall back to a fetch() from the page and hope we get a cached response that way. - fetchFromNetworkCache(url, resolve, reject); - }); - }; - } - - // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. - const hookNamesModuleLoaderFunction = () => - import( - /* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames' - ); - - root = createRoot(document.createElement('div')); - - render = (overrideTab = mostRecentOverrideTab) => { - mostRecentOverrideTab = overrideTab; - root.render( - createElement(DevTools, { - bridge, - browserTheme: getBrowserTheme(), - componentsPortalContainer, - enabledInspectedElementContextMenu: true, - fetchFileWithCaching, - hookNamesModuleLoaderFunction, - overrideTab, - profilerPortalContainer, - showTabBar: false, - store, - warnIfUnsupportedVersionDetected: true, - viewAttributeSourceFunction, - viewElementSourceFunction, - viewUrlSourceFunction, - }), - ); - }; - - render(); - } - - cloneStyleTags = () => { - const linkTags = []; - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const linkTag of document.getElementsByTagName('link')) { - if (linkTag.rel === 'stylesheet') { - const newLinkTag = document.createElement('link'); - // eslint-disable-next-line no-for-of-loops/no-for-of-loops - for (const attribute of linkTag.attributes) { - newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue); - } - linkTags.push(newLinkTag); - } - } - return linkTags; - }; - - initBridgeAndStore(); - - function ensureInitialHTMLIsCleared(container) { - if (container._hasInitialHTMLBeenCleared) { - return; - } - container.innerHTML = ''; - container._hasInitialHTMLBeenCleared = true; - } - - function setBrowserSelectionFromReact() { - // This is currently only called on demand when you press "view DOM". - // In the future, if Chrome adds an inspect() that doesn't switch tabs, - // we could make this happen automatically when you select another component. - chrome.devtools.inspectedWindow.eval( - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + - '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' + - 'false', - (didSelectionChange, evalError) => { - if (evalError) { - console.error(evalError); - } - }, - ); - } - - function setReactSelectionFromBrowser() { - // When the user chooses a different node in the browser Elements tab, - // copy it over to the hook object so that we can sync the selection. - chrome.devtools.inspectedWindow.eval( - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' + - 'false', - (didSelectionChange, evalError) => { - if (evalError) { - console.error(evalError); - } else if (didSelectionChange) { - // Remember to sync the selection next time we show Components tab. - needsToSyncElementSelection = true; - } - }, - ); - } - - setReactSelectionFromBrowser(); - chrome.devtools.panels.elements.onSelectionChanged.addListener(() => { - setReactSelectionFromBrowser(); - }); - - let currentPanel = null; - let needsToSyncElementSelection = false; - - chrome.devtools.panels.create( - IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components', - IS_EDGE ? 'icons/production.svg' : '', - 'panel.html', - extensionPanel => { - extensionPanel.onShown.addListener(panel => { - if (needsToSyncElementSelection) { - needsToSyncElementSelection = false; - bridge.send('syncSelectionFromNativeElementsPanel'); - } - - if (currentPanel === panel) { - return; - } - - currentPanel = panel; - componentsPortalContainer = panel.container; - - if (componentsPortalContainer != null) { - ensureInitialHTMLIsCleared(componentsPortalContainer); - render('components'); - panel.injectStyles(cloneStyleTags); - logEvent({event_name: 'selected-components-tab'}); - } - }); - extensionPanel.onHidden.addListener(panel => { - // TODO: Stop highlighting and stuff. - }); - }, - ); - - chrome.devtools.panels.create( - IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler', - IS_EDGE ? 'icons/production.svg' : '', - 'panel.html', - extensionPanel => { - extensionPanel.onShown.addListener(panel => { - if (currentPanel === panel) { - return; - } - - currentPanel = panel; - profilerPortalContainer = panel.container; - - if (profilerPortalContainer != null) { - ensureInitialHTMLIsCleared(profilerPortalContainer); - render('profiler'); - panel.injectStyles(cloneStyleTags); - logEvent({event_name: 'selected-profiler-tab'}); - } - }); - }, - ); - - chrome.devtools.network.onNavigated.removeListener(checkPageForReact); - - // Re-initialize DevTools panel when a new page is loaded. - chrome.devtools.network.onNavigated.addListener(function onNavigated() { - // Re-initialize saved filters on navigation, - // since global values stored on window get reset in this case. - syncSavedPreferences(); - - // It's easiest to recreate the DevTools panel (to clean up potential stale state). - // We can revisit this in the future as a small optimization. - flushSync(() => root.unmount()); - - initBridgeAndStore(); - }); - }, - ); -} - -// Load (or reload) the DevTools extension when the user navigates to a new page. -function checkPageForReact() { - syncSavedPreferences(); - createPanelIfReactLoaded(); -} - -chrome.devtools.network.onNavigated.addListener(checkPageForReact); - -// Check to see if React has loaded once per second in case React is added -// after page load -const loadCheckInterval = setInterval(function () { - createPanelIfReactLoaded(); -}, 1000); - -createPanelIfReactLoaded(); diff --git a/packages/react-devtools-extensions/src/main/cloneStyleTags.js b/packages/react-devtools-extensions/src/main/cloneStyleTags.js new file mode 100644 index 0000000000000..dd84e01fc9ef8 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/cloneStyleTags.js @@ -0,0 +1,21 @@ +function cloneStyleTags() { + const linkTags = []; + + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const linkTag of document.getElementsByTagName('link')) { + if (linkTag.rel === 'stylesheet') { + const newLinkTag = document.createElement('link'); + + // eslint-disable-next-line no-for-of-loops/no-for-of-loops + for (const attribute of linkTag.attributes) { + newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue); + } + + linkTags.push(newLinkTag); + } + } + + return linkTags; +} + +export default cloneStyleTags; diff --git a/packages/react-devtools-extensions/src/main/elementSelection.js b/packages/react-devtools-extensions/src/main/elementSelection.js new file mode 100644 index 0000000000000..54d5422776cb1 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/elementSelection.js @@ -0,0 +1,42 @@ +/* global chrome */ + +export function setBrowserSelectionFromReact() { + // This is currently only called on demand when you press "view DOM". + // In the future, if Chrome adds an inspect() that doesn't switch tabs, + // we could make this happen automatically when you select another component. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' + + 'false', + (didSelectionChange, evalError) => { + if (evalError) { + console.error(evalError); + } + }, + ); +} + +export function setReactSelectionFromBrowser(bridge) { + // When the user chooses a different node in the browser Elements tab, + // copy it over to the hook object so that we can sync the selection. + chrome.devtools.inspectedWindow.eval( + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' + + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' + + 'false', + (didSelectionChange, evalError) => { + if (evalError) { + console.error(evalError); + } else if (didSelectionChange) { + if (!bridge) { + console.error( + 'Browser element selection changed, but bridge was not initialized', + ); + return; + } + + // Remember to sync the selection next time we show Components tab. + bridge.send('syncSelectionFromNativeElementsPanel'); + } + }, + ); +} diff --git a/packages/react-devtools-extensions/src/main/getProfilingFlags.js b/packages/react-devtools-extensions/src/main/getProfilingFlags.js new file mode 100644 index 0000000000000..760b0e48355e2 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/getProfilingFlags.js @@ -0,0 +1,23 @@ +import { + localStorageGetItem, + localStorageRemoveItem, +} from 'react-devtools-shared/src/storage'; +import {LOCAL_STORAGE_SUPPORTS_PROFILING_KEY} from 'react-devtools-shared/src/constants'; + +function getProfilingFlags() { + // This flag lets us tip the Store off early that we expect to be profiling. + // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab, + // after a user has clicked the "reload and profile" button. + let isProfiling = false; + let supportsProfiling = false; + + if (localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true') { + supportsProfiling = true; + isProfiling = true; + localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY); + } + + return {isProfiling, supportsProfiling}; +} + +export default getProfilingFlags; diff --git a/packages/react-devtools-extensions/src/main/index.js b/packages/react-devtools-extensions/src/main/index.js new file mode 100644 index 0000000000000..a57e62096fd8c --- /dev/null +++ b/packages/react-devtools-extensions/src/main/index.js @@ -0,0 +1,525 @@ +/* global chrome */ + +import {createElement} from 'react'; +import {flushSync} from 'react-dom'; +import {createRoot} from 'react-dom/client'; +import Bridge from 'react-devtools-shared/src/bridge'; +import Store from 'react-devtools-shared/src/devtools/store'; +import {IS_CHROME, IS_EDGE, getBrowserTheme, IS_FIREFOX} from '../utils'; +import { + localStorageGetItem, + localStorageSetItem, +} from 'react-devtools-shared/src/storage'; +import DevTools from 'react-devtools-shared/src/devtools/views/DevTools'; +import { + __DEBUG__, + LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, + LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY, +} from 'react-devtools-shared/src/constants'; +import {logEvent} from 'react-devtools-shared/src/Logger'; + +import { + setBrowserSelectionFromReact, + setReactSelectionFromBrowser, +} from './elementSelection'; +import cloneStyleTags from './cloneStyleTags'; +import injectBackendManager from './injectBackendManager'; +import syncSavedPreferences from './syncSavedPreferences'; +import registerEventsLogger from './registerEventsLogger'; +import getProfilingFlags from './getProfilingFlags'; +import './requestAnimationFramePolyfill'; + +function executeIfReactHasLoaded(callback) { + chrome.devtools.inspectedWindow.eval( + 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0', + (pageHasReact, exceptionInfo) => { + if (exceptionInfo) { + const {code, description, isError, isException, value} = exceptionInfo; + + if (isException) { + console.error( + `Received error while checking if react has loaded: ${value}`, + ); + return; + } + + if (isError) { + console.error( + `Received error with code ${code} while checking if react has loaded: ${description}`, + ); + return; + } + } + + if (pageHasReact) { + callback(); + } + }, + ); +} + +function createBridge() { + bridge = new Bridge({ + listen(fn) { + const listener = message => fn(message); + // Store the reference so that we unsubscribe from the same object. + const portOnMessage = port.onMessage; + portOnMessage.addListener(listener); + + return () => { + portOnMessage.removeListener(listener); + }; + }, + + send(event: string, payload: any, transferable?: Array) { + port.postMessage({event, payload}, transferable); + }, + }); + + bridge.addListener('reloadAppForProfiling', () => { + localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true'); + chrome.devtools.inspectedWindow.eval('window.location.reload();'); + }); + + bridge.addListener( + 'syncSelectionToNativeElementsPanel', + setBrowserSelectionFromReact, + ); + + bridge.addListener('extensionBackendInitialized', () => { + // Initialize the renderer's trace-updates setting. + // This handles the case of navigating to a new page after the DevTools have already been shown. + bridge.send( + 'setTraceUpdatesEnabled', + localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === 'true', + ); + }); + + const onBrowserElementSelectionChanged = () => + setReactSelectionFromBrowser(bridge); + const onBridgeShutdown = () => { + chrome.devtools.panels.elements.onSelectionChanged.removeListener( + onBrowserElementSelectionChanged, + ); + }; + + bridge.addListener('shutdown', onBridgeShutdown); + + chrome.devtools.panels.elements.onSelectionChanged.addListener( + onBrowserElementSelectionChanged, + ); +} + +function createBridgeAndStore() { + createBridge(); + + const {isProfiling, supportsProfiling} = getProfilingFlags(); + + store = new Store(bridge, { + isProfiling, + supportsReloadAndProfile: IS_CHROME || IS_EDGE, + supportsProfiling, + // At this time, the timeline can only parse Chrome performance profiles. + supportsTimeline: IS_CHROME, + supportsTraceUpdates: true, + }); + + if (!isProfiling) { + // We previously stored this in performCleanup function + store.profilerStore.profilingData = profilingData; + } + + // Initialize the backend only once the Store has been initialized. + // Otherwise, the Store may miss important initial tree op codes. + injectBackendManager(chrome.devtools.inspectedWindow.tabId); + + const viewAttributeSourceFunction = (id, path) => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to find the specified attribute, + // and store it as a global variable on the window. + bridge.send('viewAttributeSource', {id, path, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the attribute, + // assuming the renderer found a match. + chrome.devtools.inspectedWindow.eval(` + if (window.$attribute != null) { + inspect(window.$attribute); + } + `); + }, 100); + } + }; + + const viewElementSourceFunction = id => { + const rendererID = store.getRendererIDForElement(id); + if (rendererID != null) { + // Ask the renderer interface to determine the component function, + // and store it as a global variable on the window + bridge.send('viewElementSource', {id, rendererID}); + + setTimeout(() => { + // Ask Chrome to display the location of the component function, + // or a render method if it is a Class (ideally Class instance, not type) + // assuming the renderer found one. + chrome.devtools.inspectedWindow.eval(` + if (window.$type != null) { + if ( + window.$type && + window.$type.prototype && + window.$type.prototype.isReactComponent + ) { + // inspect Component.render, not constructor + inspect(window.$type.prototype.render); + } else { + // inspect Functional Component + inspect(window.$type); + } + } + `); + }, 100); + } + }; + + let debugIDCounter = 0; + + // For some reason in Firefox, chrome.runtime.sendMessage() from a content script + // never reaches the chrome.runtime.onMessage event listener. + let fetchFileWithCaching = null; + if (IS_CHROME) { + const fetchFromNetworkCache = (url, resolve, reject) => { + // Debug ID allows us to avoid re-logging (potentially long) URL strings below, + // while also still associating (potentially) interleaved logs with the original request. + let debugID = null; + + if (__DEBUG__) { + debugID = debugIDCounter++; + console.log(`[main] fetchFromNetworkCache(${debugID})`, url); + } + + chrome.devtools.network.getHAR(harLog => { + for (let i = 0; i < harLog.entries.length; i++) { + const entry = harLog.entries[i]; + if (url === entry.request.url) { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`, + url, + ); + } + + entry.getContent(content => { + if (content) { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, + ); + } + + resolve(content); + } else { + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`, + content, + ); + } + + // Edge case where getContent() returned null; fall back to fetch. + fetchFromPage(url, resolve, reject); + } + }); + + return; + } + } + + if (__DEBUG__) { + console.log( + `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`, + ); + } + + // No matching URL found; fall back to fetch. + fetchFromPage(url, resolve, reject); + }); + }; + + const fetchFromPage = (url, resolve, reject) => { + if (__DEBUG__) { + console.log('[main] fetchFromPage()', url); + } + + function onPortMessage({payload, source}) { + if (source === 'react-devtools-content-script') { + switch (payload?.type) { + case 'fetch-file-with-cache-complete': + chrome.runtime.onMessage.removeListener(onPortMessage); + resolve(payload.value); + break; + case 'fetch-file-with-cache-error': + chrome.runtime.onMessage.removeListener(onPortMessage); + reject(payload.value); + break; + } + } + } + + chrome.runtime.onMessage.addListener(onPortMessage); + + chrome.devtools.inspectedWindow.eval(` + window.postMessage({ + source: 'react-devtools-extension', + payload: { + type: 'fetch-file-with-cache', + url: "${url}", + }, + }); + `); + }; + + // Fetching files from the extension won't make use of the network cache + // for resources that have already been loaded by the page. + // This helper function allows the extension to request files to be fetched + // by the content script (running in the page) to increase the likelihood of a cache hit. + fetchFileWithCaching = url => { + return new Promise((resolve, reject) => { + // Try fetching from the Network cache first. + // If DevTools was opened after the page started loading, we may have missed some requests. + // So fall back to a fetch() from the page and hope we get a cached response that way. + fetchFromNetworkCache(url, resolve, reject); + }); + }; + } + + // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration. + const hookNamesModuleLoaderFunction = () => + import( + /* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames' + ); + + root = createRoot(document.createElement('div')); + + render = (overrideTab = mostRecentOverrideTab) => { + mostRecentOverrideTab = overrideTab; + + root.render( + createElement(DevTools, { + bridge, + browserTheme: getBrowserTheme(), + componentsPortalContainer, + enabledInspectedElementContextMenu: true, + fetchFileWithCaching, + hookNamesModuleLoaderFunction, + overrideTab, + profilerPortalContainer, + showTabBar: false, + store, + warnIfUnsupportedVersionDetected: true, + viewAttributeSourceFunction, + viewElementSourceFunction, + viewUrlSourceFunction, + }), + ); + }; + + render(); +} + +const viewUrlSourceFunction = (url, line, col) => { + chrome.devtools.panels.openResource(url, line, col); +}; + +function ensureInitialHTMLIsCleared(container) { + if (container._hasInitialHTMLBeenCleared) { + return; + } + + container.innerHTML = ''; + container._hasInitialHTMLBeenCleared = true; +} + +function createComponentsPanel() { + if (componentsPortalContainer) { + render('components'); + + return; + } + + chrome.devtools.panels.create( + IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components', + IS_EDGE ? 'icons/production.svg' : '', + 'panel.html', + createdPanel => { + createdPanel.onShown.addListener(portal => { + componentsPortalContainer = portal.container; + if (componentsPortalContainer != null) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + + render('components'); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-components-tab'}); + } + }); + + // TODO: we should listen to extension.onHidden to unmount some listeners + // and potentially stop highlighting + }, + ); +} + +function createProfilerPanel() { + if (componentsPortalContainer) { + render('profiler'); + + return; + } + + chrome.devtools.panels.create( + IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler', + IS_EDGE ? 'icons/production.svg' : '', + 'panel.html', + createdPanel => { + createdPanel.onShown.addListener(portal => { + profilerPortalContainer = portal.container; + if (profilerPortalContainer != null) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + + render('profiler'); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-profiler-tab'}); + } + }); + }, + ); +} + +function performInTabNavigationCleanup() { + if (store !== null) { + // Store profiling data, so it can be used later + profilingData = store.profilerStore.profilingData; + } + + // If panels were already created, and we have already mounted React root to display + // tabs (Components or Profiler), we should unmount root first and render them again + if ((componentsPortalContainer || profilerPortalContainer) && root) { + // It's easiest to recreate the DevTools panel (to clean up potential stale state). + // We can revisit this in the future as a small optimization. + // This should also emit bridge.shutdown, but only if this root was mounted + flushSync(() => root.unmount()); + } else { + // In case Browser DevTools were opened, but user never pressed on extension panels + // They were never mounted and there is nothing to unmount, but we need to emit shutdown event + // because bridge was already created + bridge?.shutdown(); + } + + // Do not nullify componentsPanelPortal and profilerPanelPortal on purpose, + // They are not recreated when user does in-tab navigation, and they can only be accessed via + // callback in onShown listener, which is called only when panel has been shown + // This event won't be emitted again after in-tab navigation, if DevTools panel keeps being opened + + // Do not clean mostRecentOverrideTab on purpose, so we remember last opened + // React DevTools tab, when user does in-tab navigation + + store = null; + bridge = null; + render = null; + root = null; + + port?.disconnect(); + port = null; +} + +function performFullCleanup() { + if ((componentsPortalContainer || profilerPortalContainer) && root) { + // This should also emit bridge.shutdown, but only if this root was mounted + flushSync(() => root.unmount()); + } else { + bridge?.shutdown(); + } + + componentsPortalContainer = null; + profilerPortalContainer = null; + root = null; + + mostRecentOverrideTab = null; + store = null; + bridge = null; + render = null; + + port?.disconnect(); + port = null; +} + +function mountReactDevTools() { + registerEventsLogger(); + + const tabId = chrome.devtools.inspectedWindow.tabId; + port = chrome.runtime.connect({ + name: String(tabId), + }); + + createBridgeAndStore(); + + setReactSelectionFromBrowser(bridge); + + createComponentsPanel(); + createProfilerPanel(); +} + +// TODO: display some disclaimer if user performs in-tab navigation to non-react application +// when React DevTools panels are already opened, currently we will display just blank white block +function mountReactDevToolsWhenReactHasLoaded() { + const checkIfReactHasLoaded = () => executeIfReactHasLoaded(onReactReady); + + // Check to see if React has loaded in case React is added after page load + const reactPollingIntervalId = setInterval(() => { + checkIfReactHasLoaded(); + }, 500); + + function onReactReady() { + clearInterval(reactPollingIntervalId); + mountReactDevTools(); + } + + checkIfReactHasLoaded(); +} + +let bridge = null; +let store = null; + +let profilingData = null; + +let componentsPortalContainer = null; +let profilerPortalContainer = null; + +let mostRecentOverrideTab = null; +let render = null; +let root = null; + +let port = null; + +// Re-initialize saved filters on navigation, +// since global values stored on window get reset in this case. +chrome.devtools.network.onNavigated.addListener(syncSavedPreferences); + +// Cleanup previous page state and remount everything +chrome.devtools.network.onNavigated.addListener(() => { + performInTabNavigationCleanup(); + mountReactDevToolsWhenReactHasLoaded(); +}); + +// Should be emitted when browser DevTools are closed +if (IS_FIREFOX) { + // For some reason Firefox doesn't emit onBeforeUnload event + window.addEventListener('unload', performFullCleanup); +} else { + window.addEventListener('beforeunload', performFullCleanup); +} + +syncSavedPreferences(); +mountReactDevToolsWhenReactHasLoaded(); diff --git a/packages/react-devtools-extensions/src/main/injectBackendManager.js b/packages/react-devtools-extensions/src/main/injectBackendManager.js new file mode 100644 index 0000000000000..0b5c16aa157c1 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/injectBackendManager.js @@ -0,0 +1,30 @@ +/* global chrome */ + +import {IS_FIREFOX} from '../utils'; + +function injectBackendManager(tabId) { + if (IS_FIREFOX) { + // Firefox does not support executing script in ExecutionWorld.MAIN from content script. + // see prepareInjection.js + chrome.devtools.inspectedWindow.eval( + `window.postMessage({ source: 'react-devtools-inject-backend-manager' }, '*');`, + function (response, evalError) { + if (evalError) { + console.error(evalError); + } + }, + ); + + return; + } + + chrome.runtime.sendMessage({ + source: 'react-devtools-main', + payload: { + type: 'react-devtools-inject-backend-manager', + tabId, + }, + }); +} + +export default injectBackendManager; diff --git a/packages/react-devtools-extensions/src/main/registerEventsLogger.js b/packages/react-devtools-extensions/src/main/registerEventsLogger.js new file mode 100644 index 0000000000000..5234866fd546c --- /dev/null +++ b/packages/react-devtools-extensions/src/main/registerEventsLogger.js @@ -0,0 +1,18 @@ +/* global chrome */ + +import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger'; + +function registerEventsLogger() { + registerDevToolsEventLogger('extension', async () => { + // TODO: after we upgrade to Firefox Manifest V3, chrome.tabs.query returns a Promise without the callback. + return new Promise(resolve => { + chrome.tabs.query({active: true}, tabs => { + resolve({ + page_url: tabs[0]?.url, + }); + }); + }); + }); +} + +export default registerEventsLogger; diff --git a/packages/react-devtools-extensions/src/main/requestAnimationFramePolyfill.js b/packages/react-devtools-extensions/src/main/requestAnimationFramePolyfill.js new file mode 100644 index 0000000000000..ccd9a361d9c04 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/requestAnimationFramePolyfill.js @@ -0,0 +1,17 @@ +// rAF never fires on devtools_page (because it's in the background) +// https://bugs.chromium.org/p/chromium/issues/detail?id=1241986#c31 +// Since we render React elements here, we need to polyfill it with setTimeout +// The polyfill is based on https://gist.github.com/jalbam/5fe05443270fa6d8136238ec72accbc0 +const FRAME_TIME = 16; +let lastTime = 0; + +window.requestAnimationFrame = function (callback, element) { + const now = window.performance.now(); + const nextTime = Math.max(lastTime + FRAME_TIME, now); + + return setTimeout(function () { + callback((lastTime = nextTime)); + }, nextTime - now); +}; + +window.cancelAnimationFrame = clearTimeout; diff --git a/packages/react-devtools-extensions/src/main/syncSavedPreferences.js b/packages/react-devtools-extensions/src/main/syncSavedPreferences.js new file mode 100644 index 0000000000000..6ceed86fcd06d --- /dev/null +++ b/packages/react-devtools-extensions/src/main/syncSavedPreferences.js @@ -0,0 +1,38 @@ +/* global chrome */ + +import { + getAppendComponentStack, + getBreakOnConsoleErrors, + getSavedComponentFilters, + getShowInlineWarningsAndErrors, + getHideConsoleLogsInStrictMode, +} from 'react-devtools-shared/src/utils'; +import {getBrowserTheme} from 'react-devtools-extensions/src/utils'; + +// The renderer interface can't read saved component filters directly, +// because they are stored in localStorage within the context of the extension. +// Instead it relies on the extension to pass filters through. +function syncSavedPreferences() { + chrome.devtools.inspectedWindow.eval( + `window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify( + getAppendComponentStack(), + )}; + window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify( + getBreakOnConsoleErrors(), + )}; + window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify( + getSavedComponentFilters(), + )}; + window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify( + getShowInlineWarningsAndErrors(), + )}; + window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = ${JSON.stringify( + getHideConsoleLogsInStrictMode(), + )}; + window.__REACT_DEVTOOLS_BROWSER_THEME__ = ${JSON.stringify( + getBrowserTheme(), + )};`, + ); +} + +export default syncSavedPreferences; diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index f533f4952df6f..558d2e93e9699 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -55,9 +55,9 @@ module.exports = { mode: __DEV__ ? 'development' : 'production', devtool: __DEV__ ? 'cheap-module-source-map' : false, entry: { - background: './src/background.js', - backendManager: './src/backendManager.js', - main: './src/main.js', + background: './src/background/index.js', + backendManager: './src/contentScripts/backendManager.js', + main: './src/main/index.js', panel: './src/panel.js', proxy: './src/contentScripts/proxy.js', prepareInjection: './src/contentScripts/prepareInjection.js', diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index 716dad8c575db..b73c178bf2980 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -68,4 +68,7 @@ export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY = export const LOCAL_STORAGE_HIDE_CONSOLE_LOGS_IN_STRICT_MODE = 'React::DevTools::hideConsoleLogsInStrictMode'; +export const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY = + 'React::DevTools::supportsProfiling'; + export const PROFILER_EXPORT_VERSION = 5; diff --git a/packages/react-devtools-shared/src/devtools/views/DevTools.js b/packages/react-devtools-shared/src/devtools/views/DevTools.js index ea7a996d3235c..3eef04ece7c63 100644 --- a/packages/react-devtools-shared/src/devtools/views/DevTools.js +++ b/packages/react-devtools-shared/src/devtools/views/DevTools.js @@ -269,6 +269,7 @@ export default function DevTools({ useEffect(() => { logEvent({event_name: 'loaded-dev-tools'}); }, []); + return (