@samitouri / QOS-React-1 / commits / 8fbd307942

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.

Ruslan Lesiutin committed Aug 29, 2023 at 12:09 UTC 8fbd3079425eaff85ec1b52b0eefecdd44ba7289
22 files changed +1159 -847
packages/react-devtools-extensions/firefox/manifest.json
+4 -2
@@ -6,7 +6,7 @@
6 "applications": {
7 "gecko": {
8 "id": "@react-devtools",
9 - "strict_min_version": "55.0"
9 + "strict_min_version": "102.0"
10 }
11 },
12 "icons": {
@@ -41,7 +41,9 @@
41 "file:///*",
42 "http://*/*",
43 "https://*/*",
44 - "clipboardWrite"
44 + "clipboardWrite",
45 + "scripting",
46 + "devtools"
47 ],
48 "content_scripts": [
49 {
packages/react-devtools-extensions/src/background.js deleted
-245
@@ -1,245 +0,0 @@
1 -/* global chrome */
2 -
3 -'use strict';
4 -
5 -import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from './utils';
6 -
7 -const ports = {};
8 -
9 -async function dynamicallyInjectContentScripts() {
10 - const contentScriptsToInject = [
11 - {
12 - id: 'hook',
13 - matches: ['<all_urls>'],
14 - js: ['build/installHook.js'],
15 - runAt: 'document_start',
16 - world: chrome.scripting.ExecutionWorld.MAIN,
17 - },
18 - {
19 - id: 'renderer',
20 - matches: ['<all_urls>'],
21 - js: ['build/renderer.js'],
22 - runAt: 'document_start',
23 - world: chrome.scripting.ExecutionWorld.MAIN,
24 - },
25 - ];
26 -
27 - try {
28 - // For some reason dynamically injected scripts might be already registered
29 - // Registering them again will fail, which will result into
30 - // __REACT_DEVTOOLS_GLOBAL_HOOK__ hook not being injected
31 -
32 - // Not specifying ids, because Chrome throws an error
33 - // if id of non-injected script is provided
34 - await chrome.scripting.unregisterContentScripts();
35 -
36 - // equivalent logic for Firefox is in prepareInjection.js
37 - // Manifest V3 method of injecting content script
38 - // TODO(hoxyq): migrate Firefox to V3 manifests
39 - // Note: the "world" option in registerContentScripts is only available in Chrome v102+
40 - // It's critical since it allows us to directly run scripts on the "main" world on the page
41 - // "document_start" allows it to run before the page's scripts
42 - // so the hook can be detected by react reconciler
43 - await chrome.scripting.registerContentScripts(contentScriptsToInject);
44 - } catch (error) {
45 - console.error(error);
46 - }
47 -}
48 -
49 -if (!IS_FIREFOX) {
50 - dynamicallyInjectContentScripts();
51 -}
52 -
53 -chrome.runtime.onConnect.addListener(function (port) {
54 - let tab = null;
55 - let name = null;
56 - if (isNumeric(port.name)) {
57 - tab = port.name;
58 - name = 'devtools';
59 - installProxy(+port.name);
60 - } else {
61 - tab = port.sender.tab.id;
62 - name = 'content-script';
63 - }
64 -
65 - if (!ports[tab]) {
66 - ports[tab] = {
67 - devtools: null,
68 - 'content-script': null,
69 - };
70 - }
71 - ports[tab][name] = port;
72 -
73 - if (ports[tab].devtools && ports[tab]['content-script']) {
74 - doublePipe(ports[tab].devtools, ports[tab]['content-script'], tab);
75 - }
76 -});
77 -
78 -function isNumeric(str: string): boolean {
79 - return +str + '' === str;
80 -}
81 -
82 -function installProxy(tabId: number) {
83 - if (IS_FIREFOX) {
84 - chrome.tabs.executeScript(tabId, {file: '/build/proxy.js'}, function () {});
85 - } else {
86 - chrome.scripting.executeScript({
87 - target: {tabId: tabId},
88 - files: ['/build/proxy.js'],
89 - });
90 - }
91 -}
92 -
93 -function doublePipe(one, two, tabId) {
94 - one.onMessage.addListener(lOne);
95 - function lOne(message) {
96 - try {
97 - two.postMessage(message);
98 - } catch (e) {
99 - if (__DEV__) {
100 - console.log(`Broken pipe ${tabId}: `, e);
101 - }
102 - shutdown();
103 - }
104 - }
105 - two.onMessage.addListener(lTwo);
106 - function lTwo(message) {
107 - try {
108 - one.postMessage(message);
109 - } catch (e) {
110 - if (__DEV__) {
111 - console.log(`Broken pipe ${tabId}: `, e);
112 - }
113 - shutdown();
114 - }
115 - }
116 - function shutdown() {
117 - one.onMessage.removeListener(lOne);
118 - two.onMessage.removeListener(lTwo);
119 - one.disconnect();
120 - two.disconnect();
121 - // clean up so that we can rebuild the double pipe if the page is reloaded
122 - ports[tabId] = null;
123 - }
124 - one.onDisconnect.addListener(shutdown);
125 - two.onDisconnect.addListener(shutdown);
126 -}
127 -
128 -function setIconAndPopup(reactBuildType, tabId) {
129 - const action = IS_FIREFOX ? chrome.browserAction : chrome.action;
130 - action.setIcon({
131 - tabId: tabId,
132 - path: {
133 - '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`),
134 - '32': chrome.runtime.getURL(`icons/32-${reactBuildType}.png`),
135 - '48': chrome.runtime.getURL(`icons/48-${reactBuildType}.png`),
136 - '128': chrome.runtime.getURL(`icons/128-${reactBuildType}.png`),
137 - },
138 - });
139 - action.setPopup({
140 - tabId: tabId,
141 - popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`),
142 - });
143 -}
144 -
145 -function isRestrictedBrowserPage(url) {
146 - return !url || new URL(url).protocol === 'chrome:';
147 -}
148 -
149 -function checkAndHandleRestrictedPageIfSo(tab) {
150 - if (tab && isRestrictedBrowserPage(tab.url)) {
151 - setIconAndPopup('restricted', tab.id);
152 - }
153 -}
154 -
155 -// update popup page of any existing open tabs, if they are restricted browser pages.
156 -// we can't update for any other types (prod,dev,outdated etc)
157 -// as the content script needs to be injected at document_start itself for those kinds of detection
158 -// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed
159 -if (!IS_FIREFOX) {
160 - chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo));
161 - chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) =>
162 - checkAndHandleRestrictedPageIfSo(tab),
163 - );
164 -}
165 -
166 -// Listen to URL changes on the active tab and update the DevTools icon.
167 -chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
168 - if (IS_FIREFOX) {
169 - // We don't properly detect protected URLs in Firefox at the moment.
170 - // However we can reset the DevTools icon to its loading state when the URL changes.
171 - // It will be updated to the correct icon by the onMessage callback below.
172 - if (tab.active && changeInfo.status === 'loading') {
173 - setIconAndPopup('disabled', tabId);
174 - }
175 - } else {
176 - // Don't reset the icon to the loading state for Chrome or Edge.
177 - // The onUpdated callback fires more frequently for these browsers,
178 - // often after onMessage has been called.
179 - checkAndHandleRestrictedPageIfSo(tab);
180 - }
181 -});
182 -
183 -chrome.runtime.onMessage.addListener((request, sender) => {
184 - const tab = sender.tab;
185 - // sender.tab.id from content script points to the tab that injected the content script
186 - if (tab) {
187 - const id = tab.id;
188 - // This is sent from the hook content script.
189 - // It tells us a renderer has attached.
190 - if (request.hasDetectedReact) {
191 - setIconAndPopup(request.reactBuildType, id);
192 - } else {
193 - const devtools = ports[id]?.devtools;
194 - switch (request.payload?.type) {
195 - case 'fetch-file-with-cache-complete':
196 - case 'fetch-file-with-cache-error':
197 - // Forward the result of fetch-in-page requests back to the extension.
198 - devtools?.postMessage(request);
199 - break;
200 - // This is sent from the backend manager running on a page
201 - case 'react-devtools-required-backends':
202 - const backendsToDownload = [];
203 - request.payload.versions.forEach(version => {
204 - if (EXTENSION_CONTAINED_VERSIONS.includes(version)) {
205 - if (!IS_FIREFOX) {
206 - // equivalent logic for Firefox is in prepareInjection.js
207 - chrome.scripting.executeScript({
208 - target: {tabId: id},
209 - files: [`/build/react_devtools_backend_${version}.js`],
210 - world: chrome.scripting.ExecutionWorld.MAIN,
211 - });
212 - }
213 - } else {
214 - backendsToDownload.push(version);
215 - }
216 - });
217 - // Request the necessary backends in the extension DevTools UI
218 - // TODO: handle this message in main.js to build the UI
219 - devtools?.postMessage({
220 - payload: {
221 - type: 'react-devtools-additional-backends',
222 - versions: backendsToDownload,
223 - },
224 - });
225 - break;
226 - }
227 - }
228 - }
229 - // sender.tab.id from devtools page may not exist, or point to the undocked devtools window
230 - // so we use the payload to get the tab id
231 - if (request.payload?.tabId) {
232 - const tabId = request.payload?.tabId;
233 - // This is sent from the devtools page when it is ready for injecting the backend
234 - if (request.payload.type === 'react-devtools-inject-backend-manager') {
235 - if (!IS_FIREFOX) {
236 - // equivalent logic for Firefox is in prepareInjection.js
237 - chrome.scripting.executeScript({
238 - target: {tabId},
239 - files: ['/build/backendManager.js'],
240 - world: chrome.scripting.ExecutionWorld.MAIN,
241 - });
242 - }
243 - }
244 - }
245 -});
packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js new
+53
@@ -0,0 +1,53 @@
1 +/* global chrome */
2 +
3 +import {IS_FIREFOX} from '../utils';
4 +
5 +async function dynamicallyInjectContentScripts() {
6 + const contentScriptsToInject = [
7 + {
8 + id: '@react-devtools/hook',
9 + js: ['build/installHook.js'],
10 + matches: ['<all_urls>'],
11 + persistAcrossSessions: true,
12 + runAt: 'document_start',
13 + world: chrome.scripting.ExecutionWorld.MAIN,
14 + },
15 + {
16 + id: '@react-devtools/renderer',
17 + js: ['build/renderer.js'],
18 + matches: ['<all_urls>'],
19 + persistAcrossSessions: true,
20 + runAt: 'document_start',
21 + world: chrome.scripting.ExecutionWorld.MAIN,
22 + },
23 + ];
24 +
25 + try {
26 + const alreadyRegisteredContentScripts =
27 + await chrome.scripting.getRegisteredContentScripts();
28 +
29 + const scriptsToInjectNow = contentScriptsToInject.filter(
30 + scriptToInject =>
31 + !alreadyRegisteredContentScripts.some(
32 + registeredScript => registeredScript.id === scriptToInject.id,
33 + ),
34 + );
35 +
36 + if (scriptsToInjectNow.length) {
37 + // equivalent logic for Firefox is in prepareInjection.js
38 + // Manifest V3 method of injecting content script
39 + // TODO(hoxyq): migrate Firefox to V3 manifests
40 + // Note: the "world" option in registerContentScripts is only available in Chrome v102+
41 + // It's critical since it allows us to directly run scripts on the "main" world on the page
42 + // "document_start" allows it to run before the page's scripts
43 + // so the hook can be detected by react reconciler
44 + await chrome.scripting.registerContentScripts(scriptsToInjectNow);
45 + }
46 + } catch (error) {
47 + console.error(error);
48 + }
49 +}
50 +
51 +if (!IS_FIREFOX) {
52 + dynamicallyInjectContentScripts();
53 +}
packages/react-devtools-extensions/src/background/index.js new
+257
@@ -0,0 +1,257 @@
1 +/* global chrome */
2 +
3 +'use strict';
4 +
5 +import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
6 +
7 +import './dynamicallyInjectContentScripts';
8 +import './tabsManager';
9 +import setExtensionIconAndPopup from './setExtensionIconAndPopup';
10 +import injectProxy from './injectProxy';
11 +
12 +/*
13 + {
14 + [tabId]: {
15 + extension: ExtensionPort,
16 + proxy: ProxyPort,
17 + disconnectPipe: Function,
18 + },
19 + ...
20 + }
21 + */
22 +const ports = {};
23 +
24 +function registerTab(tabId) {
25 + if (!ports[tabId]) {
26 + ports[tabId] = {
27 + extension: null,
28 + proxy: null,
29 + disconnectPipe: null,
30 + };
31 + }
32 +}
33 +
34 +function registerExtensionPort(port, tabId) {
35 + ports[tabId].extension = port;
36 +
37 + port.onDisconnect.addListener(() => {
38 + // This should delete disconnectPipe from ports dictionary
39 + ports[tabId].disconnectPipe?.();
40 +
41 + delete ports[tabId].extension;
42 +
43 + const proxyPort = ports[tabId].proxy;
44 + if (proxyPort) {
45 + // Do not disconnect proxy port, we will inject this content script again
46 + // If extension port has disconnected, it probably means that user did in-tab navigation
47 + clearReconnectionTimeout(proxyPort);
48 +
49 + proxyPort.postMessage({
50 + source: 'react-devtools-service-worker',
51 + stop: true,
52 + });
53 + }
54 + });
55 +}
56 +
57 +function registerProxyPort(port, tabId) {
58 + ports[tabId].proxy = port;
59 +
60 + // In case proxy port was disconnected from the other end, from content script
61 + // This can happen if content script was detached, when user does in-tab navigation
62 + // Or if when we notify proxy port to stop reconnecting, when extension port dies
63 + // This listener should never be called when we call port.shutdown() from this (background/index.js) script
64 + port.onDisconnect.addListener(() => {
65 + ports[tabId].disconnectPipe?.();
66 +
67 + delete ports[tabId].proxy;
68 + });
69 +
70 + port._reconnectionTimeoutId = setTimeout(
71 + reconnectProxyPort,
72 + 25_000,
73 + port,
74 + tabId,
75 + );
76 +}
77 +
78 +function clearReconnectionTimeout(port) {
79 + if (port._reconnectionTimeoutId) {
80 + clearTimeout(port._reconnectionTimeoutId);
81 + delete port._reconnectionTimeoutId;
82 + }
83 +}
84 +
85 +function reconnectProxyPort(port, tabId) {
86 + // IMPORTANT: port.onDisconnect will only be emitted if disconnect() was called from the other end
87 + // We need to do it manually here if we disconnect proxy port from service worker
88 + ports[tabId].disconnectPipe?.();
89 +
90 + // It should be reconnected automatically by proxy content script, look at proxy.js
91 + port.disconnect();
92 +}
93 +
94 +function isNumeric(str: string): boolean {
95 + return +str + '' === str;
96 +}
97 +
98 +chrome.runtime.onConnect.addListener(async port => {
99 + if (port.name === 'proxy') {
100 + // Proxy content script is executed in tab, so it should have it specified.
101 + const tabId = port.sender.tab.id;
102 +
103 + registerTab(tabId);
104 + registerProxyPort(port, tabId);
105 +
106 + connectExtensionAndProxyPorts(
107 + ports[tabId].extension,
108 + ports[tabId].proxy,
109 + tabId,
110 + );
111 +
112 + return;
113 + }
114 +
115 + if (isNumeric(port.name)) {
116 + // Extension port doesn't have tab id specified, because its sender is the extension.
117 + const tabId = +port.name;
118 +
119 + registerTab(tabId);
120 + registerExtensionPort(port, tabId);
121 +
122 + injectProxy(tabId);
123 +
124 + return;
125 + }
126 +
127 + // I am not sure if we should throw here
128 + console.warn(`Unknown port ${port.name} connected`);
129 +});
130 +
131 +function connectExtensionAndProxyPorts(extensionPort, proxyPort, tabId) {
132 + if (!extensionPort) {
133 + throw new Error(
134 + `Attempted to connect ports, when extension port is not present`,
135 + );
136 + }
137 +
138 + if (!proxyPort) {
139 + throw new Error(
140 + `Attempted to connect ports, when proxy port is not present`,
141 + );
142 + }
143 +
144 + if (ports[tabId].disconnectPipe) {
145 + throw new Error(
146 + `Attempted to connect already connected ports for tab with id ${tabId}`,
147 + );
148 + }
149 +
150 + function extensionPortMessageListener(message) {
151 + try {
152 + proxyPort.postMessage(message);
153 + } catch (e) {
154 + if (__DEV__) {
155 + console.log(`Broken pipe ${tabId}: `, e);
156 + }
157 +
158 + disconnectListener();
159 + }
160 + }
161 +
162 + function proxyPortMessageListener(message) {
163 + try {
164 + extensionPort.postMessage(message);
165 + } catch (e) {
166 + if (__DEV__) {
167 + console.log(`Broken pipe ${tabId}: `, e);
168 + }
169 +
170 + disconnectListener();
171 + }
172 + }
173 +
174 + function disconnectListener() {
175 + extensionPort.onMessage.removeListener(extensionPortMessageListener);
176 + proxyPort.onMessage.removeListener(proxyPortMessageListener);
177 +
178 + // We handle disconnect() calls manually, based on each specific case
179 + // No need to disconnect other port here
180 +
181 + delete ports[tabId].disconnectPipe;
182 + }
183 +
184 + ports[tabId].disconnectPipe = disconnectListener;
185 +
186 + extensionPort.onMessage.addListener(extensionPortMessageListener);
187 + proxyPort.onMessage.addListener(proxyPortMessageListener);
188 +
189 + extensionPort.onDisconnect.addListener(disconnectListener);
190 + proxyPort.onDisconnect.addListener(disconnectListener);
191 +}
192 +
193 +chrome.runtime.onMessage.addListener((message, sender) => {
194 + const tab = sender.tab;
195 + // sender.tab.id from content script points to the tab that injected the content script
196 + if (tab) {
197 + const id = tab.id;
198 + // This is sent from the hook content script.
199 + // It tells us a renderer has attached.
200 + if (message.hasDetectedReact) {
201 + setExtensionIconAndPopup(message.reactBuildType, id);
202 + } else {
203 + const extensionPort = ports[id]?.extension;
204 +
205 + switch (message.payload?.type) {
206 + case 'fetch-file-with-cache-complete':
207 + case 'fetch-file-with-cache-error':
208 + // Forward the result of fetch-in-page requests back to the extension.
209 + extensionPort?.postMessage(message);
210 + break;
211 + // This is sent from the backend manager running on a page
212 + case 'react-devtools-required-backends':
213 + const backendsToDownload = [];
214 + message.payload.versions.forEach(version => {
215 + if (EXTENSION_CONTAINED_VERSIONS.includes(version)) {
216 + if (!IS_FIREFOX) {
217 + // equivalent logic for Firefox is in prepareInjection.js
218 + chrome.scripting.executeScript({
219 + target: {tabId: id},
220 + files: [`/build/react_devtools_backend_${version}.js`],
221 + world: chrome.scripting.ExecutionWorld.MAIN,
222 + });
223 + }
224 + } else {
225 + backendsToDownload.push(version);
226 + }
227 + });
228 +
229 + // Request the necessary backends in the extension DevTools UI
230 + // TODO: handle this message in index.js to build the UI
231 + extensionPort?.postMessage({
232 + payload: {
233 + type: 'react-devtools-additional-backends',
234 + versions: backendsToDownload,
235 + },
236 + });
237 + break;
238 + }
239 + }
240 + }
241 +
242 + // This is sent from the devtools page when it is ready for injecting the backend
243 + if (message?.payload?.type === 'react-devtools-inject-backend-manager') {
244 + // sender.tab.id from devtools page may not exist, or point to the undocked devtools window
245 + // so we use the payload to get the tab id
246 + const tabId = message.payload.tabId;
247 +
248 + if (tabId && !IS_FIREFOX) {
249 + // equivalent logic for Firefox is in prepareInjection.js
250 + chrome.scripting.executeScript({
251 + target: {tabId},
252 + files: ['/build/backendManager.js'],
253 + world: chrome.scripting.ExecutionWorld.MAIN,
254 + });
255 + }
256 + }
257 +});
packages/react-devtools-extensions/src/background/injectProxy.js new
+12
@@ -0,0 +1,12 @@
1 +/* global chrome */
2 +
3 +// We keep this logic in background, because Firefox doesn't allow using these APIs
4 +// from extension page script
5 +function injectProxy(tabId: number) {
6 + chrome.scripting.executeScript({
7 + target: {tabId},
8 + files: ['/build/proxy.js'],
9 + });
10 +}
11 +
12 +export default injectProxy;
packages/react-devtools-extensions/src/background/setExtensionIconAndPopup.js new
+26
@@ -0,0 +1,26 @@
1 +/* global chrome */
2 +
3 +'use strict';
4 +
5 +import {IS_FIREFOX} from 'react-devtools-extensions/src/utils';
6 +
7 +function setExtensionIconAndPopup(reactBuildType, tabId) {
8 + const action = IS_FIREFOX ? chrome.browserAction : chrome.action;
9 +
10 + action.setIcon({
11 + tabId,
12 + path: {
13 + '16': chrome.runtime.getURL(`icons/16-${reactBuildType}.png`),
14 + '32': chrome.runtime.getURL(`icons/32-${reactBuildType}.png`),
15 + '48': chrome.runtime.getURL(`icons/48-${reactBuildType}.png`),
16 + '128': chrome.runtime.getURL(`icons/128-${reactBuildType}.png`),
17 + },
18 + });
19 +
20 + action.setPopup({
21 + tabId,
22 + popup: chrome.runtime.getURL(`popups/${reactBuildType}.html`),
23 + });
24 +}
25 +
26 +export default setExtensionIconAndPopup;
packages/react-devtools-extensions/src/background/tabsManager.js new
+45
@@ -0,0 +1,45 @@
1 +/* global chrome */
2 +
3 +'use strict';
4 +
5 +import {IS_FIREFOX} from 'react-devtools-extensions/src/utils';
6 +
7 +import setExtensionIconAndPopup from './setExtensionIconAndPopup';
8 +
9 +function isRestrictedBrowserPage(url) {
10 + return !url || new URL(url).protocol === 'chrome:';
11 +}
12 +
13 +function checkAndHandleRestrictedPageIfSo(tab) {
14 + if (tab && isRestrictedBrowserPage(tab.url)) {
15 + setExtensionIconAndPopup('restricted', tab.id);
16 + }
17 +}
18 +
19 +// update popup page of any existing open tabs, if they are restricted browser pages.
20 +// we can't update for any other types (prod,dev,outdated etc)
21 +// as the content script needs to be injected at document_start itself for those kinds of detection
22 +// TODO: Show a different popup page(to reload current page probably) for old tabs, opened before the extension is installed
23 +if (!IS_FIREFOX) {
24 + chrome.tabs.query({}, tabs => tabs.forEach(checkAndHandleRestrictedPageIfSo));
25 + chrome.tabs.onCreated.addListener((tabId, changeInfo, tab) =>
26 + checkAndHandleRestrictedPageIfSo(tab),
27 + );
28 +}
29 +
30 +// Listen to URL changes on the active tab and update the DevTools icon.
31 +chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
32 + if (IS_FIREFOX) {
33 + // We don't properly detect protected URLs in Firefox at the moment.
34 + // However, we can reset the DevTools icon to its loading state when the URL changes.
35 + // It will be updated to the correct icon by the onMessage callback below.
36 + if (tab.active && changeInfo.status === 'loading') {
37 + setExtensionIconAndPopup('disabled', tabId);
38 + }
39 + } else {
40 + // Don't reset the icon to the loading state for Chrome or Edge.
41 + // The onUpdated callback fires more frequently for these browsers,
42 + // often after onMessage has been called.
43 + checkAndHandleRestrictedPageIfSo(tab);
44 + }
45 +});
packages/react-devtools-extensions/src/contentScripts/backendManager.js renamed
+1 -1
@@ -12,7 +12,7 @@ import type {
12 ReactRenderer,
13 } from 'react-devtools-shared/src/backend/types';
14 import {hasAssignedBackend} from 'react-devtools-shared/src/backend/utils';
15 -import {COMPACT_VERSION_NAME} from './utils';
15 +import {COMPACT_VERSION_NAME} from 'react-devtools-extensions/src/utils';
16
17 let welcomeHasInitialized = false;
18
packages/react-devtools-extensions/src/contentScripts/prepareInjection.js
+1 -1
@@ -5,7 +5,7 @@ import {SESSION_STORAGE_RELOAD_AND_PROFILE_KEY} from 'react-devtools-shared/src/
5 import {sessionStorageGetItem} from 'react-devtools-shared/src/storage';
6 import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
7
8 -// We run scripts on the page via the service worker (backgroud.js) for
8 +// We run scripts on the page via the service worker (background/index.js) for
9 // Manifest V3 extensions (Chrome & Edge).
10 // We need to inject this code for Firefox only because it does not support ExecutionWorld.MAIN
11 // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/ExecutionWorld
packages/react-devtools-extensions/src/contentScripts/proxy.js
+39 -33
@@ -2,9 +2,23 @@
2
3 'use strict';
4
5 -let backendDisconnected: boolean = false;
5 +let port = null;
6 let backendInitialized: boolean = false;
7
8 +connectPort();
9 +sayHelloToBackendManager();
10 +
11 +// The backend waits to install the global hook until notified by the content script.
12 +// In the event of a page reload, the content script might be loaded before the backend manager is injected.
13 +// Because of this we need to poll the backend manager until it has been initialized.
14 +const intervalID = setInterval(() => {
15 + if (backendInitialized) {
16 + clearInterval(intervalID);
17 + } else {
18 + sayHelloToBackendManager();
19 + }
20 +}, 500);
21 +
22 function sayHelloToBackendManager() {
23 window.postMessage(
24 {
@@ -16,6 +30,18 @@ function sayHelloToBackendManager() {
30 }
31
32 function handleMessageFromDevtools(message) {
33 + if (message.source === 'react-devtools-service-worker' && message.stop) {
34 + window.removeEventListener('message', handleMessageFromPage);
35 +
36 + // Calling disconnect here should not emit onDisconnect event inside this script
37 + // This port will not attempt to reconnect again
38 + // It will connect only once this content script will be injected again
39 + port?.disconnect();
40 + port = null;
41 +
42 + return;
43 + }
44 +
45 window.postMessage(
46 {
47 source: 'react-devtools-content-script',
@@ -33,6 +59,7 @@ function handleMessageFromPage(event) {
59
60 port.postMessage(event.data.payload);
61 }
62 +
63 // This is a message from the backend manager
64 if (event.data.source === 'react-devtools-backend-manager') {
65 chrome.runtime.sendMessage({
@@ -43,42 +70,21 @@ function handleMessageFromPage(event) {
70 }
71
72 function handleDisconnect() {
46 - backendDisconnected = true;
47 -
73 window.removeEventListener('message', handleMessageFromPage);
74 + port = null;
75
50 - window.postMessage(
51 - {
52 - source: 'react-devtools-content-script',
53 - payload: {
54 - type: 'event',
55 - event: 'shutdown',
56 - },
57 - },
58 - '*',
59 - );
76 + connectPort();
77 }
78
62 -// proxy from main page to devtools (via the background page)
63 -const port = chrome.runtime.connect({
64 - name: 'content-script',
65 -});
66 -port.onMessage.addListener(handleMessageFromDevtools);
67 -port.onDisconnect.addListener(handleDisconnect);
79 +// Creates port from application page to the React DevTools' service worker
80 +// Which then connects it with extension port
81 +function connectPort() {
82 + port = chrome.runtime.connect({
83 + name: 'proxy',
84 + });
85
69 -window.addEventListener('message', handleMessageFromPage);
86 + window.addEventListener('message', handleMessageFromPage);
87
71 -sayHelloToBackendManager();
72 -
73 -// The backend waits to install the global hook until notified by the content script.
74 -// In the event of a page reload, the content script might be loaded before the backend manager is injected.
75 -// Because of this we need to poll the backend manager until it has been initialized.
76 -if (!backendInitialized) {
77 - const intervalID = setInterval(() => {
78 - if (backendInitialized || backendDisconnected) {
79 - clearInterval(intervalID);
80 - } else {
81 - sayHelloToBackendManager();
82 - }
83 - }, 500);
88 + port.onMessage.addListener(handleMessageFromDevtools);
89 + port.onDisconnect.addListener(handleDisconnect);
90 }
packages/react-devtools-extensions/src/main.js deleted
-562
@@ -1,562 +0,0 @@
1 -/* global chrome */
2 -
3 -import {createElement} from 'react';
4 -import {flushSync} from 'react-dom';
5 -import {createRoot} from 'react-dom/client';
6 -import Bridge from 'react-devtools-shared/src/bridge';
7 -import Store from 'react-devtools-shared/src/devtools/store';
8 -import {IS_CHROME, IS_EDGE, getBrowserTheme} from './utils';
9 -import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger';
10 -import {
11 - getAppendComponentStack,
12 - getBreakOnConsoleErrors,
13 - getSavedComponentFilters,
14 - getShowInlineWarningsAndErrors,
15 - getHideConsoleLogsInStrictMode,
16 -} from 'react-devtools-shared/src/utils';
17 -import {
18 - localStorageGetItem,
19 - localStorageRemoveItem,
20 - localStorageSetItem,
21 -} from 'react-devtools-shared/src/storage';
22 -import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
23 -import {
24 - __DEBUG__,
25 - LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
26 -} from 'react-devtools-shared/src/constants';
27 -import {logEvent} from 'react-devtools-shared/src/Logger';
28 -
29 -const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
30 - 'React::DevTools::supportsProfiling';
31 -
32 -// rAF never fires on devtools_page (because it's in the background)
33 -// https://bugs.chromium.org/p/chromium/issues/detail?id=1241986#c31
34 -// Since we render React elements here, we need to polyfill it with setTimeout
35 -// The polyfill is based on https://gist.github.com/jalbam/5fe05443270fa6d8136238ec72accbc0
36 -const FRAME_TIME = 16;
37 -let lastTime = 0;
38 -window.requestAnimationFrame = function (callback, element) {
39 - const now = window.performance.now();
40 - const nextTime = Math.max(lastTime + FRAME_TIME, now);
41 - return setTimeout(function () {
42 - callback((lastTime = nextTime));
43 - }, nextTime - now);
44 -};
45 -window.cancelAnimationFrame = clearTimeout;
46 -
47 -let panelCreated = false;
48 -
49 -// The renderer interface can't read saved component filters directly,
50 -// because they are stored in localStorage within the context of the extension.
51 -// Instead it relies on the extension to pass filters through.
52 -function syncSavedPreferences() {
53 - chrome.devtools.inspectedWindow.eval(
54 - `window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
55 - getAppendComponentStack(),
56 - )};
57 - window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify(
58 - getBreakOnConsoleErrors(),
59 - )};
60 - window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
61 - getSavedComponentFilters(),
62 - )};
63 - window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify(
64 - getShowInlineWarningsAndErrors(),
65 - )};
66 - window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = ${JSON.stringify(
67 - getHideConsoleLogsInStrictMode(),
68 - )};
69 - window.__REACT_DEVTOOLS_BROWSER_THEME__ = ${JSON.stringify(
70 - getBrowserTheme(),
71 - )};`,
72 - );
73 -}
74 -
75 -syncSavedPreferences();
76 -
77 -function createPanelIfReactLoaded() {
78 - if (panelCreated) {
79 - return;
80 - }
81 -
82 - chrome.devtools.inspectedWindow.eval(
83 - 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
84 - function (pageHasReact, error) {
85 - if (!pageHasReact || panelCreated) {
86 - return;
87 - }
88 -
89 - panelCreated = true;
90 -
91 - clearInterval(loadCheckInterval);
92 -
93 - let bridge = null;
94 - let store = null;
95 -
96 - let profilingData = null;
97 -
98 - let componentsPortalContainer = null;
99 - let profilerPortalContainer = null;
100 -
101 - let cloneStyleTags = null;
102 - let mostRecentOverrideTab = null;
103 - let render = null;
104 - let root = null;
105 -
106 - const tabId = chrome.devtools.inspectedWindow.tabId;
107 -
108 - registerDevToolsEventLogger('extension', async () => {
109 - // TODO: after we upgrade to Manifest V3, chrome.tabs.query returns a Promise
110 - // without the callback.
111 - return new Promise(resolve => {
112 - chrome.tabs.query({active: true, currentWindow: true}, tabs => {
113 - resolve({
114 - page_url: tabs[0]?.url,
115 - });
116 - });
117 - });
118 - });
119 -
120 - function initBridgeAndStore() {
121 - const port = chrome.runtime.connect({
122 - name: String(tabId),
123 - });
124 - // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation,
125 - // so it makes no sense to handle it here.
126 -
127 - bridge = new Bridge({
128 - listen(fn) {
129 - const listener = message => fn(message);
130 - // Store the reference so that we unsubscribe from the same object.
131 - const portOnMessage = port.onMessage;
132 - portOnMessage.addListener(listener);
133 - return () => {
134 - portOnMessage.removeListener(listener);
135 - };
136 - },
137 - send(event: string, payload: any, transferable?: Array<any>) {
138 - port.postMessage({event, payload}, transferable);
139 - },
140 - });
141 - bridge.addListener('reloadAppForProfiling', () => {
142 - localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
143 - chrome.devtools.inspectedWindow.eval('window.location.reload();');
144 - });
145 - bridge.addListener('syncSelectionToNativeElementsPanel', () => {
146 - setBrowserSelectionFromReact();
147 - });
148 -
149 - // This flag lets us tip the Store off early that we expect to be profiling.
150 - // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab,
151 - // after a user has clicked the "reload and profile" button.
152 - let isProfiling = false;
153 - let supportsProfiling = false;
154 - if (
155 - localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true'
156 - ) {
157 - supportsProfiling = true;
158 - isProfiling = true;
159 - localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY);
160 - }
161 -
162 - if (store !== null) {
163 - profilingData = store.profilerStore.profilingData;
164 - }
165 -
166 - bridge.addListener('extensionBackendInitialized', () => {
167 - // Initialize the renderer's trace-updates setting.
168 - // This handles the case of navigating to a new page after the DevTools have already been shown.
169 - bridge.send(
170 - 'setTraceUpdatesEnabled',
171 - localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) ===
172 - 'true',
173 - );
174 - });
175 -
176 - store = new Store(bridge, {
177 - isProfiling,
178 - supportsReloadAndProfile: IS_CHROME || IS_EDGE,
179 - supportsProfiling,
180 - // At this time, the timeline can only parse Chrome performance profiles.
181 - supportsTimeline: IS_CHROME,
182 - supportsTraceUpdates: true,
183 - });
184 - if (!isProfiling) {
185 - store.profilerStore.profilingData = profilingData;
186 - }
187 -
188 - // Initialize the backend only once the Store has been initialized.
189 - // Otherwise the Store may miss important initial tree op codes.
190 - if (IS_CHROME || IS_EDGE) {
191 - chrome.runtime.sendMessage({
192 - source: 'react-devtools-main',
193 - payload: {
194 - type: 'react-devtools-inject-backend-manager',
195 - tabId,
196 - },
197 - });
198 - } else {
199 - // Firefox does not support executing script in ExecutionWorld.MAIN from content script.
200 - // see prepareInjection.js
201 - chrome.devtools.inspectedWindow.eval(
202 - `window.postMessage({ source: 'react-devtools-inject-backend-manager' }, '*');`,
203 - function (response, evalError) {
204 - if (evalError) {
205 - console.error(evalError);
206 - }
207 - },
208 - );
209 - }
210 -
211 - const viewAttributeSourceFunction = (id, path) => {
212 - const rendererID = store.getRendererIDForElement(id);
213 - if (rendererID != null) {
214 - // Ask the renderer interface to find the specified attribute,
215 - // and store it as a global variable on the window.
216 - bridge.send('viewAttributeSource', {id, path, rendererID});
217 -
218 - setTimeout(() => {
219 - // Ask Chrome to display the location of the attribute,
220 - // assuming the renderer found a match.
221 - chrome.devtools.inspectedWindow.eval(`
222 - if (window.$attribute != null) {
223 - inspect(window.$attribute);
224 - }
225 - `);
226 - }, 100);
227 - }
228 - };
229 -
230 - const viewElementSourceFunction = id => {
231 - const rendererID = store.getRendererIDForElement(id);
232 - if (rendererID != null) {
233 - // Ask the renderer interface to determine the component function,
234 - // and store it as a global variable on the window
235 - bridge.send('viewElementSource', {id, rendererID});
236 -
237 - setTimeout(() => {
238 - // Ask Chrome to display the location of the component function,
239 - // or a render method if it is a Class (ideally Class instance, not type)
240 - // assuming the renderer found one.
241 - chrome.devtools.inspectedWindow.eval(`
242 - if (window.$type != null) {
243 - if (
244 - window.$type &&
245 - window.$type.prototype &&
246 - window.$type.prototype.isReactComponent
247 - ) {
248 - // inspect Component.render, not constructor
249 - inspect(window.$type.prototype.render);
250 - } else {
251 - // inspect Functional Component
252 - inspect(window.$type);
253 - }
254 - }
255 - `);
256 - }, 100);
257 - }
258 - };
259 -
260 - const viewUrlSourceFunction = (url, line, col) => {
261 - chrome.devtools.panels.openResource(url, line, col);
262 - };
263 -
264 - let debugIDCounter = 0;
265 -
266 - // For some reason in Firefox, chrome.runtime.sendMessage() from a content script
267 - // never reaches the chrome.runtime.onMessage event listener.
268 - let fetchFileWithCaching = null;
269 - if (IS_CHROME) {
270 - const fetchFromNetworkCache = (url, resolve, reject) => {
271 - // Debug ID allows us to avoid re-logging (potentially long) URL strings below,
272 - // while also still associating (potentially) interleaved logs with the original request.
273 - let debugID = null;
274 -
275 - if (__DEBUG__) {
276 - debugID = debugIDCounter++;
277 - console.log(`[main] fetchFromNetworkCache(${debugID})`, url);
278 - }
279 -
280 - chrome.devtools.network.getHAR(harLog => {
281 - for (let i = 0; i < harLog.entries.length; i++) {
282 - const entry = harLog.entries[i];
283 - if (url === entry.request.url) {
284 - if (__DEBUG__) {
285 - console.log(
286 - `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`,
287 - url,
288 - );
289 - }
290 -
291 - entry.getContent(content => {
292 - if (content) {
293 - if (__DEBUG__) {
294 - console.log(
295 - `[main] fetchFromNetworkCache(${debugID}) Content retrieved`,
296 - );
297 - }
298 -
299 - resolve(content);
300 - } else {
301 - if (__DEBUG__) {
302 - console.log(
303 - `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`,
304 - content,
305 - );
306 - }
307 -
308 - // Edge case where getContent() returned null; fall back to fetch.
309 - fetchFromPage(url, resolve, reject);
310 - }
311 - });
312 -
313 - return;
314 - }
315 - }
316 -
317 - if (__DEBUG__) {
318 - console.log(
319 - `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`,
320 - );
321 - }
322 -
323 - // No matching URL found; fall back to fetch.
324 - fetchFromPage(url, resolve, reject);
325 - });
326 - };
327 -
328 - const fetchFromPage = (url, resolve, reject) => {
329 - if (__DEBUG__) {
330 - console.log('[main] fetchFromPage()', url);
331 - }
332 -
333 - function onPortMessage({payload, source}) {
334 - if (source === 'react-devtools-content-script') {
335 - switch (payload?.type) {
336 - case 'fetch-file-with-cache-complete':
337 - chrome.runtime.onMessage.removeListener(onPortMessage);
338 - resolve(payload.value);
339 - break;
340 - case 'fetch-file-with-cache-error':
341 - chrome.runtime.onMessage.removeListener(onPortMessage);
342 - reject(payload.value);
343 - break;
344 - }
345 - }
346 - }
347 -
348 - chrome.runtime.onMessage.addListener(onPortMessage);
349 -
350 - chrome.devtools.inspectedWindow.eval(`
351 - window.postMessage({
352 - source: 'react-devtools-extension',
353 - payload: {
354 - type: 'fetch-file-with-cache',
355 - url: "${url}",
356 - },
357 - });
358 - `);
359 - };
360 -
361 - // Fetching files from the extension won't make use of the network cache
362 - // for resources that have already been loaded by the page.
363 - // This helper function allows the extension to request files to be fetched
364 - // by the content script (running in the page) to increase the likelihood of a cache hit.
365 - fetchFileWithCaching = url => {
366 - return new Promise((resolve, reject) => {
367 - // Try fetching from the Network cache first.
368 - // If DevTools was opened after the page started loading, we may have missed some requests.
369 - // So fall back to a fetch() from the page and hope we get a cached response that way.
370 - fetchFromNetworkCache(url, resolve, reject);
371 - });
372 - };
373 - }
374 -
375 - // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
376 - const hookNamesModuleLoaderFunction = () =>
377 - import(
378 - /* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
379 - );
380 -
381 - root = createRoot(document.createElement('div'));
382 -
383 - render = (overrideTab = mostRecentOverrideTab) => {
384 - mostRecentOverrideTab = overrideTab;
385 - root.render(
386 - createElement(DevTools, {
387 - bridge,
388 - browserTheme: getBrowserTheme(),
389 - componentsPortalContainer,
390 - enabledInspectedElementContextMenu: true,
391 - fetchFileWithCaching,
392 - hookNamesModuleLoaderFunction,
393 - overrideTab,
394 - profilerPortalContainer,
395 - showTabBar: false,
396 - store,
397 - warnIfUnsupportedVersionDetected: true,
398 - viewAttributeSourceFunction,
399 - viewElementSourceFunction,
400 - viewUrlSourceFunction,
401 - }),
402 - );
403 - };
404 -
405 - render();
406 - }
407 -
408 - cloneStyleTags = () => {
409 - const linkTags = [];
410 - // eslint-disable-next-line no-for-of-loops/no-for-of-loops
411 - for (const linkTag of document.getElementsByTagName('link')) {
412 - if (linkTag.rel === 'stylesheet') {
413 - const newLinkTag = document.createElement('link');
414 - // eslint-disable-next-line no-for-of-loops/no-for-of-loops
415 - for (const attribute of linkTag.attributes) {
416 - newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue);
417 - }
418 - linkTags.push(newLinkTag);
419 - }
420 - }
421 - return linkTags;
422 - };
423 -
424 - initBridgeAndStore();
425 -
426 - function ensureInitialHTMLIsCleared(container) {
427 - if (container._hasInitialHTMLBeenCleared) {
428 - return;
429 - }
430 - container.innerHTML = '';
431 - container._hasInitialHTMLBeenCleared = true;
432 - }
433 -
434 - function setBrowserSelectionFromReact() {
435 - // This is currently only called on demand when you press "view DOM".
436 - // In the future, if Chrome adds an inspect() that doesn't switch tabs,
437 - // we could make this happen automatically when you select another component.
438 - chrome.devtools.inspectedWindow.eval(
439 - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
440 - '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
441 - 'false',
442 - (didSelectionChange, evalError) => {
443 - if (evalError) {
444 - console.error(evalError);
445 - }
446 - },
447 - );
448 - }
449 -
450 - function setReactSelectionFromBrowser() {
451 - // When the user chooses a different node in the browser Elements tab,
452 - // copy it over to the hook object so that we can sync the selection.
453 - chrome.devtools.inspectedWindow.eval(
454 - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
455 - '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
456 - 'false',
457 - (didSelectionChange, evalError) => {
458 - if (evalError) {
459 - console.error(evalError);
460 - } else if (didSelectionChange) {
461 - // Remember to sync the selection next time we show Components tab.
462 - needsToSyncElementSelection = true;
463 - }
464 - },
465 - );
466 - }
467 -
468 - setReactSelectionFromBrowser();
469 - chrome.devtools.panels.elements.onSelectionChanged.addListener(() => {
470 - setReactSelectionFromBrowser();
471 - });
472 -
473 - let currentPanel = null;
474 - let needsToSyncElementSelection = false;
475 -
476 - chrome.devtools.panels.create(
477 - IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components',
478 - IS_EDGE ? 'icons/production.svg' : '',
479 - 'panel.html',
480 - extensionPanel => {
481 - extensionPanel.onShown.addListener(panel => {
482 - if (needsToSyncElementSelection) {
483 - needsToSyncElementSelection = false;
484 - bridge.send('syncSelectionFromNativeElementsPanel');
485 - }
486 -
487 - if (currentPanel === panel) {
488 - return;
489 - }
490 -
491 - currentPanel = panel;
492 - componentsPortalContainer = panel.container;
493 -
494 - if (componentsPortalContainer != null) {
495 - ensureInitialHTMLIsCleared(componentsPortalContainer);
496 - render('components');
497 - panel.injectStyles(cloneStyleTags);
498 - logEvent({event_name: 'selected-components-tab'});
499 - }
500 - });
501 - extensionPanel.onHidden.addListener(panel => {
502 - // TODO: Stop highlighting and stuff.
503 - });
504 - },
505 - );
506 -
507 - chrome.devtools.panels.create(
508 - IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler',
509 - IS_EDGE ? 'icons/production.svg' : '',
510 - 'panel.html',
511 - extensionPanel => {
512 - extensionPanel.onShown.addListener(panel => {
513 - if (currentPanel === panel) {
514 - return;
515 - }
516 -
517 - currentPanel = panel;
518 - profilerPortalContainer = panel.container;
519 -
520 - if (profilerPortalContainer != null) {
521 - ensureInitialHTMLIsCleared(profilerPortalContainer);
522 - render('profiler');
523 - panel.injectStyles(cloneStyleTags);
524 - logEvent({event_name: 'selected-profiler-tab'});
525 - }
526 - });
527 - },
528 - );
529 -
530 - chrome.devtools.network.onNavigated.removeListener(checkPageForReact);
531 -
532 - // Re-initialize DevTools panel when a new page is loaded.
533 - chrome.devtools.network.onNavigated.addListener(function onNavigated() {
534 - // Re-initialize saved filters on navigation,
535 - // since global values stored on window get reset in this case.
536 - syncSavedPreferences();
537 -
538 - // It's easiest to recreate the DevTools panel (to clean up potential stale state).
539 - // We can revisit this in the future as a small optimization.
540 - flushSync(() => root.unmount());
541 -
542 - initBridgeAndStore();
543 - });
544 - },
545 - );
546 -}
547 -
548 -// Load (or reload) the DevTools extension when the user navigates to a new page.
549 -function checkPageForReact() {
550 - syncSavedPreferences();
551 - createPanelIfReactLoaded();
552 -}
553 -
554 -chrome.devtools.network.onNavigated.addListener(checkPageForReact);
555 -
556 -// Check to see if React has loaded once per second in case React is added
557 -// after page load
558 -const loadCheckInterval = setInterval(function () {
559 - createPanelIfReactLoaded();
560 -}, 1000);
561 -
562 -createPanelIfReactLoaded();
packages/react-devtools-extensions/src/main/cloneStyleTags.js new
+21
@@ -0,0 +1,21 @@
1 +function cloneStyleTags() {
2 + const linkTags = [];
3 +
4 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
5 + for (const linkTag of document.getElementsByTagName('link')) {
6 + if (linkTag.rel === 'stylesheet') {
7 + const newLinkTag = document.createElement('link');
8 +
9 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
10 + for (const attribute of linkTag.attributes) {
11 + newLinkTag.setAttribute(attribute.nodeName, attribute.nodeValue);
12 + }
13 +
14 + linkTags.push(newLinkTag);
15 + }
16 + }
17 +
18 + return linkTags;
19 +}
20 +
21 +export default cloneStyleTags;
packages/react-devtools-extensions/src/main/elementSelection.js new
+42
@@ -0,0 +1,42 @@
1 +/* global chrome */
2 +
3 +export function setBrowserSelectionFromReact() {
4 + // This is currently only called on demand when you press "view DOM".
5 + // In the future, if Chrome adds an inspect() that doesn't switch tabs,
6 + // we could make this happen automatically when you select another component.
7 + chrome.devtools.inspectedWindow.eval(
8 + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
9 + '(inspect(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0), true) :' +
10 + 'false',
11 + (didSelectionChange, evalError) => {
12 + if (evalError) {
13 + console.error(evalError);
14 + }
15 + },
16 + );
17 +}
18 +
19 +export function setReactSelectionFromBrowser(bridge) {
20 + // When the user chooses a different node in the browser Elements tab,
21 + // copy it over to the hook object so that we can sync the selection.
22 + chrome.devtools.inspectedWindow.eval(
23 + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 !== $0) ?' +
24 + '(window.__REACT_DEVTOOLS_GLOBAL_HOOK__.$0 = $0, true) :' +
25 + 'false',
26 + (didSelectionChange, evalError) => {
27 + if (evalError) {
28 + console.error(evalError);
29 + } else if (didSelectionChange) {
30 + if (!bridge) {
31 + console.error(
32 + 'Browser element selection changed, but bridge was not initialized',
33 + );
34 + return;
35 + }
36 +
37 + // Remember to sync the selection next time we show Components tab.
38 + bridge.send('syncSelectionFromNativeElementsPanel');
39 + }
40 + },
41 + );
42 +}
packages/react-devtools-extensions/src/main/getProfilingFlags.js new
+23
@@ -0,0 +1,23 @@
1 +import {
2 + localStorageGetItem,
3 + localStorageRemoveItem,
4 +} from 'react-devtools-shared/src/storage';
5 +import {LOCAL_STORAGE_SUPPORTS_PROFILING_KEY} from 'react-devtools-shared/src/constants';
6 +
7 +function getProfilingFlags() {
8 + // This flag lets us tip the Store off early that we expect to be profiling.
9 + // This avoids flashing a temporary "Profiling not supported" message in the Profiler tab,
10 + // after a user has clicked the "reload and profile" button.
11 + let isProfiling = false;
12 + let supportsProfiling = false;
13 +
14 + if (localStorageGetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY) === 'true') {
15 + supportsProfiling = true;
16 + isProfiling = true;
17 + localStorageRemoveItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY);
18 + }
19 +
20 + return {isProfiling, supportsProfiling};
21 +}
22 +
23 +export default getProfilingFlags;
packages/react-devtools-extensions/src/main/index.js new
+525
@@ -0,0 +1,525 @@
1 +/* global chrome */
2 +
3 +import {createElement} from 'react';
4 +import {flushSync} from 'react-dom';
5 +import {createRoot} from 'react-dom/client';
6 +import Bridge from 'react-devtools-shared/src/bridge';
7 +import Store from 'react-devtools-shared/src/devtools/store';
8 +import {IS_CHROME, IS_EDGE, getBrowserTheme, IS_FIREFOX} from '../utils';
9 +import {
10 + localStorageGetItem,
11 + localStorageSetItem,
12 +} from 'react-devtools-shared/src/storage';
13 +import DevTools from 'react-devtools-shared/src/devtools/views/DevTools';
14 +import {
15 + __DEBUG__,
16 + LOCAL_STORAGE_SUPPORTS_PROFILING_KEY,
17 + LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
18 +} from 'react-devtools-shared/src/constants';
19 +import {logEvent} from 'react-devtools-shared/src/Logger';
20 +
21 +import {
22 + setBrowserSelectionFromReact,
23 + setReactSelectionFromBrowser,
24 +} from './elementSelection';
25 +import cloneStyleTags from './cloneStyleTags';
26 +import injectBackendManager from './injectBackendManager';
27 +import syncSavedPreferences from './syncSavedPreferences';
28 +import registerEventsLogger from './registerEventsLogger';
29 +import getProfilingFlags from './getProfilingFlags';
30 +import './requestAnimationFramePolyfill';
31 +
32 +function executeIfReactHasLoaded(callback) {
33 + chrome.devtools.inspectedWindow.eval(
34 + 'window.__REACT_DEVTOOLS_GLOBAL_HOOK__ && window.__REACT_DEVTOOLS_GLOBAL_HOOK__.renderers.size > 0',
35 + (pageHasReact, exceptionInfo) => {
36 + if (exceptionInfo) {
37 + const {code, description, isError, isException, value} = exceptionInfo;
38 +
39 + if (isException) {
40 + console.error(
41 + `Received error while checking if react has loaded: ${value}`,
42 + );
43 + return;
44 + }
45 +
46 + if (isError) {
47 + console.error(
48 + `Received error with code ${code} while checking if react has loaded: ${description}`,
49 + );
50 + return;
51 + }
52 + }
53 +
54 + if (pageHasReact) {
55 + callback();
56 + }
57 + },
58 + );
59 +}
60 +
61 +function createBridge() {
62 + bridge = new Bridge({
63 + listen(fn) {
64 + const listener = message => fn(message);
65 + // Store the reference so that we unsubscribe from the same object.
66 + const portOnMessage = port.onMessage;
67 + portOnMessage.addListener(listener);
68 +
69 + return () => {
70 + portOnMessage.removeListener(listener);
71 + };
72 + },
73 +
74 + send(event: string, payload: any, transferable?: Array<any>) {
75 + port.postMessage({event, payload}, transferable);
76 + },
77 + });
78 +
79 + bridge.addListener('reloadAppForProfiling', () => {
80 + localStorageSetItem(LOCAL_STORAGE_SUPPORTS_PROFILING_KEY, 'true');
81 + chrome.devtools.inspectedWindow.eval('window.location.reload();');
82 + });
83 +
84 + bridge.addListener(
85 + 'syncSelectionToNativeElementsPanel',
86 + setBrowserSelectionFromReact,
87 + );
88 +
89 + bridge.addListener('extensionBackendInitialized', () => {
90 + // Initialize the renderer's trace-updates setting.
91 + // This handles the case of navigating to a new page after the DevTools have already been shown.
92 + bridge.send(
93 + 'setTraceUpdatesEnabled',
94 + localStorageGetItem(LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY) === 'true',
95 + );
96 + });
97 +
98 + const onBrowserElementSelectionChanged = () =>
99 + setReactSelectionFromBrowser(bridge);
100 + const onBridgeShutdown = () => {
101 + chrome.devtools.panels.elements.onSelectionChanged.removeListener(
102 + onBrowserElementSelectionChanged,
103 + );
104 + };
105 +
106 + bridge.addListener('shutdown', onBridgeShutdown);
107 +
108 + chrome.devtools.panels.elements.onSelectionChanged.addListener(
109 + onBrowserElementSelectionChanged,
110 + );
111 +}
112 +
113 +function createBridgeAndStore() {
114 + createBridge();
115 +
116 + const {isProfiling, supportsProfiling} = getProfilingFlags();
117 +
118 + store = new Store(bridge, {
119 + isProfiling,
120 + supportsReloadAndProfile: IS_CHROME || IS_EDGE,
121 + supportsProfiling,
122 + // At this time, the timeline can only parse Chrome performance profiles.
123 + supportsTimeline: IS_CHROME,
124 + supportsTraceUpdates: true,
125 + });
126 +
127 + if (!isProfiling) {
128 + // We previously stored this in performCleanup function
129 + store.profilerStore.profilingData = profilingData;
130 + }
131 +
132 + // Initialize the backend only once the Store has been initialized.
133 + // Otherwise, the Store may miss important initial tree op codes.
134 + injectBackendManager(chrome.devtools.inspectedWindow.tabId);
135 +
136 + const viewAttributeSourceFunction = (id, path) => {
137 + const rendererID = store.getRendererIDForElement(id);
138 + if (rendererID != null) {
139 + // Ask the renderer interface to find the specified attribute,
140 + // and store it as a global variable on the window.
141 + bridge.send('viewAttributeSource', {id, path, rendererID});
142 +
143 + setTimeout(() => {
144 + // Ask Chrome to display the location of the attribute,
145 + // assuming the renderer found a match.
146 + chrome.devtools.inspectedWindow.eval(`
147 + if (window.$attribute != null) {
148 + inspect(window.$attribute);
149 + }
150 + `);
151 + }, 100);
152 + }
153 + };
154 +
155 + const viewElementSourceFunction = id => {
156 + const rendererID = store.getRendererIDForElement(id);
157 + if (rendererID != null) {
158 + // Ask the renderer interface to determine the component function,
159 + // and store it as a global variable on the window
160 + bridge.send('viewElementSource', {id, rendererID});
161 +
162 + setTimeout(() => {
163 + // Ask Chrome to display the location of the component function,
164 + // or a render method if it is a Class (ideally Class instance, not type)
165 + // assuming the renderer found one.
166 + chrome.devtools.inspectedWindow.eval(`
167 + if (window.$type != null) {
168 + if (
169 + window.$type &&
170 + window.$type.prototype &&
171 + window.$type.prototype.isReactComponent
172 + ) {
173 + // inspect Component.render, not constructor
174 + inspect(window.$type.prototype.render);
175 + } else {
176 + // inspect Functional Component
177 + inspect(window.$type);
178 + }
179 + }
180 + `);
181 + }, 100);
182 + }
183 + };
184 +
185 + let debugIDCounter = 0;
186 +
187 + // For some reason in Firefox, chrome.runtime.sendMessage() from a content script
188 + // never reaches the chrome.runtime.onMessage event listener.
189 + let fetchFileWithCaching = null;
190 + if (IS_CHROME) {
191 + const fetchFromNetworkCache = (url, resolve, reject) => {
192 + // Debug ID allows us to avoid re-logging (potentially long) URL strings below,
193 + // while also still associating (potentially) interleaved logs with the original request.
194 + let debugID = null;
195 +
196 + if (__DEBUG__) {
197 + debugID = debugIDCounter++;
198 + console.log(`[main] fetchFromNetworkCache(${debugID})`, url);
199 + }
200 +
201 + chrome.devtools.network.getHAR(harLog => {
202 + for (let i = 0; i < harLog.entries.length; i++) {
203 + const entry = harLog.entries[i];
204 + if (url === entry.request.url) {
205 + if (__DEBUG__) {
206 + console.log(
207 + `[main] fetchFromNetworkCache(${debugID}) Found matching URL in HAR`,
208 + url,
209 + );
210 + }
211 +
212 + entry.getContent(content => {
213 + if (content) {
214 + if (__DEBUG__) {
215 + console.log(
216 + `[main] fetchFromNetworkCache(${debugID}) Content retrieved`,
217 + );
218 + }
219 +
220 + resolve(content);
221 + } else {
222 + if (__DEBUG__) {
223 + console.log(
224 + `[main] fetchFromNetworkCache(${debugID}) Invalid content returned by getContent()`,
225 + content,
226 + );
227 + }
228 +
229 + // Edge case where getContent() returned null; fall back to fetch.
230 + fetchFromPage(url, resolve, reject);
231 + }
232 + });
233 +
234 + return;
235 + }
236 + }
237 +
238 + if (__DEBUG__) {
239 + console.log(
240 + `[main] fetchFromNetworkCache(${debugID}) No cached request found in getHAR()`,
241 + );
242 + }
243 +
244 + // No matching URL found; fall back to fetch.
245 + fetchFromPage(url, resolve, reject);
246 + });
247 + };
248 +
249 + const fetchFromPage = (url, resolve, reject) => {
250 + if (__DEBUG__) {
251 + console.log('[main] fetchFromPage()', url);
252 + }
253 +
254 + function onPortMessage({payload, source}) {
255 + if (source === 'react-devtools-content-script') {
256 + switch (payload?.type) {
257 + case 'fetch-file-with-cache-complete':
258 + chrome.runtime.onMessage.removeListener(onPortMessage);
259 + resolve(payload.value);
260 + break;
261 + case 'fetch-file-with-cache-error':
262 + chrome.runtime.onMessage.removeListener(onPortMessage);
263 + reject(payload.value);
264 + break;
265 + }
266 + }
267 + }
268 +
269 + chrome.runtime.onMessage.addListener(onPortMessage);
270 +
271 + chrome.devtools.inspectedWindow.eval(`
272 + window.postMessage({
273 + source: 'react-devtools-extension',
274 + payload: {
275 + type: 'fetch-file-with-cache',
276 + url: "${url}",
277 + },
278 + });
279 + `);
280 + };
281 +
282 + // Fetching files from the extension won't make use of the network cache
283 + // for resources that have already been loaded by the page.
284 + // This helper function allows the extension to request files to be fetched
285 + // by the content script (running in the page) to increase the likelihood of a cache hit.
286 + fetchFileWithCaching = url => {
287 + return new Promise((resolve, reject) => {
288 + // Try fetching from the Network cache first.
289 + // If DevTools was opened after the page started loading, we may have missed some requests.
290 + // So fall back to a fetch() from the page and hope we get a cached response that way.
291 + fetchFromNetworkCache(url, resolve, reject);
292 + });
293 + };
294 + }
295 +
296 + // TODO (Webpack 5) Hopefully we can remove this prop after the Webpack 5 migration.
297 + const hookNamesModuleLoaderFunction = () =>
298 + import(
299 + /* webpackChunkName: 'parseHookNames' */ 'react-devtools-shared/src/hooks/parseHookNames'
300 + );
301 +
302 + root = createRoot(document.createElement('div'));
303 +
304 + render = (overrideTab = mostRecentOverrideTab) => {
305 + mostRecentOverrideTab = overrideTab;
306 +
307 + root.render(
308 + createElement(DevTools, {
309 + bridge,
310 + browserTheme: getBrowserTheme(),
311 + componentsPortalContainer,
312 + enabledInspectedElementContextMenu: true,
313 + fetchFileWithCaching,
314 + hookNamesModuleLoaderFunction,
315 + overrideTab,
316 + profilerPortalContainer,
317 + showTabBar: false,
318 + store,
319 + warnIfUnsupportedVersionDetected: true,
320 + viewAttributeSourceFunction,
321 + viewElementSourceFunction,
322 + viewUrlSourceFunction,
323 + }),
324 + );
325 + };
326 +
327 + render();
328 +}
329 +
330 +const viewUrlSourceFunction = (url, line, col) => {
331 + chrome.devtools.panels.openResource(url, line, col);
332 +};
333 +
334 +function ensureInitialHTMLIsCleared(container) {
335 + if (container._hasInitialHTMLBeenCleared) {
336 + return;
337 + }
338 +
339 + container.innerHTML = '';
340 + container._hasInitialHTMLBeenCleared = true;
341 +}
342 +
343 +function createComponentsPanel() {
344 + if (componentsPortalContainer) {
345 + render('components');
346 +
347 + return;
348 + }
349 +
350 + chrome.devtools.panels.create(
351 + IS_CHROME || IS_EDGE ? '⚛️ Components' : 'Components',
352 + IS_EDGE ? 'icons/production.svg' : '',
353 + 'panel.html',
354 + createdPanel => {
355 + createdPanel.onShown.addListener(portal => {
356 + componentsPortalContainer = portal.container;
357 + if (componentsPortalContainer != null) {
358 + ensureInitialHTMLIsCleared(componentsPortalContainer);
359 +
360 + render('components');
361 + portal.injectStyles(cloneStyleTags);
362 +
363 + logEvent({event_name: 'selected-components-tab'});
364 + }
365 + });
366 +
367 + // TODO: we should listen to extension.onHidden to unmount some listeners
368 + // and potentially stop highlighting
369 + },
370 + );
371 +}
372 +
373 +function createProfilerPanel() {
374 + if (componentsPortalContainer) {
375 + render('profiler');
376 +
377 + return;
378 + }
379 +
380 + chrome.devtools.panels.create(
381 + IS_CHROME || IS_EDGE ? '⚛️ Profiler' : 'Profiler',
382 + IS_EDGE ? 'icons/production.svg' : '',
383 + 'panel.html',
384 + createdPanel => {
385 + createdPanel.onShown.addListener(portal => {
386 + profilerPortalContainer = portal.container;
387 + if (profilerPortalContainer != null) {
388 + ensureInitialHTMLIsCleared(profilerPortalContainer);
389 +
390 + render('profiler');
391 + portal.injectStyles(cloneStyleTags);
392 +
393 + logEvent({event_name: 'selected-profiler-tab'});
394 + }
395 + });
396 + },
397 + );
398 +}
399 +
400 +function performInTabNavigationCleanup() {
401 + if (store !== null) {
402 + // Store profiling data, so it can be used later
403 + profilingData = store.profilerStore.profilingData;
404 + }
405 +
406 + // If panels were already created, and we have already mounted React root to display
407 + // tabs (Components or Profiler), we should unmount root first and render them again
408 + if ((componentsPortalContainer || profilerPortalContainer) && root) {
409 + // It's easiest to recreate the DevTools panel (to clean up potential stale state).
410 + // We can revisit this in the future as a small optimization.
411 + // This should also emit bridge.shutdown, but only if this root was mounted
412 + flushSync(() => root.unmount());
413 + } else {
414 + // In case Browser DevTools were opened, but user never pressed on extension panels
415 + // They were never mounted and there is nothing to unmount, but we need to emit shutdown event
416 + // because bridge was already created
417 + bridge?.shutdown();
418 + }
419 +
420 + // Do not nullify componentsPanelPortal and profilerPanelPortal on purpose,
421 + // They are not recreated when user does in-tab navigation, and they can only be accessed via
422 + // callback in onShown listener, which is called only when panel has been shown
423 + // This event won't be emitted again after in-tab navigation, if DevTools panel keeps being opened
424 +
425 + // Do not clean mostRecentOverrideTab on purpose, so we remember last opened
426 + // React DevTools tab, when user does in-tab navigation
427 +
428 + store = null;
429 + bridge = null;
430 + render = null;
431 + root = null;
432 +
433 + port?.disconnect();
434 + port = null;
435 +}
436 +
437 +function performFullCleanup() {
438 + if ((componentsPortalContainer || profilerPortalContainer) && root) {
439 + // This should also emit bridge.shutdown, but only if this root was mounted
440 + flushSync(() => root.unmount());
441 + } else {
442 + bridge?.shutdown();
443 + }
444 +
445 + componentsPortalContainer = null;
446 + profilerPortalContainer = null;
447 + root = null;
448 +
449 + mostRecentOverrideTab = null;
450 + store = null;
451 + bridge = null;
452 + render = null;
453 +
454 + port?.disconnect();
455 + port = null;
456 +}
457 +
458 +function mountReactDevTools() {
459 + registerEventsLogger();
460 +
461 + const tabId = chrome.devtools.inspectedWindow.tabId;
462 + port = chrome.runtime.connect({
463 + name: String(tabId),
464 + });
465 +
466 + createBridgeAndStore();
467 +
468 + setReactSelectionFromBrowser(bridge);
469 +
470 + createComponentsPanel();
471 + createProfilerPanel();
472 +}
473 +
474 +// TODO: display some disclaimer if user performs in-tab navigation to non-react application
475 +// when React DevTools panels are already opened, currently we will display just blank white block
476 +function mountReactDevToolsWhenReactHasLoaded() {
477 + const checkIfReactHasLoaded = () => executeIfReactHasLoaded(onReactReady);
478 +
479 + // Check to see if React has loaded in case React is added after page load
480 + const reactPollingIntervalId = setInterval(() => {
481 + checkIfReactHasLoaded();
482 + }, 500);
483 +
484 + function onReactReady() {
485 + clearInterval(reactPollingIntervalId);
486 + mountReactDevTools();
487 + }
488 +
489 + checkIfReactHasLoaded();
490 +}
491 +
492 +let bridge = null;
493 +let store = null;
494 +
495 +let profilingData = null;
496 +
497 +let componentsPortalContainer = null;
498 +let profilerPortalContainer = null;
499 +
500 +let mostRecentOverrideTab = null;
501 +let render = null;
502 +let root = null;
503 +
504 +let port = null;
505 +
506 +// Re-initialize saved filters on navigation,
507 +// since global values stored on window get reset in this case.
508 +chrome.devtools.network.onNavigated.addListener(syncSavedPreferences);
509 +
510 +// Cleanup previous page state and remount everything
511 +chrome.devtools.network.onNavigated.addListener(() => {
512 + performInTabNavigationCleanup();
513 + mountReactDevToolsWhenReactHasLoaded();
514 +});
515 +
516 +// Should be emitted when browser DevTools are closed
517 +if (IS_FIREFOX) {
518 + // For some reason Firefox doesn't emit onBeforeUnload event
519 + window.addEventListener('unload', performFullCleanup);
520 +} else {
521 + window.addEventListener('beforeunload', performFullCleanup);
522 +}
523 +
524 +syncSavedPreferences();
525 +mountReactDevToolsWhenReactHasLoaded();
packages/react-devtools-extensions/src/main/injectBackendManager.js new
+30
@@ -0,0 +1,30 @@
1 +/* global chrome */
2 +
3 +import {IS_FIREFOX} from '../utils';
4 +
5 +function injectBackendManager(tabId) {
6 + if (IS_FIREFOX) {
7 + // Firefox does not support executing script in ExecutionWorld.MAIN from content script.
8 + // see prepareInjection.js
9 + chrome.devtools.inspectedWindow.eval(
10 + `window.postMessage({ source: 'react-devtools-inject-backend-manager' }, '*');`,
11 + function (response, evalError) {
12 + if (evalError) {
13 + console.error(evalError);
14 + }
15 + },
16 + );
17 +
18 + return;
19 + }
20 +
21 + chrome.runtime.sendMessage({
22 + source: 'react-devtools-main',
23 + payload: {
24 + type: 'react-devtools-inject-backend-manager',
25 + tabId,
26 + },
27 + });
28 +}
29 +
30 +export default injectBackendManager;
packages/react-devtools-extensions/src/main/registerEventsLogger.js new
+18
@@ -0,0 +1,18 @@
1 +/* global chrome */
2 +
3 +import {registerDevToolsEventLogger} from 'react-devtools-shared/src/registerDevToolsEventLogger';
4 +
5 +function registerEventsLogger() {
6 + registerDevToolsEventLogger('extension', async () => {
7 + // TODO: after we upgrade to Firefox Manifest V3, chrome.tabs.query returns a Promise without the callback.
8 + return new Promise(resolve => {
9 + chrome.tabs.query({active: true}, tabs => {
10 + resolve({
11 + page_url: tabs[0]?.url,
12 + });
13 + });
14 + });
15 + });
16 +}
17 +
18 +export default registerEventsLogger;
packages/react-devtools-extensions/src/main/requestAnimationFramePolyfill.js new
+17
@@ -0,0 +1,17 @@
1 +// rAF never fires on devtools_page (because it's in the background)
2 +// https://bugs.chromium.org/p/chromium/issues/detail?id=1241986#c31
3 +// Since we render React elements here, we need to polyfill it with setTimeout
4 +// The polyfill is based on https://gist.github.com/jalbam/5fe05443270fa6d8136238ec72accbc0
5 +const FRAME_TIME = 16;
6 +let lastTime = 0;
7 +
8 +window.requestAnimationFrame = function (callback, element) {
9 + const now = window.performance.now();
10 + const nextTime = Math.max(lastTime + FRAME_TIME, now);
11 +
12 + return setTimeout(function () {
13 + callback((lastTime = nextTime));
14 + }, nextTime - now);
15 +};
16 +
17 +window.cancelAnimationFrame = clearTimeout;
packages/react-devtools-extensions/src/main/syncSavedPreferences.js new
+38
@@ -0,0 +1,38 @@
1 +/* global chrome */
2 +
3 +import {
4 + getAppendComponentStack,
5 + getBreakOnConsoleErrors,
6 + getSavedComponentFilters,
7 + getShowInlineWarningsAndErrors,
8 + getHideConsoleLogsInStrictMode,
9 +} from 'react-devtools-shared/src/utils';
10 +import {getBrowserTheme} from 'react-devtools-extensions/src/utils';
11 +
12 +// The renderer interface can't read saved component filters directly,
13 +// because they are stored in localStorage within the context of the extension.
14 +// Instead it relies on the extension to pass filters through.
15 +function syncSavedPreferences() {
16 + chrome.devtools.inspectedWindow.eval(
17 + `window.__REACT_DEVTOOLS_APPEND_COMPONENT_STACK__ = ${JSON.stringify(
18 + getAppendComponentStack(),
19 + )};
20 + window.__REACT_DEVTOOLS_BREAK_ON_CONSOLE_ERRORS__ = ${JSON.stringify(
21 + getBreakOnConsoleErrors(),
22 + )};
23 + window.__REACT_DEVTOOLS_COMPONENT_FILTERS__ = ${JSON.stringify(
24 + getSavedComponentFilters(),
25 + )};
26 + window.__REACT_DEVTOOLS_SHOW_INLINE_WARNINGS_AND_ERRORS__ = ${JSON.stringify(
27 + getShowInlineWarningsAndErrors(),
28 + )};
29 + window.__REACT_DEVTOOLS_HIDE_CONSOLE_LOGS_IN_STRICT_MODE__ = ${JSON.stringify(
30 + getHideConsoleLogsInStrictMode(),
31 + )};
32 + window.__REACT_DEVTOOLS_BROWSER_THEME__ = ${JSON.stringify(
33 + getBrowserTheme(),
34 + )};`,
35 + );
36 +}
37 +
38 +export default syncSavedPreferences;
packages/react-devtools-extensions/webpack.config.js
+3 -3
@@ -55,9 +55,9 @@ module.exports = {
55 mode: __DEV__ ? 'development' : 'production',
56 devtool: __DEV__ ? 'cheap-module-source-map' : false,
57 entry: {
58 - background: './src/background.js',
59 - backendManager: './src/backendManager.js',
60 - main: './src/main.js',
58 + background: './src/background/index.js',
59 + backendManager: './src/contentScripts/backendManager.js',
60 + main: './src/main/index.js',
61 panel: './src/panel.js',
62 proxy: './src/contentScripts/proxy.js',
63 prepareInjection: './src/contentScripts/prepareInjection.js',
packages/react-devtools-shared/src/constants.js
+3
@@ -68,4 +68,7 @@ export const LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY =
68 export const LOCAL_STORAGE_HIDE_CONSOLE_LOGS_IN_STRICT_MODE =
69 'React::DevTools::hideConsoleLogsInStrictMode';
70
71 +export const LOCAL_STORAGE_SUPPORTS_PROFILING_KEY =
72 + 'React::DevTools::supportsProfiling';
73 +
74 export const PROFILER_EXPORT_VERSION = 5;
packages/react-devtools-shared/src/devtools/views/DevTools.js
+1
@@ -269,6 +269,7 @@ export default function DevTools({
269 useEffect(() => {
270 logEvent({event_name: 'loaded-dev-tools'});
271 }, []);
272 +
273 return (
274 <BridgeContext.Provider value={bridge}>
275 <StoreContext.Provider value={store}>