diff --git a/.eslintrc.js b/.eslintrc.js index 941c2e3b23ca8..878dd08fda653 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -415,9 +415,7 @@ module.exports = { }, }, { - files: [ - 'packages/react-native-renderer/**/*.js', - ], + files: ['packages/react-native-renderer/**/*.js'], globals: { nativeFabricUIManager: 'readonly', }, @@ -456,6 +454,7 @@ module.exports = { $ReadOnlyArray: 'readonly', $ArrayBufferView: 'readonly', $Shape: 'readonly', + ReturnType: 'readonly', AnimationFrameID: 'readonly', // For Flow type annotation. Only `BigInt` is valid at runtime. bigint: 'readonly', @@ -492,7 +491,6 @@ module.exports = { ReadableStreamController: 'readonly', RequestInfo: 'readonly', RequestOptions: 'readonly', - ResponseState: 'readonly', StoreAsGlobal: 'readonly', symbol: 'readonly', SyntheticEvent: 'readonly', diff --git a/README.md b/README.md index 766c673306a86..b7a7f30f4cc71 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ React is a JavaScript library for building user interfaces. * **Component-Based:** Build encapsulated components that manage their own state, then compose them to make complex UIs. Since component logic is written in JavaScript instead of templates, you can easily pass rich data through your app and keep the state out of the DOM. * **Learn Once, Write Anywhere:** We don't make assumptions about the rest of your technology stack, so you can develop new features in React without rewriting existing code. React can also render on the server using Node and power mobile apps using [React Native](https://reactnative.dev/). -[Learn how to use React in your project](https://reactjs.org/docs/getting-started.html). +[Learn how to use React in your project](https://react.dev/learn). ## Installation @@ -20,9 +20,9 @@ You can use React as a ` diff --git a/packages/react-devtools-extensions/popups/shared.css b/packages/react-devtools-extensions/popups/shared.css index cd3d35d005897..731a2036c374b 100644 --- a/packages/react-devtools-extensions/popups/shared.css +++ b/packages/react-devtools-extensions/popups/shared.css @@ -4,4 +4,20 @@ html, body { body { margin: 8px; -} \ No newline at end of file +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + } + + @supports (-moz-appearance:none) { + :root { + background: black; + } + + body { + color: white; + } + } +} 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..45fe394974004 --- /dev/null +++ b/packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js @@ -0,0 +1,79 @@ +/* global chrome */ + +import {IS_FIREFOX} from '../utils'; + +// Firefox doesn't support ExecutionWorld.MAIN yet +// equivalent logic for Firefox is in prepareInjection.js +const contentScriptsToInject = IS_FIREFOX + ? [ + { + id: '@react-devtools/proxy', + js: ['build/proxy.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + }, + { + id: '@react-devtools/file-fetcher', + js: ['build/fileFetcher.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + }, + ] + : [ + { + id: '@react-devtools/proxy', + js: ['build/proxy.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + id: '@react-devtools/file-fetcher', + js: ['build/fileFetcher.js'], + matches: [''], + persistAcrossSessions: true, + runAt: 'document_end', + world: chrome.scripting.ExecutionWorld.ISOLATED, + }, + { + 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, + }, + ]; + +async function dynamicallyInjectContentScripts() { + try { + // Using this, instead of filtering registered scrips with `chrome.scripting.getRegisteredScripts` + // because of https://bugs.chromium.org/p/chromium/issues/detail?id=1393762 + // This fixes registering proxy content script in incognito mode + 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); + } +} + +dynamicallyInjectContentScripts(); diff --git a/packages/react-devtools-extensions/src/background/executeScript.js b/packages/react-devtools-extensions/src/background/executeScript.js new file mode 100644 index 0000000000000..af48726be028c --- /dev/null +++ b/packages/react-devtools-extensions/src/background/executeScript.js @@ -0,0 +1,58 @@ +/* global chrome */ + +import {IS_FIREFOX} from '../utils'; + +// Firefox doesn't support ExecutionWorld.MAIN yet +// https://bugzilla.mozilla.org/show_bug.cgi?id=1736575 +function executeScriptForFirefoxInMainWorld({target, files}) { + return chrome.scripting.executeScript({ + target, + func: fileNames => { + function injectScriptSync(src) { + let code = ''; + const request = new XMLHttpRequest(); + request.addEventListener('load', function () { + code = this.responseText; + }); + request.open('GET', src, false); + request.send(); + + const script = document.createElement('script'); + script.textContent = code; + + // This script runs before the element is created, + // so we add the script to instead. + if (document.documentElement) { + document.documentElement.appendChild(script); + } + + if (script.parentNode) { + script.parentNode.removeChild(script); + } + } + + fileNames.forEach(file => injectScriptSync(chrome.runtime.getURL(file))); + }, + args: [files], + }); +} + +export function executeScriptInIsolatedWorld({target, files}) { + return chrome.scripting.executeScript({ + target, + files, + world: chrome.scripting.ExecutionWorld.ISOLATED, + }); +} + +export function executeScriptInMainWorld({target, files}) { + if (IS_FIREFOX) { + return executeScriptForFirefoxInMainWorld({target, files}); + } + + return chrome.scripting.executeScript({ + target, + files, + world: chrome.scripting.ExecutionWorld.MAIN, + }); +} 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..b25eb53033193 --- /dev/null +++ b/packages/react-devtools-extensions/src/background/index.js @@ -0,0 +1,214 @@ +/* global chrome */ + +'use strict'; + +import './dynamicallyInjectContentScripts'; +import './tabsManager'; + +import { + handleDevToolsPageMessage, + handleBackendManagerMessage, + handleReactDevToolsHookMessage, + handleFetchResourceContentScriptMessage, +} from './messageHandlers'; + +/* + { + [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; + }); +} + +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 + // This listener should never be called when we call port.disconnect() from this (background/index.js) script + port.onDisconnect.addListener(() => { + ports[tabId].disconnectPipe?.(); + + delete ports[tabId].proxy; + }); +} + +function isNumeric(str: string): boolean { + return +str + '' === str; +} + +chrome.runtime.onConnect.addListener(port => { + if (port.name === 'proxy') { + // Might not be present for restricted pages in Firefox + if (port.sender?.tab?.id == null) { + // Not disconnecting it, so it would not reconnect + return; + } + + // Proxy content script is executed in tab, so it should have it specified. + const tabId = port.sender.tab.id; + + if (ports[tabId]?.proxy) { + ports[tabId].disconnectPipe?.(); + ports[tabId].proxy.disconnect(); + } + + registerTab(tabId); + registerProxyPort(port, tabId); + + if (ports[tabId].extension) { + connectExtensionAndProxyPorts( + ports[tabId].extension, + ports[tabId].proxy, + tabId, + ); + } + + return; + } + + if (isNumeric(port.name)) { + // DevTools page port doesn't have tab id specified, because its sender is the extension. + const tabId = +port.name; + + registerTab(tabId); + registerExtensionPort(port, tabId); + + if (ports[tabId].proxy) { + connectExtensionAndProxyPorts( + ports[tabId].extension, + ports[tabId].proxy, + 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) => { + switch (message?.source) { + case 'devtools-page': { + handleDevToolsPageMessage(message); + break; + } + case 'react-devtools-fetch-resource-content-script': { + handleFetchResourceContentScriptMessage(message); + break; + } + case 'react-devtools-backend-manager': { + handleBackendManagerMessage(message, sender); + break; + } + case 'react-devtools-hook': { + handleReactDevToolsHookMessage(message, sender); + } + } +}); + +chrome.tabs.onActivated.addListener(({tabId: activeTabId}) => { + for (const registeredTabId in ports) { + if ( + ports[registeredTabId].proxy != null && + ports[registeredTabId].extension != null + ) { + const numericRegisteredTabId = +registeredTabId; + const event = + activeTabId === numericRegisteredTabId + ? 'resumeElementPolling' + : 'pauseElementPolling'; + + ports[registeredTabId].extension.postMessage({event}); + } + } +}); diff --git a/packages/react-devtools-extensions/src/background/messageHandlers.js b/packages/react-devtools-extensions/src/background/messageHandlers.js new file mode 100644 index 0000000000000..5afcd6aadcc07 --- /dev/null +++ b/packages/react-devtools-extensions/src/background/messageHandlers.js @@ -0,0 +1,103 @@ +/* global chrome */ + +import setExtensionIconAndPopup from './setExtensionIconAndPopup'; +import {executeScriptInMainWorld} from './executeScript'; + +import {EXTENSION_CONTAINED_VERSIONS} from '../utils'; + +export function handleReactDevToolsHookMessage(message, sender) { + const {payload} = message; + + switch (payload?.type) { + case 'react-renderer-attached': { + setExtensionIconAndPopup(payload.reactBuildType, sender.tab.id); + + break; + } + } +} + +export function handleBackendManagerMessage(message, sender) { + const {payload} = message; + + switch (payload?.type) { + case 'require-backends': { + payload.versions.forEach(version => { + if (EXTENSION_CONTAINED_VERSIONS.includes(version)) { + executeScriptInMainWorld({ + target: {tabId: sender.tab.id}, + files: [`/build/react_devtools_backend_${version}.js`], + }); + } + }); + + break; + } + } +} + +export function handleDevToolsPageMessage(message) { + const {payload} = message; + + switch (payload?.type) { + // Proxy this message from DevTools page to content script via chrome.tabs.sendMessage + case 'fetch-file-with-cache': { + const { + payload: {tabId, url}, + } = message; + + if (!tabId) { + throw new Error("Couldn't fetch file sources: tabId not specified"); + } + + if (!url) { + throw new Error("Couldn't fetch file sources: url not specified"); + } + + chrome.tabs.sendMessage(tabId, { + source: 'devtools-page', + payload: { + type: 'fetch-file-with-cache', + url, + }, + }); + + break; + } + + case 'inject-backend-manager': { + const { + payload: {tabId}, + } = message; + + if (!tabId) { + throw new Error("Couldn't inject backend manager: tabId not specified"); + } + + executeScriptInMainWorld({ + target: {tabId}, + files: ['/build/backendManager.js'], + }); + + break; + } + } +} + +export function handleFetchResourceContentScriptMessage(message) { + const {payload} = message; + + switch (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 DevTools page. + // We switch the source here because of inconsistency between Firefox and Chrome + // In Chromium this message will be propagated from content script to DevTools page + // For Firefox, only background script will get this message, so we need to forward it to DevTools page + chrome.runtime.sendMessage({ + source: 'react-devtools-background', + payload, + }); + break; + } +} 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 97% rename from packages/react-devtools-extensions/src/backendManager.js rename to packages/react-devtools-extensions/src/contentScripts/backendManager.js index a77ca2f9601c6..36a2ab19aa411 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; @@ -170,7 +170,7 @@ function updateRequiredBackends() { { source: 'react-devtools-backend-manager', payload: { - type: 'react-devtools-required-backends', + type: 'require-backends', versions: Array.from(requiredBackends), }, }, diff --git a/packages/react-devtools-extensions/src/contentScripts/fileFetcher.js b/packages/react-devtools-extensions/src/contentScripts/fileFetcher.js new file mode 100644 index 0000000000000..198b4f68cd301 --- /dev/null +++ b/packages/react-devtools-extensions/src/contentScripts/fileFetcher.js @@ -0,0 +1,48 @@ +/* global chrome */ + +function fetchResource(url) { + const reject = value => { + chrome.runtime.sendMessage({ + source: 'react-devtools-fetch-resource-content-script', + payload: { + type: 'fetch-file-with-cache-error', + url, + value, + }, + }); + }; + + const resolve = value => { + chrome.runtime.sendMessage({ + source: 'react-devtools-fetch-resource-content-script', + payload: { + type: 'fetch-file-with-cache-complete', + url, + value, + }, + }); + }; + + fetch(url, {cache: 'force-cache'}).then( + response => { + if (response.ok) { + response + .text() + .then(text => resolve(text)) + .catch(error => reject(null)); + } else { + reject(null); + } + }, + error => reject(null), + ); +} + +chrome.runtime.onMessage.addListener(message => { + if ( + message?.source === 'devtools-page' && + message?.payload?.type === 'fetch-file-with-cache' + ) { + fetchResource(message.payload.url); + } +}); diff --git a/packages/react-devtools-extensions/src/contentScripts/installHook.js b/packages/react-devtools-extensions/src/contentScripts/installHook.js index c02a66342ffc2..2d33cdd89036d 100644 --- a/packages/react-devtools-extensions/src/contentScripts/installHook.js +++ b/packages/react-devtools-extensions/src/contentScripts/installHook.js @@ -10,8 +10,11 @@ if (!window.hasOwnProperty('__REACT_DEVTOOLS_GLOBAL_HOOK__')) { function ({reactBuildType}) { window.postMessage( { - source: 'react-devtools-detector', - reactBuildType, + source: 'react-devtools-hook', + payload: { + type: 'react-renderer-attached', + reactBuildType, + }, }, '*', ); diff --git a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js index a62cce3903f46..8f7b6405cb0a8 100644 --- a/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js +++ b/packages/react-devtools-extensions/src/contentScripts/prepareInjection.js @@ -1,11 +1,9 @@ /* global chrome */ import nullthrows from 'nullthrows'; -import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/constants'; -import {sessionStorageGetItem} from 'react-devtools-shared/src/storage'; -import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils'; +import {IS_FIREFOX} 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 @@ -29,7 +27,7 @@ function injectScriptSync(src) { nullthrows(script.parentNode).removeChild(script); } -let lastDetectionResult; +let lastSentDevToolsHookMessage; // We want to detect when a renderer attaches, and notify the "background page" // (which is shared between tabs and can highlight the React icon). @@ -41,73 +39,14 @@ window.addEventListener('message', function onMessage({data, source}) { if (source !== window || !data) { return; } - switch (data.source) { - case 'react-devtools-detector': - lastDetectionResult = { - hasDetectedReact: true, - reactBuildType: data.reactBuildType, - }; - chrome.runtime.sendMessage(lastDetectionResult); - break; - case 'react-devtools-extension': - if (data.payload?.type === 'fetch-file-with-cache') { - const url = data.payload.url; - - const reject = value => { - chrome.runtime.sendMessage({ - source: 'react-devtools-content-script', - payload: { - type: 'fetch-file-with-cache-error', - url, - value, - }, - }); - }; - const resolve = value => { - chrome.runtime.sendMessage({ - source: 'react-devtools-content-script', - payload: { - type: 'fetch-file-with-cache-complete', - url, - value, - }, - }); - }; + // We keep this logic here and not in `proxy.js`, because proxy content script is injected later at `document_end` + if (data.source === 'react-devtools-hook') { + const {source: messageSource, payload} = data; + const message = {source: messageSource, payload}; - fetch(url, {cache: 'force-cache'}).then( - response => { - if (response.ok) { - response - .text() - .then(text => resolve(text)) - .catch(error => reject(null)); - } else { - reject(null); - } - }, - error => reject(null), - ); - } - break; - case 'react-devtools-inject-backend-manager': - if (IS_FIREFOX) { - injectScriptSync(chrome.runtime.getURL('build/backendManager.js')); - } - break; - case 'react-devtools-backend-manager': - if (IS_FIREFOX) { - data.payload?.versions?.forEach(version => { - if (EXTENSION_CONTAINED_VERSIONS.includes(version)) { - injectScriptSync( - chrome.runtime.getURL( - `/build/react_devtools_backend_${version}.js`, - ), - ); - } - }); - } - break; + lastSentDevToolsHookMessage = message; + chrome.runtime.sendMessage(message); } }); @@ -116,19 +55,16 @@ window.addEventListener('message', function onMessage({data, source}) { // replay the last detection result if the content script is active and the // document has been hidden and shown again. window.addEventListener('pageshow', function ({target}) { - if (!lastDetectionResult || target !== window.document) { + if (!lastSentDevToolsHookMessage || target !== window.document) { return; } - chrome.runtime.sendMessage(lastDetectionResult); + + chrome.runtime.sendMessage(lastSentDevToolsHookMessage); }); if (IS_FIREFOX) { - // 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' - ) { - injectScriptSync(chrome.runtime.getURL('build/renderer.js')); - } + injectScriptSync(chrome.runtime.getURL('build/renderer.js')); + // Inject a __REACT_DEVTOOLS_GLOBAL_HOOK__ global for React to interact with. // Only do this for HTML documents though, to avoid e.g. breaking syntax highlighting for XML docs. switch (document.contentType) { diff --git a/packages/react-devtools-extensions/src/contentScripts/proxy.js b/packages/react-devtools-extensions/src/contentScripts/proxy.js index 3bf4bf5cab445..8ffeeffb2af53 100644 --- a/packages/react-devtools-extensions/src/contentScripts/proxy.js +++ b/packages/react-devtools-extensions/src/contentScripts/proxy.js @@ -2,7 +2,37 @@ 'use strict'; -let backendDisconnected: boolean = false; +window.addEventListener('pageshow', function ({target}) { + // Firefox's behaviour for injecting this content script can be unpredictable + // While navigating the history, some content scripts might not be re-injected and still be alive + if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) { + window.__REACT_DEVTOOLS_PROXY_INJECTED__ = true; + + 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); + } +}); + +window.addEventListener('pagehide', function ({target}) { + if (target !== window.document) { + return; + } + + delete window.__REACT_DEVTOOLS_PROXY_INJECTED__; +}); + +let port = null; let backendInitialized: boolean = false; function sayHelloToBackendManager() { @@ -26,59 +56,49 @@ function handleMessageFromDevtools(message) { } function handleMessageFromPage(event) { - if (event.source === window && event.data) { + if (event.source !== window || !event.data) { + return; + } + + switch (event.data.source) { // This is a message from a bridge (initialized by a devtools backend) - if (event.data.source === 'react-devtools-bridge') { + case 'react-devtools-bridge': { backendInitialized = true; port.postMessage(event.data.payload); + break; } - // This is a message from the backend manager - if (event.data.source === 'react-devtools-backend-manager') { + + // This is a message from the backend manager, which runs in ExecutionWorld.MAIN + // and can't use `chrome.runtime.sendMessage` + case 'react-devtools-backend-manager': { + const {source, payload} = event.data; + chrome.runtime.sendMessage({ - payload: event.data.payload, + source, + payload, }); + break; } } } 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); - -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); +// 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); + + 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/debounce.js b/packages/react-devtools-extensions/src/main/debounce.js new file mode 100644 index 0000000000000..5ca765002f2fa --- /dev/null +++ b/packages/react-devtools-extensions/src/main/debounce.js @@ -0,0 +1,10 @@ +function debounce(fn, timeout) { + let executionTimeoutId = null; + + return (...args) => { + clearTimeout(executionTimeoutId); + executionTimeoutId = setTimeout(fn, timeout, ...args); + }; +} + +export default debounce; 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/fetchFileWithCaching.js b/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js new file mode 100644 index 0000000000000..6aee0969a3229 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/fetchFileWithCaching.js @@ -0,0 +1,123 @@ +/* global chrome */ + +import {__DEBUG__} from 'react-devtools-shared/src/constants'; + +let debugIDCounter = 0; + +const debugLog = (...args) => { + if (__DEBUG__) { + console.log(...args); + } +}; + +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++; + debugLog(`[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) { + continue; + } + + debugLog( + `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`, + url, + ); + + if (entry.getContent != null) { + entry.getContent(content => { + if (content) { + debugLog( + `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, + ); + + resolve(content); + } else { + debugLog( + `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`, + content, + ); + + // Edge case where getContent() returned null; fall back to fetch. + fetchFromPage(url, resolve, reject); + } + }); + } else { + const content = entry.response.content.text; + + if (content != null) { + debugLog( + `[main] fetchFromNetworkCache(${debugID}) Content retrieved`, + ); + resolve(content); + } else { + debugLog( + `[main] fetchFromNetworkCache(${debugID}) Invalid content returned from entry.response.content`, + content, + ); + fetchFromPage(url, resolve, reject); + } + } + } + + debugLog( + `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`, + ); + + // No matching URL found; fall back to fetch. + fetchFromPage(url, resolve, reject); + }); +}; + +const fetchFromPage = async (url, resolve, reject) => { + debugLog('[main] fetchFromPage()', url); + + function onPortMessage({payload, source}) { + if (source === 'react-devtools-background') { + 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.runtime.sendMessage({ + source: 'devtools-page', + payload: { + type: 'fetch-file-with-cache', + tabId: chrome.devtools.inspectedWindow.tabId, + 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. +const 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); + }); +}; + +export default fetchFileWithCaching; 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..555d35fe5d372 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/index.js @@ -0,0 +1,457 @@ +/* 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 { + 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 {startReactPolling} from './reactPolling'; +import cloneStyleTags from './cloneStyleTags'; +import fetchFileWithCaching from './fetchFileWithCaching'; +import injectBackendManager from './injectBackendManager'; +import syncSavedPreferences from './syncSavedPreferences'; +import registerEventsLogger from './registerEventsLogger'; +import getProfilingFlags from './getProfilingFlags'; +import debounce from './debounce'; +import './requestAnimationFramePolyfill'; + +function createBridge() { + bridge = new Bridge({ + listen(fn) { + const bridgeListener = message => fn(message); + // Store the reference so that we unsubscribe from the same object. + const portOnMessage = port.onMessage; + portOnMessage.addListener(bridgeListener); + + lastSubscribedBridgeListener = bridgeListener; + + return () => { + port?.onMessage.removeListener(bridgeListener); + lastSubscribedBridgeListener = null; + }; + }, + + 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); + } + }; + + // 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, + }), + ); + }; +} + +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) { + // Panel is created and user opened it at least once + ensureInitialHTMLIsCleared(componentsPortalContainer); + render('components'); + + return; + } + + if (componentsPanel) { + // Panel is created, but wasn't opened yet, so no document is present for it + return; + } + + chrome.devtools.panels.create( + IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components', + IS_EDGE ? 'icons/production.svg' : '', + 'panel.html', + createdPanel => { + componentsPanel = createdPanel; + + createdPanel.onShown.addListener(portal => { + componentsPortalContainer = portal.container; + if (componentsPortalContainer != null && render) { + ensureInitialHTMLIsCleared(componentsPortalContainer); + + render('components'); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-components-tab'}); + } + }); + + // TODO: we should listen to createdPanel.onHidden to unmount some listeners + // and potentially stop highlighting + }, + ); +} + +function createProfilerPanel() { + if (profilerPortalContainer) { + // Panel is created and user opened it at least once + ensureInitialHTMLIsCleared(profilerPortalContainer); + render('profiler'); + + return; + } + + if (profilerPanel) { + // Panel is created, but wasn't opened yet, so no document is present for it + return; + } + + chrome.devtools.panels.create( + IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler', + IS_EDGE ? 'icons/production.svg' : '', + 'panel.html', + createdPanel => { + profilerPanel = createdPanel; + + createdPanel.onShown.addListener(portal => { + profilerPortalContainer = portal.container; + if (profilerPortalContainer != null && render) { + ensureInitialHTMLIsCleared(profilerPortalContainer); + + render('profiler'); + portal.injectStyles(cloneStyleTags); + + logEvent({event_name: 'selected-profiler-tab'}); + } + }); + }, + ); +} + +function performInTabNavigationCleanup() { + // Potentially, if react hasn't loaded yet and user performs in-tab navigation + clearReactPollingInstance(); + + 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; +} + +function performFullCleanup() { + // Potentially, if react hasn't loaded yet and user closed the browser DevTools + clearReactPollingInstance(); + + 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 connectExtensionPort() { + if (port) { + throw new Error('DevTools port was already connected'); + } + + const tabId = chrome.devtools.inspectedWindow.tabId; + port = chrome.runtime.connect({ + name: String(tabId), + }); + + // If DevTools port was reconnected and Bridge was already created + // We should subscribe bridge to this port events + // This could happen if service worker dies and all ports are disconnected, + // but later user continues the session and Chrome reconnects all ports + // Bridge object is still in-memory, though + if (lastSubscribedBridgeListener) { + port.onMessage.addListener(lastSubscribedBridgeListener); + } + + // This port may be disconnected by Chrome at some point, this callback + // will be executed only if this port was disconnected from the other end + // so, when we call `port.disconnect()` from this script, + // this should not trigger this callback and port reconnection + port.onDisconnect.addListener(() => { + port = null; + connectExtensionPort(); + }); +} + +function mountReactDevTools() { + reactPollingInstance = null; + + registerEventsLogger(); + + createBridgeAndStore(); + + setReactSelectionFromBrowser(bridge); + + createComponentsPanel(); + createProfilerPanel(); +} + +let reactPollingInstance = null; +function clearReactPollingInstance() { + reactPollingInstance?.abort(); + reactPollingInstance = null; +} + +function showNoReactDisclaimer() { + if (componentsPortalContainer) { + componentsPortalContainer.innerHTML = + '

Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.

'; + delete componentsPortalContainer._hasInitialHTMLBeenCleared; + } + + if (profilerPortalContainer) { + profilerPortalContainer.innerHTML = + '

Looks like this page doesn\'t have React, or it hasn\'t been loaded yet.

'; + delete profilerPortalContainer._hasInitialHTMLBeenCleared; + } +} + +function mountReactDevToolsWhenReactHasLoaded() { + reactPollingInstance = startReactPolling( + mountReactDevTools, + 5, // ~5 seconds + showNoReactDisclaimer, + ); +} + +let bridge = null; +let lastSubscribedBridgeListener = null; +let store = null; + +let profilingData = null; + +let componentsPanel = null; +let profilerPanel = 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); + +// In case when multiple navigation events emitted in a short period of time +// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results +// into subscribing to the same events from Bridge and window multiple times +// In this case, we will handle `operations` event twice or more and user will see +// `Cannot add node "1" because a node with that id is already in the Store.` +const debouncedOnNavigatedListener = debounce(() => { + performInTabNavigationCleanup(); + mountReactDevToolsWhenReactHasLoaded(); +}, 500); + +// Cleanup previous page state and remount everything +chrome.devtools.network.onNavigated.addListener(debouncedOnNavigatedListener); + +// 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); +} + +connectExtensionPort(); + +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..e70cb95a9a5ca --- /dev/null +++ b/packages/react-devtools-extensions/src/main/injectBackendManager.js @@ -0,0 +1,13 @@ +/* global chrome */ + +function injectBackendManager(tabId) { + chrome.runtime.sendMessage({ + source: 'devtools-page', + payload: { + type: 'inject-backend-manager', + tabId, + }, + }); +} + +export default injectBackendManager; diff --git a/packages/react-devtools-extensions/src/main/reactPolling.js b/packages/react-devtools-extensions/src/main/reactPolling.js new file mode 100644 index 0000000000000..9bb034c6a1091 --- /dev/null +++ b/packages/react-devtools-extensions/src/main/reactPolling.js @@ -0,0 +1,103 @@ +/* global chrome */ + +class CouldNotFindReactOnThePageError extends Error { + constructor() { + super("Could not find React, or it hasn't been loaded yet"); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, CouldNotFindReactOnThePageError); + } + + this.name = 'CouldNotFindReactOnThePageError'; + } +} + +export function startReactPolling( + onReactFound, + attemptsThreshold, + onCouldNotFindReactAfterReachingAttemptsThreshold, +) { + let status = 'idle'; + + function abort() { + status = 'aborted'; + } + + // This function will call onSuccess only if React was found and polling is not aborted, onError will be called for every other case + function checkIfReactPresentInInspectedWindow(onSuccess, onError) { + chrome.devtools.inspectedWindow.eval( + 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0', + (pageHasReact, exceptionInfo) => { + if (status === 'aborted') { + onError( + 'Polling was aborted, user probably navigated to the other page', + ); + return; + } + + if (exceptionInfo) { + const {code, description, isError, isException, value} = + exceptionInfo; + + if (isException) { + onError( + `Received error while checking if react has loaded: ${value}`, + ); + return; + } + + if (isError) { + onError( + `Received error with code ${code} while checking if react has loaded: "${description}"`, + ); + return; + } + } + + if (pageHasReact) { + onSuccess(); + return; + } + + onError(new CouldNotFindReactOnThePageError()); + }, + ); + } + + // Just a Promise wrapper around `checkIfReactPresentInInspectedWindow` + // returns a Promise, which will resolve only if React has been found on the page + function poll(attempt) { + return new Promise((resolve, reject) => { + checkIfReactPresentInInspectedWindow(resolve, reject); + }).catch(error => { + if (error instanceof CouldNotFindReactOnThePageError) { + if (attempt === attemptsThreshold) { + onCouldNotFindReactAfterReachingAttemptsThreshold(); + } + + // Start next attempt in 0.5s + return new Promise(r => setTimeout(r, 500)).then(() => + poll(attempt + 1), + ); + } + + // Propagating every other Error + throw error; + }); + } + + poll(1) + .then(onReactFound) + .catch(error => { + // Log propagated errors only if polling was not aborted + // Some errors are expected when user performs in-tab navigation and `.eval()` is still being executed + if (status === 'aborted') { + return; + } + + console.error(error); + }); + + return {abort}; +} 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/src/utils.js b/packages/react-devtools-extensions/src/utils.js index 9fc9f2502eec1..91311dcc766bb 100644 --- a/packages/react-devtools-extensions/src/utils.js +++ b/packages/react-devtools-extensions/src/utils.js @@ -2,26 +2,9 @@ import type {BrowserTheme} from 'react-devtools-shared/src/devtools/views/DevTools'; -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'; - -export function getBrowserName(): BrowserName { - if (IS_EDGE) { - return 'Edge'; - } - if (IS_FIREFOX) { - return 'Firefox'; - } - if (IS_CHROME) { - return 'Chrome'; - } - throw new Error( - 'Expected browser name to be one of Chrome, Edge or Firefox.', - ); -} +export const IS_EDGE: boolean = process.env.IS_EDGE; +export const IS_FIREFOX: boolean = process.env.IS_FIREFOX; +export const IS_CHROME: boolean = process.env.IS_CHROME; export function getBrowserTheme(): BrowserTheme { if (IS_CHROME) { diff --git a/packages/react-devtools-extensions/webpack.backend.js b/packages/react-devtools-extensions/webpack.backend.js index e613415e2362b..87d4d8e2fbd02 100644 --- a/packages/react-devtools-extensions/webpack.backend.js +++ b/packages/react-devtools-extensions/webpack.backend.js @@ -34,6 +34,10 @@ const __DEV__ = NODE_ENV === 'development'; const DEVTOOLS_VERSION = getVersionString(process.env.DEVTOOLS_VERSION); +const IS_CHROME = process.env.IS_CHROME === 'true'; +const IS_FIREFOX = process.env.IS_FIREFOX === 'true'; +const IS_EDGE = process.env.IS_EDGE === 'true'; + const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss'; module.exports = { @@ -79,6 +83,9 @@ module.exports = { 'process.env.LIGHT_MODE_DIMMED_WARNING_COLOR': `"${LIGHT_MODE_DIMMED_WARNING_COLOR}"`, 'process.env.LIGHT_MODE_DIMMED_ERROR_COLOR': `"${LIGHT_MODE_DIMMED_ERROR_COLOR}"`, 'process.env.LIGHT_MODE_DIMMED_LOG_COLOR': `"${LIGHT_MODE_DIMMED_LOG_COLOR}"`, + 'process.env.IS_CHROME': IS_CHROME, + 'process.env.IS_FIREFOX': IS_FIREFOX, + 'process.env.IS_EDGE': IS_EDGE, }), new DevToolsIgnorePlugin({ shouldIgnorePath: function (path) { diff --git a/packages/react-devtools-extensions/webpack.config.js b/packages/react-devtools-extensions/webpack.config.js index 202c3ad086015..e6c3ca931bb51 100644 --- a/packages/react-devtools-extensions/webpack.config.js +++ b/packages/react-devtools-extensions/webpack.config.js @@ -2,6 +2,7 @@ const {resolve} = require('path'); const Webpack = require('webpack'); +const TerserPlugin = require('terser-webpack-plugin'); const { DARK_MODE_DIMMED_WARNING_COLOR, DARK_MODE_DIMMED_ERROR_COLOR, @@ -35,6 +36,10 @@ const DEVTOOLS_VERSION = getVersionString(process.env.DEVTOOLS_VERSION); const EDITOR_URL = process.env.EDITOR_URL || null; const LOGGING_URL = process.env.LOGGING_URL || null; +const IS_CHROME = process.env.IS_CHROME === 'true'; +const IS_FIREFOX = process.env.IS_FIREFOX === 'true'; +const IS_EDGE = process.env.IS_EDGE === 'true'; + const featureFlagTarget = process.env.FEATURE_FLAG_TARGET || 'extension-oss'; const babelOptions = { @@ -50,9 +55,10 @@ 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', + fileFetcher: './src/contentScripts/fileFetcher.js', + main: './src/main/index.js', panel: './src/panel.js', proxy: './src/contentScripts/proxy.js', prepareInjection: './src/contentScripts/prepareInjection.js', @@ -80,7 +86,21 @@ module.exports = { }, }, optimization: { - minimize: false, + minimize: !__DEV__, + minimizer: [ + new TerserPlugin({ + terserOptions: { + compress: false, + mangle: { + keep_fnames: true, + }, + format: { + comments: false, + }, + }, + extractComments: false, + }), + ], }, plugins: [ new Webpack.ProvidePlugin({ @@ -105,6 +125,9 @@ module.exports = { 'process.env.LIGHT_MODE_DIMMED_WARNING_COLOR': `"${LIGHT_MODE_DIMMED_WARNING_COLOR}"`, 'process.env.LIGHT_MODE_DIMMED_ERROR_COLOR': `"${LIGHT_MODE_DIMMED_ERROR_COLOR}"`, 'process.env.LIGHT_MODE_DIMMED_LOG_COLOR': `"${LIGHT_MODE_DIMMED_LOG_COLOR}"`, + 'process.env.IS_CHROME': IS_CHROME, + 'process.env.IS_FIREFOX': IS_FIREFOX, + 'process.env.IS_EDGE': IS_EDGE, }), ], module: { diff --git a/packages/react-devtools-inline/package.json b/packages/react-devtools-inline/package.json index 270119f7a8cc9..b651289d57b18 100644 --- a/packages/react-devtools-inline/package.json +++ b/packages/react-devtools-inline/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools-inline", - "version": "4.28.0", + "version": "4.28.4", "description": "Embed react-devtools within a website", "license": "MIT", "main": "./dist/backend.js", diff --git a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js index bc50e175e9c3c..7f70586bd6f71 100644 --- a/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js +++ b/packages/react-devtools-shared/src/__tests__/inspectedElement-test.js @@ -461,10 +461,6 @@ describe('InspectedElement', () => { // This test causes an intermediate error to be logged but we can ignore it. jest.spyOn(console, 'error').mockImplementation(() => {}); - // Wait for our check-for-updates poll to get the new data. - jest.runOnlyPendingTimers(); - await Promise.resolve(); - // Clear the frontend cache to simulate DevTools being closed and re-opened. // The backend still thinks the most recently-inspected element is still cached, // so the frontend needs to tell it to resend a full value. @@ -1072,7 +1068,6 @@ describe('InspectedElement', () => { await TestUtilsAct(async () => { await TestRendererAct(async () => { inspectElementPath(path); - jest.runOnlyPendingTimers(); }); }); @@ -1227,7 +1222,6 @@ describe('InspectedElement', () => { await TestUtilsAct(async () => { await TestRendererAct(async () => { inspectElementPath(path); - jest.runOnlyPendingTimers(); }); }); @@ -1309,7 +1303,6 @@ describe('InspectedElement', () => { await TestUtilsAct(async () => { await TestRendererAct(async () => { inspectElementPath(path); - jest.runOnlyPendingTimers(); }); }); @@ -1470,9 +1463,8 @@ describe('InspectedElement', () => { async function loadPath(path) { await TestUtilsAct(async () => { - await TestRendererAct(async () => { + await TestRendererAct(() => { inspectElementPath(path); - jest.runOnlyPendingTimers(); }); }); @@ -1597,9 +1589,8 @@ describe('InspectedElement', () => { async function loadPath(path) { await TestUtilsAct(async () => { - await TestRendererAct(async () => { + await TestRendererAct(() => { inspectElementPath(path); - jest.runOnlyPendingTimers(); }); }); @@ -1640,9 +1631,11 @@ describe('InspectedElement', () => { expect(inspectedElement.props).toMatchInlineSnapshot(` { "nestedObject": { - "a": Dehydrated { - "preview_short": {…}, - "preview_long": {b: {…}, value: 2}, + "a": { + "b": { + "value": 2, + }, + "value": 2, }, "value": 2, }, diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index e694339687483..050793aa107b6 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -1419,22 +1419,20 @@ export function attach( const boundHasOwnProperty = hasOwnProperty.bind(queue); - // Detect the shape of useState() or useReducer() + // Detect the shape of useState() / useReducer() / useTransition() // using the attributes that are unique to these hooks // but also stable (e.g. not tied to current Lanes implementation) - const isStateOrReducer = - boundHasOwnProperty('pending') && - boundHasOwnProperty('dispatch') && - typeof queue.dispatch === 'function'; + // We don't check for dispatch property, because useTransition doesn't have it + if (boundHasOwnProperty('pending')) { + return true; + } // Detect useSyncExternalStore() - const isSyncExternalStore = + return ( boundHasOwnProperty('value') && boundHasOwnProperty('getSnapshot') && - typeof queue.getSnapshot === 'function'; - - // These are the only types of hooks that can schedule an update. - return isStateOrReducer || isSyncExternalStore; + typeof queue.getSnapshot === 'function' + ); } function didStatefulHookChange(prev: any, next: any): boolean { @@ -2345,6 +2343,18 @@ export function attach( const prevFallbackChildSet = prevFiberChild ? prevFiberChild.sibling : null; + + if (prevFallbackChildSet == null && nextFallbackChildSet != null) { + mountFiberRecursively( + nextFallbackChildSet, + shouldIncludeInTree ? nextFiber : parentFiber, + true, + traceNearestHostComponentUpdate, + ); + + shouldResetChildren = true; + } + if ( nextFallbackChildSet != null && prevFallbackChildSet != null && diff --git a/packages/react-devtools-shared/src/backendAPI.js b/packages/react-devtools-shared/src/backendAPI.js index 8d5cb409fe403..fcbfb423a7f01 100644 --- a/packages/react-devtools-shared/src/backendAPI.js +++ b/packages/react-devtools-shared/src/backendAPI.js @@ -11,6 +11,7 @@ import {hydrate, fillInPath} from 'react-devtools-shared/src/hydration'; import {separateDisplayNameAndHOCs} from 'react-devtools-shared/src/utils'; import Store from 'react-devtools-shared/src/devtools/store'; import TimeoutError from 'react-devtools-shared/src/errors/TimeoutError'; +import ElementPollingCancellationError from 'react-devtools-shared/src/errors/ElementPollingCancellationError'; import type { InspectedElement as InspectedElementBackend, @@ -138,7 +139,7 @@ export function storeAsGlobal({ }); } -const TIMEOUT_DELAY = 5000; +const TIMEOUT_DELAY = 10_000; let requestCounter = 0; @@ -151,10 +152,17 @@ function getPromiseForRequestID( return new Promise((resolve, reject) => { const cleanup = () => { bridge.removeListener(eventType, onInspectedElement); + bridge.removeListener('shutdown', onDisconnect); + bridge.removeListener('pauseElementPolling', onDisconnect); clearTimeout(timeoutID); }; + const onDisconnect = () => { + cleanup(); + reject(new ElementPollingCancellationError()); + }; + const onInspectedElement = (data: any) => { if (data.responseID === requestID) { cleanup(); @@ -168,6 +176,8 @@ function getPromiseForRequestID( }; bridge.addListener(eventType, onInspectedElement); + bridge.addListener('shutdown', onDisconnect); + bridge.addListener('pauseElementPolling', onDisconnect); const timeoutID = setTimeout(onTimeout, TIMEOUT_DELAY); }); diff --git a/packages/react-devtools-shared/src/bridge.js b/packages/react-devtools-shared/src/bridge.js index f4576e65096c6..b5876ddb33623 100644 --- a/packages/react-devtools-shared/src/bridge.js +++ b/packages/react-devtools-shared/src/bridge.js @@ -263,6 +263,9 @@ type FrontendEvents = { overrideHookState: [OverrideHookState], overrideProps: [OverrideValue], overrideState: [OverrideValue], + + resumeElementPolling: [], + pauseElementPolling: [], }; class Bridge< diff --git a/packages/react-devtools-shared/src/constants.js b/packages/react-devtools-shared/src/constants.js index 5c2454a36f909..b73c178bf2980 100644 --- a/packages/react-devtools-shared/src/constants.js +++ b/packages/react-devtools-shared/src/constants.js @@ -39,6 +39,9 @@ export const SESSION_STORAGE_LAST_SELECTION_KEY = export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL = 'React::DevTools::openInEditorUrl'; +export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET = + 'React::DevTools::openInEditorUrlPreset'; + export const LOCAL_STORAGE_PARSE_HOOK_NAMES_KEY = 'React::DevTools::parseHookNames'; @@ -65,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/Components/InspectedElement.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js index 8d5f02c1a02f0..eca9f80f2bb32 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElement.js @@ -219,8 +219,11 @@ export default function InspectedElementWrapper(_: Props): React.Node { } const url = new URL(editorURL); - url.href = url.href.replace('{path}', source.fileName); - url.href = url.href.replace('{line}', String(source.lineNumber)); + url.href = url.href + .replace('{path}', source.fileName) + .replace('{line}', String(source.lineNumber)) + .replace('%7Bpath%7D', source.fileName) + .replace('%7Bline%7D', String(source.lineNumber)); window.open(url); }, [inspectedElement, editorURL]); diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js index 13bc39ba2f467..c1b7ac777f11a 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementContext.js @@ -24,8 +24,8 @@ import { import {TreeStateContext} from './TreeContext'; import {BridgeContext, StoreContext} from '../context'; import { - checkForUpdate, inspectElement, + startElementUpdatesPolling, } from 'react-devtools-shared/src/inspectedElementCache'; import { clearHookNamesCache, @@ -59,8 +59,6 @@ type Context = { export const InspectedElementContext: ReactContext = createContext(((null: any): Context)); -const POLL_INTERVAL = 1000; - export type Props = { children: ReactNodeList, }; @@ -107,6 +105,8 @@ export function InspectedElementContextController({ parseHookNamesByDefault || alreadyLoadedHookNames, ); + const [bridgeIsAlive, setBridgeIsAliveStatus] = useState(true); + const elementHasChanged = element !== null && element !== state.element; // Reset the cached inspected paths when a new element is selected. @@ -213,16 +213,34 @@ export function InspectedElementContextController({ } }, [state]); + useEffect(() => { + // Assuming that new bridge is always alive at this moment + setBridgeIsAliveStatus(true); + + const listener = () => setBridgeIsAliveStatus(false); + bridge.addListener('shutdown', listener); + + return () => bridge.removeListener('shutdown', listener); + }, [bridge]); + // Periodically poll the selected element for updates. useEffect(() => { - if (element !== null) { - const checkForUpdateWrapper = () => { - checkForUpdate({bridge, element, refresh, store}); - timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL); - }; - let timeoutID = setTimeout(checkForUpdateWrapper, POLL_INTERVAL); + if (element !== null && bridgeIsAlive) { + const {abort, pause, resume} = startElementUpdatesPolling({ + bridge, + element, + refresh, + store, + }); + + bridge.addListener('resumeElementPolling', resume); + bridge.addListener('pauseElementPolling', pause); + return () => { - clearTimeout(timeoutID); + bridge.removeListener('resumeElementPolling', resume); + bridge.removeListener('pauseElementPolling', pause); + + abort(); }; } }, [ @@ -232,6 +250,7 @@ export function InspectedElementContextController({ // No sense to ping right away after e.g. inspecting/hydrating a path. inspectedElement, state, + bridgeIsAlive, ]); const value = useMemo( 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 ( diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js index 023116060e62a..99a5ab2d103f0 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js +++ b/packages/react-devtools-shared/src/devtools/views/Settings/ComponentsSettings.js @@ -16,7 +16,10 @@ import { useRef, useState, } from 'react'; -import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants'; +import { + LOCAL_STORAGE_OPEN_IN_EDITOR_URL, + LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, +} from '../../../constants'; import {useLocalStorage, useSubscription} from '../hooks'; import {StoreContext} from '../context'; import Button from '../Button'; @@ -51,6 +54,8 @@ import type { RegExpComponentFilter, } from 'react-devtools-shared/src/types'; +const vscodeFilepath = 'vscode://file/{path}:{line}'; + export default function ComponentsSettings(_: {}): React.Node { const store = useContext(StoreContext); const {parseHookNames, setParseHookNames} = useContext(SettingsContext); @@ -83,6 +88,10 @@ export default function ComponentsSettings(_: {}): React.Node { [setParseHookNames], ); + const [openInEditorURLPreset, setOpenInEditorURLPreset] = useLocalStorage< + 'vscode' | 'custom', + >(LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, 'custom'); + const [openInEditorURL, setOpenInEditorURL] = useLocalStorage( LOCAL_STORAGE_OPEN_IN_EDITOR_URL, getDefaultOpenInEditorURL(), @@ -209,6 +218,10 @@ export default function ComponentsSettings(_: {}): React.Node { }); }, []); + const removeAllFilter = () => { + setComponentFilters([]); + }; + const toggleFilterIsEnabled = useCallback( (componentFilter: ComponentFilter, isEnabled: boolean) => { setComponentFilters(prevComponentFilters => { @@ -280,15 +293,32 @@ export default function ComponentsSettings(_: {}): React.Node {
Hide components where...
@@ -408,11 +438,16 @@ export default function ComponentsSettings(_: {}): React.Node { ))} - - + {componentFilters.length > 0 && ( + + )} ); } diff --git a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css index de0b8da9e6f0f..c803f611908f4 100644 --- a/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css +++ b/packages/react-devtools-shared/src/devtools/views/Settings/SettingsShared.css @@ -88,6 +88,7 @@ border: 1px solid var(--color-border); border-radius: 0.125rem; padding: 0.125rem; + margin-left: .5rem; } .InvalidRegExp, diff --git a/packages/react-devtools-shared/src/errors/ElementPollingCancellationError.js b/packages/react-devtools-shared/src/errors/ElementPollingCancellationError.js new file mode 100644 index 0000000000000..c30a9f77a58b1 --- /dev/null +++ b/packages/react-devtools-shared/src/errors/ElementPollingCancellationError.js @@ -0,0 +1,21 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export default class ElementPollingCancellationError extends Error { + constructor() { + super(); + + // Maintains proper stack trace for where our error was thrown (only available on V8) + if (Error.captureStackTrace) { + Error.captureStackTrace(this, ElementPollingCancellationError); + } + + this.name = 'ElementPollingCancellationError'; + } +} diff --git a/packages/react-devtools-shared/src/hook.js b/packages/react-devtools-shared/src/hook.js index 39af7daa374ce..4df992e23ea61 100644 --- a/packages/react-devtools-shared/src/hook.js +++ b/packages/react-devtools-shared/src/hook.js @@ -320,7 +320,7 @@ export function installHook(target: any): DevToolsHook | null { let uidCounter = 0; - function inject(renderer: ReactRenderer) { + function inject(renderer: ReactRenderer): number { const id = ++uidCounter; renderers.set(id, renderer); diff --git a/packages/react-devtools-shared/src/inspectedElementCache.js b/packages/react-devtools-shared/src/inspectedElementCache.js index f0fb6c542ef32..bfa9fb1092406 100644 --- a/packages/react-devtools-shared/src/inspectedElementCache.js +++ b/packages/react-devtools-shared/src/inspectedElementCache.js @@ -11,8 +11,9 @@ import { unstable_getCacheForType as getCacheForType, startTransition, } from 'react'; -import Store from './devtools/store'; -import {inspectElement as inspectElementMutableSource} from './inspectedElementMutableSource'; +import Store from 'react-devtools-shared/src/devtools/store'; +import {inspectElement as inspectElementMutableSource} from 'react-devtools-shared/src/inspectedElementMutableSource'; +import ElementPollingCancellationError from 'react-devtools-shared/src//errors/ElementPollingCancellationError'; import type {FrontendBridge} from 'react-devtools-shared/src/bridge'; import type {Wakeable} from 'shared/ReactTypes'; @@ -177,35 +178,111 @@ export function checkForUpdate({ element: Element, refresh: RefreshFunction, store: Store, -}): void { +}): void | Promise { const {id} = element; const rendererID = store.getRendererIDForElement(id); - if (rendererID != null) { - inspectElementMutableSource({ - bridge, - element, - path: null, - rendererID: ((rendererID: any): number), - }).then( - ([inspectedElement, responseType]: [ - InspectedElementFrontend, - InspectedElementResponseType, - ]) => { - if (responseType === 'full-data') { - startTransition(() => { - const [key, value] = createCacheSeed(element, inspectedElement); - refresh(key, value); - }); + + if (rendererID == null) { + return; + } + + return inspectElementMutableSource({ + bridge, + element, + path: null, + rendererID, + }).then( + ([inspectedElement, responseType]: [ + InspectedElementFrontend, + InspectedElementResponseType, + ]) => { + if (responseType === 'full-data') { + startTransition(() => { + const [key, value] = createCacheSeed(element, inspectedElement); + refresh(key, value); + }); + } + }, + ); +} + +function createPromiseWhichResolvesInOneSecond() { + return new Promise(resolve => setTimeout(resolve, 1000)); +} + +type PollingStatus = 'idle' | 'running' | 'paused' | 'aborted'; + +export function startElementUpdatesPolling({ + bridge, + element, + refresh, + store, +}: { + bridge: FrontendBridge, + element: Element, + refresh: RefreshFunction, + store: Store, +}): {abort: () => void, pause: () => void, resume: () => void} { + let status: PollingStatus = 'idle'; + + function abort() { + status = 'aborted'; + } + + function resume() { + if (status === 'running' || status === 'aborted') { + return; + } + + status = 'idle'; + poll(); + } + + function pause() { + if (status === 'paused' || status === 'aborted') { + return; + } + + status = 'paused'; + } + + function poll(): Promise { + status = 'running'; + + return Promise.allSettled([ + checkForUpdate({bridge, element, refresh, store}), + createPromiseWhichResolvesInOneSecond(), + ]) + .then(([{status: updateStatus, reason}]) => { + // There isn't much to do about errors in this case, + // but we should at least log them, so they aren't silent. + // Log only if polling is still active, we can't handle the case when + // request was sent, and then bridge was remounted (for example, when user did navigate to a new page), + // but at least we can mark that polling was aborted + if (updateStatus === 'rejected' && status !== 'aborted') { + // This is expected Promise rejection, no need to log it + if (reason instanceof ElementPollingCancellationError) { + return; + } + + console.error(reason); } - }, + }) + .finally(() => { + const shouldContinuePolling = + status !== 'aborted' && status !== 'paused'; - // There isn't much to do about errors in this case, - // but we should at least log them so they aren't silent. - error => { - console.error(error); - }, - ); + status = 'idle'; + + if (shouldContinuePolling) { + return poll(); + } + }); } + + poll(); + + return {abort, resume, pause}; } export function clearCacheBecauseOfError(refresh: RefreshFunction): void { diff --git a/packages/react-devtools-timeline/package.json b/packages/react-devtools-timeline/package.json index 970edc5496b11..6ae9e6bdbd813 100644 --- a/packages/react-devtools-timeline/package.json +++ b/packages/react-devtools-timeline/package.json @@ -1,7 +1,7 @@ { "private": true, "name": "react-devtools-timeline", - "version": "4.28.0", + "version": "4.28.4", "license": "MIT", "dependencies": { "@elg/speedscope": "1.9.0-a6f84db", diff --git a/packages/react-devtools/CHANGELOG.md b/packages/react-devtools/CHANGELOG.md index e0c16ea03f088..c503a95712c17 100644 --- a/packages/react-devtools/CHANGELOG.md +++ b/packages/react-devtools/CHANGELOG.md @@ -4,6 +4,58 @@ --- +### 4.28.4 +September 25, 2023 + +#### Features +* feat:-Added a delete all filters action and added title to the add filter a… ([Biki-das](https://github.com/Biki-das) in [#27332](https://github.com/facebook/react/pull/27332)) +* feat[devtools/extension]: add dark theme for popup ([rakleed](https://github.com/rakleed) in [#27330](https://github.com/facebook/react/pull/27330)) + +#### Bugfixes +* refactor[devtools/extension]: refactored messaging logic across different parts of the extension ([hoxyq](https://github.com/hoxyq) in [#27417](https://github.com/facebook/react/pull/27417)) +* fix[devtools/extension]: added a workaround for proxy content script injection in firefox ([hoxyq](https://github.com/hoxyq) in [#27375](https://github.com/facebook/react/pull/27375)) +* fix[devtools/useTransition]: don't check for dispatch property when determining if hook is stateful ([hoxyq](https://github.com/hoxyq) in [#27365](https://github.com/facebook/react/pull/27365)) +* feat[devtools/extension]: show disclaimer when page doesnt run react and refactor react polling logic ([hoxyq](https://github.com/hoxyq) in [#27373](https://github.com/facebook/react/pull/27373)) +* fix[devtools/extension]: unregister dynamically injected content scripts instead of filtering ([hoxyq](https://github.com/hoxyq) in [#27369](https://github.com/facebook/react/pull/27369)) +* refactor[devtools/extension]: more stable element updates polling to avoid timed out errors ([hoxyq](https://github.com/hoxyq) in [#27357](https://github.com/facebook/react/pull/27357)) + +--- + +### 4.28.3 +September 5, 2023 + +* refactor[devtools/extension]: handle ports disconnection, instead of frequent reconnection ([hoxyq](https://github.com/hoxyq) in [#27336](https://github.com/facebook/react/pull/27336)) +* refactor[devtools/extension]: migrate from using setInterval for polling if react is loaded ([hoxyq](https://github.com/hoxyq) in [#27323](https://github.com/facebook/react/pull/27323)) +* fix[devtools/extension]: fixed duplicating panels in firefox ([hoxyq](https://github.com/hoxyq) in [#27320](https://github.com/facebook/react/pull/27320)) + +--- + +### 4.28.2 +August 30, 2023 + +#### Bugfixes +* fix[devtools/extension]: handle tab navigation events before react is loaded ([hoxyq](https://github.com/hoxyq) in [#27316](https://github.com/facebook/react/pull/27316)) + +--- + +### 4.28.1 +August 29, 2023 + +#### Features +* feat: added open in editor to appear by default ([Biki-das](https://github.com/Biki-das) in [#26949](https://github.com/facebook/react/pull/26949)) + +#### Bugfixes +* refactor: refactored devtools browser extension scripts to improve port management and service worker lifetime ([hoxyq](https://github.com/hoxyq) in [#27215](https://github.com/facebook/react/pull/27215)) +* fix[devtools]: allow element updates polling only if bridge is alive ([hoxyq](https://github.com/hoxyq) in [#27067](https://github.com/facebook/react/pull/27067)) +* refactor: resolve browser via env variables based on build rather than user agent ([hoxyq](https://github.com/hoxyq) in [#27179](https://github.com/facebook/react/pull/27179)) +* fix[devtools/updateFiberRecursively]: mount suspense fallback set in timed out case ([hoxyq](https://github.com/hoxyq) in [#27147](https://github.com/facebook/react/pull/27147)) +* fix[devtools/inspect]: null check memoized props before trying to call hasOwnProperty ([hoxyq](https://github.com/hoxyq) in [#27057](https://github.com/facebook/react/pull/27057)) + +#### Other +* refactor[devtools/extension]: minify production builds to strip comments ([hoxyq](https://github.com/hoxyq) in [#27304](https://github.com/facebook/react/pull/27304)) + +--- + ### 4.28.0 July 4, 2023 diff --git a/packages/react-devtools/package.json b/packages/react-devtools/package.json index f2bef3b12afbf..577a98ac3d1c9 100644 --- a/packages/react-devtools/package.json +++ b/packages/react-devtools/package.json @@ -1,6 +1,6 @@ { "name": "react-devtools", - "version": "4.28.0", + "version": "4.28.4", "description": "Use react-devtools outside of the browser", "license": "MIT", "repository": { @@ -27,7 +27,7 @@ "electron": "^23.1.2", "ip": "^1.1.4", "minimist": "^1.2.3", - "react-devtools-core": "4.28.0", + "react-devtools-core": "4.28.4", "update-notifier": "^2.1.0" } } diff --git a/packages/react-dom-bindings/src/client/CSSPropertyOperations.js b/packages/react-dom-bindings/src/client/CSSPropertyOperations.js index 521f4c67fc66b..55fd43ec479d0 100644 --- a/packages/react-dom-bindings/src/client/CSSPropertyOperations.js +++ b/packages/react-dom-bindings/src/client/CSSPropertyOperations.js @@ -11,7 +11,6 @@ import hyphenateStyleName from '../shared/hyphenateStyleName'; import warnValidStyle from '../shared/warnValidStyle'; import isUnitlessNumber from '../shared/isUnitlessNumber'; import {checkCSSPropertyStringCoercion} from 'shared/CheckStringCoercion'; -import {diffInCommitPhase} from 'shared/ReactFeatureFlags'; /** * Operations for dealing with CSS properties. @@ -126,7 +125,7 @@ export function setValueForStyles(node, styles, prevStyles) { const style = node.style; - if (diffInCommitPhase && prevStyles != null) { + if (prevStyles != null) { if (__DEV__) { validateShorthandPropertyCollisionInDev(prevStyles, styles); } @@ -200,10 +199,7 @@ function expandShorthandMap(styles) { * {font: 'foo', fontVariant: 'bar'} -> {font: 'foo'} * becomes .style.fontVariant = '' */ -export function validateShorthandPropertyCollisionInDev( - prevStyles, - nextStyles, -) { +function validateShorthandPropertyCollisionInDev(prevStyles, nextStyles) { if (__DEV__) { if (!nextStyles) { return; diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 6454ee7c2d424..cbd51de23412d 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -55,7 +55,6 @@ import setTextContent from './setTextContent'; import { createDangerousStringForStyles, setValueForStyles, - validateShorthandPropertyCollisionInDev, } from './CSSPropertyOperations'; import {SVG_NAMESPACE, MATH_NAMESPACE} from './DOMNamespaces'; import isCustomElement from '../shared/isCustomElement'; @@ -74,7 +73,6 @@ import { disableIEWorkarounds, enableTrustedTypesIntegration, enableFilterEmptyStringAttributesDOM, - diffInCommitPhase, } from 'shared/ReactFeatureFlags'; import { mediaEventTypes, @@ -501,7 +499,7 @@ function setProp( // eslint-disable-next-line no-script-url "javascript:throw new Error('" + 'A React form was unexpectedly submitted. If you called form.submit() manually, ' + - "consider using form.requestSubmit() instead. If you're trying to use " + + "consider using form.requestSubmit() instead. If you\\'re trying to use " + 'event.stopPropagation() in a submit event handler, consider also calling ' + 'event.preventDefault().' + "')", @@ -1341,119 +1339,6 @@ export function setInitialProperties( } } -// Calculate the diff between the two objects. -export function diffProperties( - domElement: Element, - tag: string, - lastProps: Object, - nextProps: Object, -): null | Array { - if (__DEV__) { - validatePropertiesInDevelopment(tag, nextProps); - } - - let updatePayload: null | Array = null; - - let propKey; - let styleName; - let styleUpdates = null; - for (propKey in lastProps) { - if ( - nextProps.hasOwnProperty(propKey) || - !lastProps.hasOwnProperty(propKey) || - lastProps[propKey] == null - ) { - continue; - } - switch (propKey) { - case 'style': { - const lastStyle = lastProps[propKey]; - for (styleName in lastStyle) { - if (lastStyle.hasOwnProperty(styleName)) { - if (!styleUpdates) { - styleUpdates = ({}: {[string]: $FlowFixMe}); - } - styleUpdates[styleName] = ''; - } - } - break; - } - default: { - // For all other deleted properties we add it to the queue. We use - // the allowed property list in the commit phase instead. - (updatePayload = updatePayload || []).push(propKey, null); - } - } - } - for (propKey in nextProps) { - const nextProp = nextProps[propKey]; - const lastProp = lastProps != null ? lastProps[propKey] : undefined; - if ( - nextProps.hasOwnProperty(propKey) && - nextProp !== lastProp && - (nextProp != null || lastProp != null) - ) { - switch (propKey) { - case 'style': { - if (lastProp) { - // Unset styles on `lastProp` but not on `nextProp`. - for (styleName in lastProp) { - if ( - lastProp.hasOwnProperty(styleName) && - (!nextProp || !nextProp.hasOwnProperty(styleName)) - ) { - if (!styleUpdates) { - styleUpdates = ({}: {[string]: string}); - } - styleUpdates[styleName] = ''; - } - } - // Update styles that changed since `lastProp`. - for (styleName in nextProp) { - if ( - nextProp.hasOwnProperty(styleName) && - lastProp[styleName] !== nextProp[styleName] - ) { - if (!styleUpdates) { - styleUpdates = ({}: {[string]: $FlowFixMe}); - } - styleUpdates[styleName] = nextProp[styleName]; - } - } - } else { - // Relies on `updateStylesByID` not mutating `styleUpdates`. - if (!styleUpdates) { - if (!updatePayload) { - updatePayload = []; - } - updatePayload.push(propKey, styleUpdates); - } - styleUpdates = nextProp; - } - break; - } - case 'is': - if (__DEV__) { - console.error( - 'Cannot update the "is" prop after it has been initialized.', - ); - } - // Fall through - default: { - (updatePayload = updatePayload || []).push(propKey, nextProp); - } - } - } - } - if (styleUpdates) { - if (__DEV__) { - validateShorthandPropertyCollisionInDev(lastProps.style, nextProps.style); - } - (updatePayload = updatePayload || []).push('style', styleUpdates); - } - return updatePayload; -} - export function updateProperties( domElement: Element, tag: string, @@ -1924,305 +1809,6 @@ export function updateProperties( } } -// Apply the diff. -export function updatePropertiesWithDiff( - domElement: Element, - updatePayload: Array, - tag: string, - lastProps: Object, - nextProps: Object, -): void { - switch (tag) { - case 'div': - case 'span': - case 'svg': - case 'path': - case 'a': - case 'g': - case 'p': - case 'li': { - // Fast track the most common tag types - break; - } - case 'input': { - const name = nextProps.name; - const type = nextProps.type; - const value = nextProps.value; - const defaultValue = nextProps.defaultValue; - const lastDefaultValue = lastProps.defaultValue; - const checked = nextProps.checked; - const defaultChecked = nextProps.defaultChecked; - for (let i = 0; i < updatePayload.length; i += 2) { - const propKey = updatePayload[i]; - const propValue = updatePayload[i + 1]; - switch (propKey) { - case 'type': { - break; - } - case 'name': { - break; - } - case 'checked': { - break; - } - case 'defaultChecked': { - break; - } - case 'value': { - break; - } - case 'defaultValue': { - break; - } - case 'children': - case 'dangerouslySetInnerHTML': { - if (propValue != null) { - throw new Error( - `${tag} is a void element tag and must neither have \`children\` nor ` + - 'use `dangerouslySetInnerHTML`.', - ); - } - break; - } - default: { - setProp( - domElement, - tag, - propKey, - propValue, - nextProps, - lastProps[propKey], - ); - } - } - } - - if (__DEV__) { - const wasControlled = - lastProps.type === 'checkbox' || lastProps.type === 'radio' - ? lastProps.checked != null - : lastProps.value != null; - const isControlled = - nextProps.type === 'checkbox' || nextProps.type === 'radio' - ? nextProps.checked != null - : nextProps.value != null; - - if ( - !wasControlled && - isControlled && - !didWarnUncontrolledToControlled - ) { - console.error( - 'A component is changing an uncontrolled input to be controlled. ' + - 'This is likely caused by the value changing from undefined to ' + - 'a defined value, which should not happen. ' + - 'Decide between using a controlled or uncontrolled input ' + - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', - ); - didWarnUncontrolledToControlled = true; - } - if ( - wasControlled && - !isControlled && - !didWarnControlledToUncontrolled - ) { - console.error( - 'A component is changing a controlled input to be uncontrolled. ' + - 'This is likely caused by the value changing from a defined to ' + - 'undefined, which should not happen. ' + - 'Decide between using a controlled or uncontrolled input ' + - 'element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components', - ); - didWarnControlledToUncontrolled = true; - } - } - - // Update the wrapper around inputs *after* updating props. This has to - // happen after updating the rest of props. Otherwise HTML5 input validations - // raise warnings and prevent the new value from being assigned. - updateInput( - domElement, - value, - defaultValue, - lastDefaultValue, - checked, - defaultChecked, - type, - name, - ); - return; - } - case 'select': { - const value = nextProps.value; - const defaultValue = nextProps.defaultValue; - const multiple = nextProps.multiple; - const wasMultiple = lastProps.multiple; - for (let i = 0; i < updatePayload.length; i += 2) { - const propKey = updatePayload[i]; - const propValue = updatePayload[i + 1]; - switch (propKey) { - case 'value': { - // This is handled by updateWrapper below. - break; - } - // defaultValue are ignored by setProp - default: { - setProp( - domElement, - tag, - propKey, - propValue, - nextProps, - lastProps[propKey], - ); - } - } - } - //