diff --git a/packages/react-devtools-extensions/chrome/manifest.json b/packages/react-devtools-extensions/chrome/manifest.json index 5753fbba7ca84..ffb1afc80de90 100644 --- a/packages/react-devtools-extensions/chrome/manifest.json +++ b/packages/react-devtools-extensions/chrome/manifest.json @@ -1,17 +1,17 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Chrome Developer Tools.", "version": "4.26.1", "version_name": "4.26.1", - "minimum_chrome_version": "60", + "minimum_chrome_version": "88", "icons": { "16": "icons/16-production.png", "32": "icons/32-production.png", "48": "icons/48-production.png", "128": "icons/128-production.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icons/16-disabled.png", "32": "icons/32-disabled.png", @@ -21,23 +21,34 @@ "default_popup": "popups/disabled.html" }, "devtools_page": "main.html", - "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "web_accessible_resources": [ - "main.html", - "panel.html", - "build/react_devtools_backend.js", - "build/renderer.js" + { + "resources": [ + "main.html", + "panel.html", + "build/react_devtools_backend.js", + "build/proxy.js", + "build/renderer.js", + "build/installHook.js" + ], + "matches": [ + "" + ], + "extension_ids": [] + } ], "background": { - "scripts": [ - "build/background.js" - ], - "persistent": false + "service_worker": "build/background.js" }, "permissions": [ - "file:///*", - "http://*/*", - "https://*/*" + "storage", + "scripting" + ], + "host_permissions": [ + "" ], "content_scripts": [ { @@ -45,7 +56,7 @@ "" ], "js": [ - "build/injectGlobalHook.js" + "build/prepareInjection.js" ], "run_at": "document_start" } diff --git a/packages/react-devtools-extensions/edge/manifest.json b/packages/react-devtools-extensions/edge/manifest.json index 0d7bead9d2521..16e9790f943ab 100644 --- a/packages/react-devtools-extensions/edge/manifest.json +++ b/packages/react-devtools-extensions/edge/manifest.json @@ -1,17 +1,17 @@ { - "manifest_version": 2, + "manifest_version": 3, "name": "React Developer Tools", "description": "Adds React debugging tools to the Microsoft Edge Developer Tools.", "version": "4.26.1", "version_name": "4.26.1", - "minimum_chrome_version": "60", + "minimum_chrome_version": "88", "icons": { "16": "icons/16-production.png", "32": "icons/32-production.png", "48": "icons/48-production.png", "128": "icons/128-production.png" }, - "browser_action": { + "action": { "default_icon": { "16": "icons/16-disabled.png", "32": "icons/32-disabled.png", @@ -21,23 +21,34 @@ "default_popup": "popups/disabled.html" }, "devtools_page": "main.html", - "content_security_policy": "script-src 'self' 'unsafe-eval'; object-src 'self'", + "content_security_policy": { + "extension_pages": "script-src 'self'; object-src 'self'" + }, "web_accessible_resources": [ - "main.html", - "panel.html", - "build/react_devtools_backend.js", - "build/renderer.js" + { + "resources": [ + "main.html", + "panel.html", + "build/react_devtools_backend.js", + "build/proxy.js", + "build/renderer.js", + "build/installHook.js" + ], + "matches": [ + "" + ], + "extension_ids": [] + } ], "background": { - "scripts": [ - "build/background.js" - ], - "persistent": false + "service_worker": "build/background.js" }, "permissions": [ - "file:///*", - "http://*/*", - "https://*/*" + "storage", + "scripting" + ], + "host_permissions": [ + "" ], "content_scripts": [ { @@ -45,7 +56,7 @@ "" ], "js": [ - "build/injectGlobalHook.js" + "build/prepareInjection.js" ], "run_at": "document_start" } diff --git a/packages/react-devtools-extensions/firefox/manifest.json b/packages/react-devtools-extensions/firefox/manifest.json index 401f0ae1fed82..953ad1efff9c2 100644 --- a/packages/react-devtools-extensions/firefox/manifest.json +++ b/packages/react-devtools-extensions/firefox/manifest.json @@ -31,7 +31,9 @@ "main.html", "panel.html", "build/react_devtools_backend.js", - "build/renderer.js" + "build/proxy.js", + "build/renderer.js", + "build/installHook.js" ], "background": { "scripts": [ @@ -50,7 +52,7 @@ "" ], "js": [ - "build/injectGlobalHook.js" + "build/prepareInjection.js" ], "run_at": "document_start" } diff --git a/packages/react-devtools-extensions/src/background.js b/packages/react-devtools-extensions/src/background.js index 9e09513b78fb4..5b33330994581 100644 --- a/packages/react-devtools-extensions/src/background.js +++ b/packages/react-devtools-extensions/src/background.js @@ -2,9 +2,33 @@ 'use strict'; +import {IS_FIREFOX} from './utils'; + const ports = {}; -const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') >= 0; +if (!IS_FIREFOX) { + // Manifest V3 method of injecting content scripts (not yet supported in Firefox) + // 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 + chrome.scripting.registerContentScripts([ + { + 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, + }, + ]); +} chrome.runtime.onConnect.addListener(function(port) { let tab = null; @@ -12,7 +36,7 @@ chrome.runtime.onConnect.addListener(function(port) { if (isNumeric(port.name)) { tab = port.name; name = 'devtools'; - installContentScript(+port.name); + installProxy(+port.name); } else { tab = port.sender.tab.id; name = 'content-script'; @@ -35,12 +59,15 @@ function isNumeric(str: string): boolean { return +str + '' === str; } -function installContentScript(tabId: number) { - chrome.tabs.executeScript( - tabId, - {file: '/build/contentScript.js'}, - function() {}, - ); +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) { @@ -63,18 +90,19 @@ function doublePipe(one, two) { } function setIconAndPopup(reactBuildType, tabId) { - chrome.browserAction.setIcon({ + const action = IS_FIREFOX ? chrome.browserAction : chrome.action; + action.setIcon({ tabId: tabId, path: { - '16': 'icons/16-' + reactBuildType + '.png', - '32': 'icons/32-' + reactBuildType + '.png', - '48': 'icons/48-' + reactBuildType + '.png', - '128': 'icons/128-' + reactBuildType + '.png', + '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`), }, }); - chrome.browserAction.setPopup({ + action.setPopup({ tabId: tabId, - popup: 'popups/' + reactBuildType + '.html', + popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`), }); } @@ -123,9 +151,6 @@ chrome.runtime.onMessage.addListener((request, sender) => { // This is sent from the hook content script. // It tells us a renderer has attached. if (request.hasDetectedReact) { - // We use browserAction instead of pageAction because this lets us - // display a custom default popup when React is *not* detected. - // It is specified in the manifest. setIconAndPopup(request.reactBuildType, id); } else { switch (request.payload?.type) { diff --git a/packages/react-devtools-extensions/src/contentScripts/installHook.js b/packages/react-devtools-extensions/src/contentScripts/installHook.js new file mode 100644 index 0000000000000..8517ff5ff999f --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/installHook.js @@ -0,0 +1,25 @@ +import {installHook} from 'react-devtools-shared/src/hook'; + +// avoid double execution +if (!window.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) { + installHook(window); + + // detect react + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({ + reactBuildType, + }) { + window.postMessage( + { + source: 'react-devtools-detector', + reactBuildType, + }, + '*', + ); + }); + + // save native values + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeObjectCreate = Object.create; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeMap = Map; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeWeakMap = WeakMap; + window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set; +} diff --git a/packages/react-devtools-extensions/src/injectGlobalHook.js b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js similarity index 75% rename from packages/react-devtools-extensions/src/injectGlobalHook.js rename to packages/react-devtools-extensions/src/contentScripts/prepareInjection.js index ff9123824e38f..c609b58c909d6 100644 --- a/packages/react-devtools-extensions/src/injectGlobalHook.js +++ b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js @@ -1,11 +1,19 @@ /* global chrome */ import nullthrows from 'nullthrows'; -import {installHook} from 'react-devtools-shared/src/hook'; import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/constants'; import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; +import {IS_FIREFOX} from '../utils'; + +function injectScriptSync(src) { + let code = ''; + const request = new XMLHttpRequest(); + request.addEventListener('load', function() { + code = this.responseText; + }); + request.open('GET', src, false); + request.send(); -function injectCode(code) { const script = document.createElement('script'); script.textContent = code; @@ -15,6 +23,15 @@ function injectCode(code) { nullthrows(script.parentNode).removeChild(script); } +function injectScriptAsync(src) { + const script = document.createElement('script'); + script.src = src; + script.onload = function() { + script.remove(); + }; + nullthrows(document.documentElement).appendChild(script); +} + let lastDetectionResult; // We want to detect when a renderer attaches, and notify the "background page" @@ -77,10 +94,9 @@ window.addEventListener('message', function onMessage({data, source}) { } break; case 'react-devtools-inject-backend': - const script = document.createElement('script'); - script.src = chrome.runtime.getURL('build/react_devtools_backend.js'); - document.documentElement.appendChild(script); - script.parentNode.removeChild(script); + injectScriptAsync( + chrome.runtime.getURL('build/react_devtools_backend.js'), + ); break; } }); @@ -96,38 +112,18 @@ window.addEventListener('pageshow', function({target}) { chrome.runtime.sendMessage(lastDetectionResult); }); -const detectReact = ` -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.on('renderer', function({reactBuildType}) { - window.postMessage({ - source: 'react-devtools-detector', - reactBuildType, - }, '*'); -}); -`; -const saveNativeValues = ` -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeObjectCreate = Object.create; -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeMap = Map; -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeWeakMap = WeakMap; -window.__REACT_DEVTOOLS_GLOBAL_HOOK__.nativeSet = Set; -`; +// We create a "sync" script tag to page to inject the global hook on Manifest V2 extensions. +// To comply with the new security policy in V3, we use chrome.scripting.registerContentScripts instead (see background.js). +// However, the new API only works for Chrome v102+. +// We insert a "async" script tag as a fallback for older versions. +// It has known issues if JS on the page is faster than the extension. +// Users will see a notice in components tab when that happens (see ). +// For Firefox, V3 is not ready, so sync injection is still the best approach. +const injectScript = IS_FIREFOX ? injectScriptSync : injectScriptAsync; // If we have just reloaded to profile, we need to inject the renderer interface before the app loads. if (sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { - const rendererURL = chrome.runtime.getURL('build/renderer.js'); - let rendererCode; - - // We need to inject in time to catch the initial mount. - // This means we need to synchronously read the renderer code itself, - // and synchronously inject it into the page. - // There are very few ways to actually do this. - // This seems to be the best approach. - const request = new XMLHttpRequest(); - request.addEventListener('load', function() { - rendererCode = this.responseText; - }); - request.open('GET', rendererURL, false); - request.send(); - injectCode(rendererCode); + injectScript(chrome.runtime.getURL('build/renderer.js')); } // Inject a __REACT_DEVTOOLS_GLOBAL_HOOK__ global for React to interact with. @@ -138,13 +134,7 @@ if (sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true') { switch (document.contentType) { case 'text/html': case 'application/xhtml+xml': { - injectCode( - ';(' + - installHook.toString() + - '(window))' + - saveNativeValues + - detectReact, - ); + injectScript(chrome.runtime.getURL('build/installHook.js')); break; } } diff --git a/packages/react-devtools-extensions/src/contentScript.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js similarity index 100% rename from packages/react-devtools-extensions/src/contentScript.js rename to packages/react-devtools-extensions/src/contentScripts/proxy.js diff --git a/packages/react-devtools-extensions/src/contentScripts/renderer.js b/packages/react-devtools-extensions/src/contentScripts/renderer.js new file mode 100644 index 0000000000000..ab02997bbaab8 --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/renderer.js @@ -0,0 +1,33 @@ +/** + * In order to support reload-and-profile functionality, the renderer needs to be injected before any other scripts. + * Since it is a complex file (with imports) we can't just toString() it like we do with the hook itself, + * So this entry point (one of the web_accessible_resources) provides a way to eagerly inject it. + * The hook will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an injected renderer early. + * The normal case (not a reload-and-profile) will not make use of this entry point though. + * + * @flow + */ + +import {attach} from 'react-devtools-shared/src/backend/renderer'; +import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/constants'; +import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; + +if ( + sessionStorageGetItem(SESSION_STORAGE_RELOAD_AND_PROFILE_KEY) === 'true' && + !window.hasOwnProperty('__REACT_DEVTOOLS_ATTACH__') +) { + Object.defineProperty( + window, + '__REACT_DEVTOOLS_ATTACH__', + ({ + enumerable: false, + // This property needs to be configurable to allow third-party integrations + // to attach their own renderer. Note that using third-party integrations + // is not officially supported. Use at your own risk. + configurable: true, + get() { + return attach; + }, + }: Object), + ); +} diff --git a/packages/react-devtools-extensions/src/renderer.js b/packages/react-devtools-extensions/src/renderer.js deleted file mode 100644 index e9c9639f51f22..0000000000000 --- a/packages/react-devtools-extensions/src/renderer.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * In order to support reload-and-profile functionality, the renderer needs to be injected before any other scripts. - * Since it is a complex file (with imports) we can't just toString() it like we do with the hook itself, - * So this entry point (one of the web_accessible_resources) provides a way to eagerly inject it. - * The hook will look for the presence of a global __REACT_DEVTOOLS_ATTACH__ and attach an injected renderer early. - * The normal case (not a reload-and-profile) will not make use of this entry point though. - * - * @flow - */ - -import {attach} from 'react-devtools-shared/src/backend/renderer'; - -Object.defineProperty( - window, - '__REACT_DEVTOOLS_ATTACH__', - ({ - enumerable: false, - // This property needs to be configurable to allow third-party integrations - // to attach their own renderer. Note that using third-party integrations - // is not officially supported. Use at your own risk. - configurable: true, - get() { - return attach; - }, - }: Object), -); diff --git a/packages/react-devtools-extensions/src/utils.js b/packages/react-devtools-extensions/src/utils.js index ea2bd27ee992a..c34c01d21d0a7 100644 --- a/packages/react-devtools-extensions/src/utils.js +++ b/packages/react-devtools-extensions/src/utils.js @@ -2,9 +2,9 @@ import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools'; -const IS_EDGE = navigator.userAgent.indexOf('Edg') >= 0; -const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') >= 0; -const IS_CHROME = IS_EDGE === false && IS_FIREFOX === false; +export const IS_EDGE = navigator.userAgent.indexOf('Edg') >= 0; +export const IS_FIREFOX = navigator.userAgent.indexOf('Firefox') >= 0; +export const IS_CHROME = IS_EDGE === false && IS_FIREFOX === false; export type BrowserName = 'Chrome' | 'Firefox' | 'Edge'; diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 3a913e0373804..ffbf153a8db4a 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -51,11 +51,12 @@ module.exports = { devtool: __DEV__ ? 'cheap-module-eval-source-map' : false, entry: { background: './src/background.js', - contentScript: './src/contentScript.js', - injectGlobalHook: './src/injectGlobalHook.js', main: './src/main.js', panel: './src/panel.js', - renderer: './src/renderer.js', + proxy: './src/contentScripts/proxy.js', + prepareInjection: './src/contentScripts/prepareInjection.js', + renderer: './src/contentScripts/renderer.js', + installHook: './src/contentScripts/installHook.js', }, output: { path: __dirname + '/build', diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css index 2036695a50e34..bf18f1d2e6019 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.css @@ -79,3 +79,22 @@ .WarningIcon { color: var(--color-console-warning-icon); } + +.ZeroElementsNotice { + padding-left: 1em; + opacity: 0; + animation: fadeIn 0.5s forwards; + animation-delay: 2s; +} +@keyframes fadeIn { + 0% { + opacity: 0; + } + 100% { + opacity: 1; + } +} + +.Link { + color: var(--color-button-active); +} \ No newline at end of file diff --git a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js index a7c4048da2c98..106bda6d36123 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/Tree.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/Tree.js @@ -344,6 +344,22 @@ export default function Tree(props: Props): React.Node { clearErrorsAndWarningsAPI({bridge, store}); }; + const zeroElementsNotice = ( +
+

Loading React Element Tree...

+

+ If this seems stuck, please follow the{' '} + + troubleshooting instructions + + . +

+
+ ); + return (
@@ -398,32 +414,36 @@ export default function Tree(props: Props): React.Node { )}
-
- - {({height, width}) => ( - - {Element} - - )} - -
+ {numElements === 0 ? ( + zeroElementsNotice + ) : ( +
+ + {({height, width}) => ( + + {Element} + + )} + +
+ )}
); diff --git a/packages/react-devtools/README.md b/packages/react-devtools/README.md index 07d811ec223da..60e31025c01a6 100644 --- a/packages/react-devtools/README.md +++ b/packages/react-devtools/README.md @@ -105,8 +105,13 @@ Or you could develop with a local HTTP server like [`serve`](https://www.npmjs.c **If your app is inside an iframe, a Chrome extension, React Native, or in another unusual environment**, try [the standalone version instead](https://github.com/facebook/react/tree/main/packages/react-devtools). Chrome apps are currently not inspectable. +**If your Components tab is empty, refer to "Chrome v101 and earlier" section below**, please read the "the issue with Chrome v101 and earlier versions" part below. + **If you still have issues** please [report them](https://github.com/facebook/react/issues/new?labels=Component:%20Developer%20Tools). Don't forget to specify your OS, browser version, extension version, and the exact instructions to reproduce the issue with a screenshot. +### The Issue with Chrome v101 and earlier +As we migrate to a Chrome Extension Manifest V3, we start to use a new method to hook the DevTools with the inspected page. This new method is more secure, but relies on a new API that's only supported in Chrome v102+. For Chrome v101 or earlier, we use a fallback method, which can cause malfunctions (e.g. empty React component tab) if the JS resources on your page is loaded from cache. Please upgrade to Chrome v102+ to avoid this issue. + ## Local development The standalone DevTools app can be built and tested from source following the instructions below.