refactor: refactored devtools browser extension scripts to improve port management and service worker lifetime (#27215)

Fixes https://github.com/facebook/react/issues/27119,
https://github.com/facebook/react/issues/27185.

Fixed:
- React DevTools now works as expected when user performs in-tab
navigation, previously it was just stuck.


https://github.com/facebook/react/assets/28902667/b11c5f84-7155-47a5-8b5a-7e90baca5347

- When user closes browser DevTools panel, we now do some cleanup to
disconnect ports and emit shutdown event for bridge. This should fix the
issue with registering duplicated fibers with the same id in Store.

Changed:
- We reconnect proxy port once in 25 seconds, in order to [keep service
worker
alive](https://developer.chrome.com/docs/extensions/whatsnew/#m110-sw-idle).
- Instead of unregistering dynamically injected content scripts, wen now
get list of already registered scripts and filter them out from scripts
that we want to inject again, see dynamicallyInjectContentScripts.js.
- Split `main.js` and `background.js` into multiple files.

Tested on Chromium and Firefox browsers.
This commit is contained in:
Ruslan Lesiutin
2023-08-29 12:09:26 +01:00
committed by GitHub
parent f47956239f
commit 8fbd307942
22 changed files with 1159 additions and 847 deletions
@@ -6,7 +6,7 @@
"applications": {
"gecko": {
"id": "@react-devtools",
"strict_min_version": "55.0"
"strict_min_version": "102.0"
}
},
"icons": {
@@ -41,7 +41,9 @@
"file:///*",
"http://*/*",
"https://*/*",
"clipboardWrite"
"clipboardWrite",
"scripting",
"devtools"
],
"content_scripts": [
{
-245
View File
@@ -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: ['<all_urls>'],
js: ['build/installHook.js'],
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: 'renderer',
matches: ['<all_urls>'],
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,
});
}
}
}
});
@@ -0,0 +1,53 @@
/* global chrome */
import {IS_FIREFOX} from '../utils';
async function dynamicallyInjectContentScripts() {
const contentScriptsToInject = [
{
id: '@react-devtools/hook',
js: ['build/installHook.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
{
id: '@react-devtools/renderer',
js: ['build/renderer.js'],
matches: ['<all_urls>'],
persistAcrossSessions: true,
runAt: 'document_start',
world: chrome.scripting.ExecutionWorld.MAIN,
},
];
try {
const alreadyRegisteredContentScripts =
await chrome.scripting.getRegisteredContentScripts();
const scriptsToInjectNow = contentScriptsToInject.filter(
scriptToInject =>
!alreadyRegisteredContentScripts.some(
registeredScript => registeredScript.id === scriptToInject.id,
),
);
if (scriptsToInjectNow.length) {
// equivalent logic for Firefox is in prepareInjection.js
// Manifest V3 method of injecting content script
// TODO(hoxyq): migrate Firefox to V3 manifests
// Note: the "world" option in registerContentScripts is only available in Chrome v102+
// It's critical since it allows us to directly run scripts on the "main" world on the page
// "document_start" allows it to run before the page's scripts
// so the hook can be detected by react reconciler
await chrome.scripting.registerContentScripts(scriptsToInjectNow);
}
} catch (error) {
console.error(error);
}
}
if (!IS_FIREFOX) {
dynamicallyInjectContentScripts();
}
@@ -0,0 +1,257 @@
/* global chrome */
'use strict';
import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
import './dynamicallyInjectContentScripts';
import './tabsManager';
import setExtensionIconAndPopup from './setExtensionIconAndPopup';
import injectProxy from './injectProxy';
/*
{
[tabId]: {
extension: ExtensionPort,
proxy: ProxyPort,
disconnectPipe: Function,
},
...
}
*/
const ports = {};
function registerTab(tabId) {
if (!ports[tabId]) {
ports[tabId] = {
extension: null,
proxy: null,
disconnectPipe: null,
};
}
}
function registerExtensionPort(port, tabId) {
ports[tabId].extension = port;
port.onDisconnect.addListener(() => {
// This should delete disconnectPipe from ports dictionary
ports[tabId].disconnectPipe?.();
delete ports[tabId].extension;
const proxyPort = ports[tabId].proxy;
if (proxyPort) {
// Do not disconnect proxy port, we will inject this content script again
// If extension port has disconnected, it probably means that user did in-tab navigation
clearReconnectionTimeout(proxyPort);
proxyPort.postMessage({
source: 'react-devtools-service-worker',
stop: true,
});
}
});
}
function registerProxyPort(port, tabId) {
ports[tabId].proxy = port;
// In case proxy port was disconnected from the other end, from content script
// This can happen if content script was detached, when user does in-tab navigation
// Or if when we notify proxy port to stop reconnecting, when extension port dies
// This listener should never be called when we call port.shutdown() from this (background/index.js) script
port.onDisconnect.addListener(() => {
ports[tabId].disconnectPipe?.();
delete ports[tabId].proxy;
});
port._reconnectionTimeoutId = setTimeout(
reconnectProxyPort,
25_000,
port,
tabId,
);
}
function clearReconnectionTimeout(port) {
if (port._reconnectionTimeoutId) {
clearTimeout(port._reconnectionTimeoutId);
delete port._reconnectionTimeoutId;
}
}
function reconnectProxyPort(port, tabId) {
// IMPORTANT: port.onDisconnect will only be emitted if disconnect() was called from the other end
// We need to do it manually here if we disconnect proxy port from service worker
ports[tabId].disconnectPipe?.();
// It should be reconnected automatically by proxy content script, look at proxy.js
port.disconnect();
}
function isNumeric(str: string): boolean {
return +str + '' === str;
}
chrome.runtime.onConnect.addListener(async port => {
if (port.name === 'proxy') {
// Proxy content script is executed in tab, so it should have it specified.
const tabId = port.sender.tab.id;
registerTab(tabId);
registerProxyPort(port, tabId);
connectExtensionAndProxyPorts(
ports[tabId].extension,
ports[tabId].proxy,
tabId,
);
return;
}
if (isNumeric(port.name)) {
// Extension port doesn't have tab id specified, because its sender is the extension.
const tabId = +port.name;
registerTab(tabId);
registerExtensionPort(port, tabId);
injectProxy(tabId);
return;
}
// I am not sure if we should throw here
console.warn(`Unknown port ${port.name} connected`);
});
function connectExtensionAndProxyPorts(extensionPort, proxyPort, tabId) {
if (!extensionPort) {
throw new Error(
`Attempted to connect ports, when extension port is not present`,
);
}
if (!proxyPort) {
throw new Error(
`Attempted to connect ports, when proxy port is not present`,
);
}
if (ports[tabId].disconnectPipe) {
throw new Error(
`Attempted to connect already connected ports for tab with id ${tabId}`,
);
}
function extensionPortMessageListener(message) {
try {
proxyPort.postMessage(message);
} catch (e) {
if (__DEV__) {
console.log(`Broken pipe ${tabId}: `, e);
}
disconnectListener();
}
}
function proxyPortMessageListener(message) {
try {
extensionPort.postMessage(message);
} catch (e) {
if (__DEV__) {
console.log(`Broken pipe ${tabId}: `, e);
}
disconnectListener();
}
}
function disconnectListener() {
extensionPort.onMessage.removeListener(extensionPortMessageListener);
proxyPort.onMessage.removeListener(proxyPortMessageListener);
// We handle disconnect() calls manually, based on each specific case
// No need to disconnect other port here
delete ports[tabId].disconnectPipe;
}
ports[tabId].disconnectPipe = disconnectListener;
extensionPort.onMessage.addListener(extensionPortMessageListener);
proxyPort.onMessage.addListener(proxyPortMessageListener);
extensionPort.onDisconnect.addListener(disconnectListener);
proxyPort.onDisconnect.addListener(disconnectListener);
}
chrome.runtime.onMessage.addListener((message, sender) => {
const tab = sender.tab;
// sender.tab.id from content script points to the tab that injected the content script
if (tab) {
const id = tab.id;
// This is sent from the hook content script.
// It tells us a renderer has attached.
if (message.hasDetectedReact) {
setExtensionIconAndPopup(message.reactBuildType, id);
} else {
const extensionPort = ports[id]?.extension;
switch (message.payload?.type) {
case 'fetch-file-with-cache-complete':
case 'fetch-file-with-cache-error':
// Forward the result of fetch-in-page requests back to the extension.
extensionPort?.postMessage(message);
break;
// This is sent from the backend manager running on a page
case 'react-devtools-required-backends':
const backendsToDownload = [];
message.payload.versions.forEach(version => {
if (EXTENSION_CONTAINED_VERSIONS.includes(version)) {
if (!IS_FIREFOX) {
// equivalent logic for Firefox is in prepareInjection.js
chrome.scripting.executeScript({
target: {tabId: id},
files: [`/build/react_devtools_backend_${version}.js`],
world: chrome.scripting.ExecutionWorld.MAIN,
});
}
} else {
backendsToDownload.push(version);
}
});
// Request the necessary backends in the extension DevTools UI
// TODO: handle this message in index.js to build the UI
extensionPort?.postMessage({
payload: {
type: 'react-devtools-additional-backends',
versions: backendsToDownload,
},
});
break;
}
}
}
// This is sent from the devtools page when it is ready for injecting the backend
if (message?.payload?.type === 'react-devtools-inject-backend-manager') {
// sender.tab.id from devtools page may not exist, or point to the undocked devtools window
// so we use the payload to get the tab id
const tabId = message.payload.tabId;
if (tabId && !IS_FIREFOX) {
// equivalent logic for Firefox is in prepareInjection.js
chrome.scripting.executeScript({
target: {tabId},
files: ['/build/backendManager.js'],
world: chrome.scripting.ExecutionWorld.MAIN,
});
}
}
});
@@ -0,0 +1,12 @@
/* global chrome */
// We keep this logic in background, because Firefox doesn't allow using these APIs
// from extension page script
function injectProxy(tabId: number) {
chrome.scripting.executeScript({
target: {tabId},
files: ['/build/proxy.js'],
});
}
export default injectProxy;
@@ -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;
@@ -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);
}
});
@@ -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;
@@ -5,7 +5,7 @@ import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/
import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
// We run scripts on the page via the service worker (backgroud.js) for
// We run scripts on the page via the service worker (background/index.js) for
// Manifest V3 extensions (Chrome & Edge).
// We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN
// https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
@@ -2,9 +2,23 @@
'use strict';
let backendDisconnected: boolean = false;
let port = null;
let backendInitialized: boolean = false;
connectPort();
sayHelloToBackendManager();
// The backend waits to install the global hook until notified by the content script.
// In the event of a page reload, the content script might be loaded before the backend manager is injected.
// Because of this we need to poll the backend manager until it has been initialized.
const intervalID = setInterval(() => {
if (backendInitialized) {
clearInterval(intervalID);
} else {
sayHelloToBackendManager();
}
}, 500);
function sayHelloToBackendManager() {
window.postMessage(
{
@@ -16,6 +30,18 @@ function sayHelloToBackendManager() {
}
function handleMessageFromDevtools(message) {
if (message.source === 'react-devtools-service-worker' && message.stop) {
window.removeEventListener('message', handleMessageFromPage);
// Calling disconnect here should not emit onDisconnect event inside this script
// This port will not attempt to reconnect again
// It will connect only once this content script will be injected again
port?.disconnect();
port = null;
return;
}
window.postMessage(
{
source: 'react-devtools-content-script',
@@ -33,6 +59,7 @@ function handleMessageFromPage(event) {
port.postMessage(event.data.payload);
}
// This is a message from the backend manager
if (event.data.source === 'react-devtools-backend-manager') {
chrome.runtime.sendMessage({
@@ -43,42 +70,21 @@ function handleMessageFromPage(event) {
}
function handleDisconnect() {
backendDisconnected = true;
window.removeEventListener('message', handleMessageFromPage);
port = null;
window.postMessage(
{
source: 'react-devtools-content-script',
payload: {
type: 'event',
event: 'shutdown',
},
},
'*',
);
connectPort();
}
// proxy from main page to devtools (via the background page)
const port = chrome.runtime.connect({
name: 'content-script',
});
port.onMessage.addListener(handleMessageFromDevtools);
port.onDisconnect.addListener(handleDisconnect);
// Creates port from application page to the React DevTools' service worker
// Which then connects it with extension port
function connectPort() {
port = chrome.runtime.connect({
name: 'proxy',
});
window.addEventListener('message', handleMessageFromPage);
window.addEventListener('message', handleMessageFromPage);
sayHelloToBackendManager();
// The backend waits to install the global hook until notified by the content script.
// In the event of a page reload, the content script might be loaded before the backend manager is injected.
// Because of this we need to poll the backend manager until it has been initialized.
if (!backendInitialized) {
const intervalID = setInterval(() => {
if (backendInitialized || backendDisconnected) {
clearInterval(intervalID);
} else {
sayHelloToBackendManager();
}
}, 500);
port.onMessage.addListener(handleMessageFromDevtools);
port.onDisconnect.addListener(handleDisconnect);
}
-562
View File
@@ -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<any>) {
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();
@@ -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;
@@ -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');
}
},
);
}
@@ -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;
+525
View File
@@ -0,0 +1,525 @@
/* global chrome */
import {createElement} from 'react';
import {flushSync} from 'react-dom';
import {createRoot} from 'react-dom/client';
import Bridge from 'react-devtools-shared/src/bridge';
import Store from 'react-devtools-shared/src/devtools/store';
import {IS_CHROME, IS_EDGE, getBrowserTheme, IS_FIREFOX} from '../utils';
import {
localStorageGetItem,
localStorageSetItem,
} from 'react-devtools-shared/src/storage';
import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
import {
__DEBUG__,
LOCAL_STORAGE_SUPPORTS_PROFILING_KEY,
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
} from 'react-devtools-shared/src/constants';
import {logEvent} from 'react-devtools-shared/src/Logger';
import {
setBrowserSelectionFromReact,
setReactSelectionFromBrowser,
} from './elementSelection';
import cloneStyleTags from './cloneStyleTags';
import injectBackendManager from './injectBackendManager';
import syncSavedPreferences from './syncSavedPreferences';
import registerEventsLogger from './registerEventsLogger';
import getProfilingFlags from './getProfilingFlags';
import './requestAnimationFramePolyfill';
function executeIfReactHasLoaded(callback) {
chrome.devtools.inspectedWindow.eval(
'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
(pageHasReact, exceptionInfo) => {
if (exceptionInfo) {
const {code, description, isError, isException, value} = exceptionInfo;
if (isException) {
console.error(
`Received error while checking if react has loaded: ${value}`,
);
return;
}
if (isError) {
console.error(
`Received error with code ${code} while checking if react has loaded: ${description}`,
);
return;
}
}
if (pageHasReact) {
callback();
}
},
);
}
function createBridge() {
bridge = new Bridge({
listen(fn) {
const listener = message => fn(message);
// Store the reference so that we unsubscribe from the same object.
const portOnMessage = port.onMessage;
portOnMessage.addListener(listener);
return () => {
portOnMessage.removeListener(listener);
};
},
send(event: string, payload: any, transferable?: Array<any>) {
port.postMessage({event, payload}, transferable);
},
});
bridge.addListener('reloadAppForProfiling', () => {
localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
chrome.devtools.inspectedWindow.eval('window.location.reload();');
});
bridge.addListener(
'syncSelectionToNativeElementsPanel',
setBrowserSelectionFromReact,
);
bridge.addListener('extensionBackendInitialized', () => {
// Initialize the renderer's trace-updates setting.
// This handles the case of navigating to a new page after the DevTools have already been shown.
bridge.send(
'setTraceUpdatesEnabled',
localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === 'true',
);
});
const onBrowserElementSelectionChanged = () =>
setReactSelectionFromBrowser(bridge);
const onBridgeShutdown = () => {
chrome.devtools.panels.elements.onSelectionChanged.removeListener(
onBrowserElementSelectionChanged,
);
};
bridge.addListener('shutdown', onBridgeShutdown);
chrome.devtools.panels.elements.onSelectionChanged.addListener(
onBrowserElementSelectionChanged,
);
}
function createBridgeAndStore() {
createBridge();
const {isProfiling, supportsProfiling} = getProfilingFlags();
store = new Store(bridge, {
isProfiling,
supportsReloadAndProfile: IS_CHROME || IS_EDGE,
supportsProfiling,
// At this time, the timeline can only parse Chrome performance profiles.
supportsTimeline: IS_CHROME,
supportsTraceUpdates: true,
});
if (!isProfiling) {
// We previously stored this in performCleanup function
store.profilerStore.profilingData = profilingData;
}
// Initialize the backend only once the Store has been initialized.
// Otherwise, the Store may miss important initial tree op codes.
injectBackendManager(chrome.devtools.inspectedWindow.tabId);
const viewAttributeSourceFunction = (id, path) => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
// Ask the renderer interface to find the specified attribute,
// and store it as a global variable on the window.
bridge.send('viewAttributeSource', {id, path, rendererID});
setTimeout(() => {
// Ask Chrome to display the location of the attribute,
// assuming the renderer found a match.
chrome.devtools.inspectedWindow.eval(`
if (window.$attribute != null) {
inspect(window.$attribute);
}
`);
}, 100);
}
};
const viewElementSourceFunction = id => {
const rendererID = store.getRendererIDForElement(id);
if (rendererID != null) {
// Ask the renderer interface to determine the component function,
// and store it as a global variable on the window
bridge.send('viewElementSource', {id, rendererID});
setTimeout(() => {
// Ask Chrome to display the location of the component function,
// or a render method if it is a Class (ideally Class instance, not type)
// assuming the renderer found one.
chrome.devtools.inspectedWindow.eval(`
if (window.$type != null) {
if (
window.$type &&
window.$type.prototype &&
window.$type.prototype.isReactComponent
) {
// inspect Component.render, not constructor
inspect(window.$type.prototype.render);
} else {
// inspect Functional Component
inspect(window.$type);
}
}
`);
}, 100);
}
};
let debugIDCounter = 0;
// For some reason in Firefox, chrome.runtime.sendMessage() from a content script
// never reaches the chrome.runtime.onMessage event listener.
let fetchFileWithCaching = null;
if (IS_CHROME) {
const fetchFromNetworkCache = (url, resolve, reject) => {
// Debug ID allows us to avoid re-logging (potentially long) URL strings below,
// while also still associating (potentially) interleaved logs with the original request.
let debugID = null;
if (__DEBUG__) {
debugID = debugIDCounter++;
console.log(`[main] fetchFromNetworkCache(${debugID})`, url);
}
chrome.devtools.network.getHAR(harLog => {
for (let i = 0; i < harLog.entries.length; i++) {
const entry = harLog.entries[i];
if (url === entry.request.url) {
if (__DEBUG__) {
console.log(
`[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`,
url,
);
}
entry.getContent(content => {
if (content) {
if (__DEBUG__) {
console.log(
`[main] fetchFromNetworkCache(${debugID}) Content retrieved`,
);
}
resolve(content);
} else {
if (__DEBUG__) {
console.log(
`[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`,
content,
);
}
// Edge case where getContent() returned null; fall back to fetch.
fetchFromPage(url, resolve, reject);
}
});
return;
}
}
if (__DEBUG__) {
console.log(
`[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`,
);
}
// No matching URL found; fall back to fetch.
fetchFromPage(url, resolve, reject);
});
};
const fetchFromPage = (url, resolve, reject) => {
if (__DEBUG__) {
console.log('[main] fetchFromPage()', url);
}
function onPortMessage({payload, source}) {
if (source === 'react-devtools-content-script') {
switch (payload?.type) {
case 'fetch-file-with-cache-complete':
chrome.runtime.onMessage.removeListener(onPortMessage);
resolve(payload.value);
break;
case 'fetch-file-with-cache-error':
chrome.runtime.onMessage.removeListener(onPortMessage);
reject(payload.value);
break;
}
}
}
chrome.runtime.onMessage.addListener(onPortMessage);
chrome.devtools.inspectedWindow.eval(`
window.postMessage({
source: 'react-devtools-extension',
payload: {
type: 'fetch-file-with-cache',
url: "${url}",
},
});
`);
};
// Fetching files from the extension won't make use of the network cache
// for resources that have already been loaded by the page.
// This helper function allows the extension to request files to be fetched
// by the content script (running in the page) to increase the likelihood of a cache hit.
fetchFileWithCaching = url => {
return new Promise((resolve, reject) => {
// Try fetching from the Network cache first.
// If DevTools was opened after the page started loading, we may have missed some requests.
// So fall back to a fetch() from the page and hope we get a cached response that way.
fetchFromNetworkCache(url, resolve, reject);
});
};
}
// TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
const hookNamesModuleLoaderFunction = () =>
import(
/* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
);
root = createRoot(document.createElement('div'));
render = (overrideTab = mostRecentOverrideTab) => {
mostRecentOverrideTab = overrideTab;
root.render(
createElement(DevTools, {
bridge,
browserTheme: getBrowserTheme(),
componentsPortalContainer,
enabledInspectedElementContextMenu: true,
fetchFileWithCaching,
hookNamesModuleLoaderFunction,
overrideTab,
profilerPortalContainer,
showTabBar: false,
store,
warnIfUnsupportedVersionDetected: true,
viewAttributeSourceFunction,
viewElementSourceFunction,
viewUrlSourceFunction,
}),
);
};
render();
}
const viewUrlSourceFunction = (url, line, col) => {
chrome.devtools.panels.openResource(url, line, col);
};
function ensureInitialHTMLIsCleared(container) {
if (container._hasInitialHTMLBeenCleared) {
return;
}
container.innerHTML = '';
container._hasInitialHTMLBeenCleared = true;
}
function createComponentsPanel() {
if (componentsPortalContainer) {
render('components');
return;
}
chrome.devtools.panels.create(
IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components',
IS_EDGE ? 'icons/production.svg' : '',
'panel.html',
createdPanel => {
createdPanel.onShown.addListener(portal => {
componentsPortalContainer = portal.container;
if (componentsPortalContainer != null) {
ensureInitialHTMLIsCleared(componentsPortalContainer);
render('components');
portal.injectStyles(cloneStyleTags);
logEvent({event_name: 'selected-components-tab'});
}
});
// TODO: we should listen to extension.onHidden to unmount some listeners
// and potentially stop highlighting
},
);
}
function createProfilerPanel() {
if (componentsPortalContainer) {
render('profiler');
return;
}
chrome.devtools.panels.create(
IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler',
IS_EDGE ? 'icons/production.svg' : '',
'panel.html',
createdPanel => {
createdPanel.onShown.addListener(portal => {
profilerPortalContainer = portal.container;
if (profilerPortalContainer != null) {
ensureInitialHTMLIsCleared(profilerPortalContainer);
render('profiler');
portal.injectStyles(cloneStyleTags);
logEvent({event_name: 'selected-profiler-tab'});
}
});
},
);
}
function performInTabNavigationCleanup() {
if (store !== null) {
// Store profiling data, so it can be used later
profilingData = store.profilerStore.profilingData;
}
// If panels were already created, and we have already mounted React root to display
// tabs (Components or Profiler), we should unmount root first and render them again
if ((componentsPortalContainer || profilerPortalContainer) && root) {
// It's easiest to recreate the DevTools panel (to clean up potential stale state).
// We can revisit this in the future as a small optimization.
// This should also emit bridge.shutdown, but only if this root was mounted
flushSync(() => root.unmount());
} else {
// In case Browser DevTools were opened, but user never pressed on extension panels
// They were never mounted and there is nothing to unmount, but we need to emit shutdown event
// because bridge was already created
bridge?.shutdown();
}
// Do not nullify componentsPanelPortal and profilerPanelPortal on purpose,
// They are not recreated when user does in-tab navigation, and they can only be accessed via
// callback in onShown listener, which is called only when panel has been shown
// This event won't be emitted again after in-tab navigation, if DevTools panel keeps being opened
// Do not clean mostRecentOverrideTab on purpose, so we remember last opened
// React DevTools tab, when user does in-tab navigation
store = null;
bridge = null;
render = null;
root = null;
port?.disconnect();
port = null;
}
function performFullCleanup() {
if ((componentsPortalContainer || profilerPortalContainer) && root) {
// This should also emit bridge.shutdown, but only if this root was mounted
flushSync(() => root.unmount());
} else {
bridge?.shutdown();
}
componentsPortalContainer = null;
profilerPortalContainer = null;
root = null;
mostRecentOverrideTab = null;
store = null;
bridge = null;
render = null;
port?.disconnect();
port = null;
}
function mountReactDevTools() {
registerEventsLogger();
const tabId = chrome.devtools.inspectedWindow.tabId;
port = chrome.runtime.connect({
name: String(tabId),
});
createBridgeAndStore();
setReactSelectionFromBrowser(bridge);
createComponentsPanel();
createProfilerPanel();
}
// TODO: display some disclaimer if user performs in-tab navigation to non-react application
// when React DevTools panels are already opened, currently we will display just blank white block
function mountReactDevToolsWhenReactHasLoaded() {
const checkIfReactHasLoaded = () => executeIfReactHasLoaded(onReactReady);
// Check to see if React has loaded in case React is added after page load
const reactPollingIntervalId = setInterval(() => {
checkIfReactHasLoaded();
}, 500);
function onReactReady() {
clearInterval(reactPollingIntervalId);
mountReactDevTools();
}
checkIfReactHasLoaded();
}
let bridge = null;
let store = null;
let profilingData = null;
let componentsPortalContainer = null;
let profilerPortalContainer = null;
let mostRecentOverrideTab = null;
let render = null;
let root = null;
let port = null;
// Re-initialize saved filters on navigation,
// since global values stored on window get reset in this case.
chrome.devtools.network.onNavigated.addListener(syncSavedPreferences);
// Cleanup previous page state and remount everything
chrome.devtools.network.onNavigated.addListener(() => {
performInTabNavigationCleanup();
mountReactDevToolsWhenReactHasLoaded();
});
// Should be emitted when browser DevTools are closed
if (IS_FIREFOX) {
// For some reason Firefox doesn't emit onBeforeUnload event
window.addEventListener('unload', performFullCleanup);
} else {
window.addEventListener('beforeunload', performFullCleanup);
}
syncSavedPreferences();
mountReactDevToolsWhenReactHasLoaded();
@@ -0,0 +1,30 @@
/* global chrome */
import {IS_FIREFOX} from '../utils';
function injectBackendManager(tabId) {
if (IS_FIREFOX) {
// Firefox does not support executing script in ExecutionWorld.MAIN from content script.
// see prepareInjection.js
chrome.devtools.inspectedWindow.eval(
`window.postMessage({ source: 'react-devtools-inject-backend-manager' }, '*');`,
function (response, evalError) {
if (evalError) {
console.error(evalError);
}
},
);
return;
}
chrome.runtime.sendMessage({
source: 'react-devtools-main',
payload: {
type: 'react-devtools-inject-backend-manager',
tabId,
},
});
}
export default injectBackendManager;
@@ -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;
@@ -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;
@@ -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;
@@ -55,9 +55,9 @@ module.exports = {
mode: __DEV__ ? 'development' : 'production',
devtool: __DEV__ ? 'cheap-module-source-map' : false,
entry: {
background: './src/background.js',
backendManager: './src/backendManager.js',
main: './src/main.js',
background: './src/background/index.js',
backendManager: './src/contentScripts/backendManager.js',
main: './src/main/index.js',
panel: './src/panel.js',
proxy: './src/contentScripts/proxy.js',
prepareInjection: './src/contentScripts/prepareInjection.js',
+3
View File
@@ -68,4 +68,7 @@ export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY =
export const LOCAL_STORAGE_HIDE_CONSOLE_LOGS_IN_STRICT_MODE =
'React::DevTools::hideConsoleLogsInStrictMode';
export const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
'React::DevTools::supportsProfiling';
export const PROFILER_EXPORT_VERSION = 5;
@@ -269,6 +269,7 @@ export default function DevTools({
useEffect(() => {
logEvent({event_name: 'loaded-dev-tools'});
}, []);
return (
<BridgeContext.Provider value={bridge}>
<StoreContext.Provider value={store}>