From 10836bcd6a649d35c964fef7f4c10a513d47ee7c Mon Sep 17 00:00:00 2001 From: Ruslan Lesiutin Date: Fri, 11 Aug 2023 14:59:20 +0100 Subject: [PATCH] refactor: refactored devtools browser extension scripts to improve ports management and service worker lifetime --- .../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 0b14b44503712..aace9b479f3d1 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -54,9 +54,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 (