@samitouri / QOS-React-1 / commits / a27df56a5c

refactor[devtools/extension]: handle ports disconnection, instead of frequent reconnection (#27336)

- Instead of reconnecting ports from devtools page and proxy content script, now handling their disconnection properly - `proxy.js` is now dynamically registered as a content script, which loaded for each page. This will probably not work well for Firefox, since we are still on manifest v2, I will try to fix this in the next few PRs. - Handling the case when devtools page port was reconnected and bridge is still present. This could happen if user switches the tab and Chrome decides to kill service worker, devtools page port gets disconnected, and then user returns back to the tab. When port is reconnected, we check if bridge message listener is present, connecting them if so. - Added simple debounce when evaluating if page has react application running. We start this check in `chrome.network.onNavigated` listener, which is asynchronous. Also, this check itself is asynchronous, so previously we could mount React DevTools multiple times if navigates multiple times while `chrome.devtools.inspectedWindow.eval` (which is also asynchronous) can be executed. https://github.com/hoxyq/react/blob/00b7c4331819289548b40714aea12335368e10f4/packages/react-devtools-extensions/src/main/index.js#L575-L583 https://github.com/facebook/react/assets/28902667/9d519a77-145e-413c-b142-b5063223d073

Ruslan Lesiutin committed Sep 5, 2023 at 18:41 UTC a27df56a5cc54b77a9206d1344b457af4ec64b30
5 files changed +112 -111
packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js
+40 -22
@@ -2,26 +2,46 @@
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 - ];
5 +// Firefox doesn't support ExecutionWorld.MAIN yet
6 +// equivalent logic for Firefox is in prepareInjection.js
7 +const contentScriptsToInject = IS_FIREFOX
8 + ? [
9 + {
10 + id: '@react-devtools/proxy',
11 + js: ['build/proxy.js'],
12 + matches: ['<all_urls>'],
13 + persistAcrossSessions: true,
14 + runAt: 'document_idle',
15 + },
16 + ]
17 + : [
18 + {
19 + id: '@react-devtools/proxy',
20 + js: ['build/proxy.js'],
21 + matches: ['<all_urls>'],
22 + persistAcrossSessions: true,
23 + runAt: 'document_end',
24 + world: chrome.scripting.ExecutionWorld.ISOLATED,
25 + },
26 + {
27 + id: '@react-devtools/hook',
28 + js: ['build/installHook.js'],
29 + matches: ['<all_urls>'],
30 + persistAcrossSessions: true,
31 + runAt: 'document_start',
32 + world: chrome.scripting.ExecutionWorld.MAIN,
33 + },
34 + {
35 + id: '@react-devtools/renderer',
36 + js: ['build/renderer.js'],
37 + matches: ['<all_urls>'],
38 + persistAcrossSessions: true,
39 + runAt: 'document_start',
40 + world: chrome.scripting.ExecutionWorld.MAIN,
41 + },
42 + ];
43
44 +async function dynamicallyInjectContentScripts() {
45 try {
46 const alreadyRegisteredContentScripts =
47 await chrome.scripting.getRegisteredContentScripts();
@@ -48,6 +68,4 @@ async function dynamicallyInjectContentScripts() {
68 }
69 }
70
51 -if (!IS_FIREFOX) {
52 - dynamicallyInjectContentScripts();
53 -}
71 +dynamicallyInjectContentScripts();
packages/react-devtools-extensions/src/background/index.js
+19 -60
@@ -7,7 +7,6 @@ import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
7 import './dynamicallyInjectContentScripts';
8 import './tabsManager';
9 import setExtensionIconAndPopup from './setExtensionIconAndPopup';
10 -import injectProxy from './injectProxy';
10
11 /*
12 {
@@ -39,18 +38,6 @@ function registerExtensionPort(port, tabId) {
38 ports[tabId].disconnectPipe?.();
39
40 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 - }
41 });
42 }
43
@@ -59,36 +46,12 @@ function registerProxyPort(port, tabId) {
46
47 // In case proxy port was disconnected from the other end, from content script
48 // 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
49 + // This listener should never be called when we call port.disconnect() from this (background/index.js) script
50 port.onDisconnect.addListener(() => {
51 ports[tabId].disconnectPipe?.();
52
53 delete ports[tabId].proxy;
54 });
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();
55 }
56
57 function isNumeric(str: string): boolean {
@@ -100,14 +63,21 @@ chrome.runtime.onConnect.addListener(port => {
63 // Proxy content script is executed in tab, so it should have it specified.
64 const tabId = port.sender.tab.id;
65
66 + if (ports[tabId]?.proxy) {
67 + port.disconnect();
68 + return;
69 + }
70 +
71 registerTab(tabId);
72 registerProxyPort(port, tabId);
73
106 - connectExtensionAndProxyPorts(
107 - ports[tabId].extension,
108 - ports[tabId].proxy,
109 - tabId,
110 - );
74 + if (ports[tabId].extension) {
75 + connectExtensionAndProxyPorts(
76 + ports[tabId].extension,
77 + ports[tabId].proxy,
78 + tabId,
79 + );
80 + }
81
82 return;
83 }
@@ -115,27 +85,16 @@ chrome.runtime.onConnect.addListener(port => {
85 if (isNumeric(port.name)) {
86 // Extension port doesn't have tab id specified, because its sender is the extension.
87 const tabId = +port.name;
118 - const extensionPortAlreadyConnected = ports[tabId]?.extension != null;
119 -
120 - // Handle the case when extension port was disconnected and we were not notified
121 - if (extensionPortAlreadyConnected) {
122 - ports[tabId].disconnectPipe?.();
123 - }
88
89 registerTab(tabId);
90 registerExtensionPort(port, tabId);
91
128 - if (extensionPortAlreadyConnected) {
129 - const proxyPort = ports[tabId].proxy;
130 -
131 - // Avoid re-injecting the content script, we might end up in a situation
132 - // where we would have multiple proxy ports opened and trying to reconnect
133 - if (proxyPort) {
134 - clearReconnectionTimeout(proxyPort);
135 - reconnectProxyPort(proxyPort, tabId);
136 - }
137 - } else {
138 - injectProxy(tabId);
92 + if (ports[tabId].proxy) {
93 + connectExtensionAndProxyPorts(
94 + ports[tabId].extension,
95 + ports[tabId].proxy,
96 + tabId,
97 + );
98 }
99
100 return;
packages/react-devtools-extensions/src/contentScripts/proxy.js
-12
@@ -30,18 +30,6 @@ 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 -
33 window.postMessage(
34 {
35 source: 'react-devtools-content-script',
packages/react-devtools-extensions/src/main/debounce.js new
+10
@@ -0,0 +1,10 @@
1 +function debounce(fn, timeout) {
2 + let executionTimeoutId = null;
3 +
4 + return (...args) => {
5 + clearTimeout(executionTimeoutId);
6 + executionTimeoutId = setTimeout(fn, timeout, ...args);
7 + };
8 +}
9 +
10 +export default debounce;
packages/react-devtools-extensions/src/main/index.js
+43 -17
@@ -27,6 +27,7 @@ import injectBackendManager from './injectBackendManager';
27 import syncSavedPreferences from './syncSavedPreferences';
28 import registerEventsLogger from './registerEventsLogger';
29 import getProfilingFlags from './getProfilingFlags';
30 +import debounce from './debounce';
31 import './requestAnimationFramePolyfill';
32
33 // Try polling for at least 5 seconds, in case if it takes too long to load react
@@ -34,13 +35,13 @@ const REACT_POLLING_TICK_COOLDOWN = 250;
35 const REACT_POLLING_ATTEMPTS_THRESHOLD = 20;
36
37 let reactPollingTimeoutId = null;
37 -function clearReactPollingTimeout() {
38 +export function clearReactPollingTimeout() {
39 clearTimeout(reactPollingTimeoutId);
40 reactPollingTimeoutId = null;
41 }
42
42 -function executeIfReactHasLoaded(callback, attempt = 1) {
43 - reactPollingTimeoutId = null;
43 +export function executeIfReactHasLoaded(callback, attempt = 1) {
44 + clearReactPollingTimeout();
45
46 if (attempt > REACT_POLLING_ATTEMPTS_THRESHOLD) {
47 return;
@@ -81,21 +82,26 @@ function executeIfReactHasLoaded(callback, attempt = 1) {
82 );
83 }
84
85 +let lastSubscribedBridgeListener = null;
86 +
87 function createBridge() {
88 bridge = new Bridge({
89 listen(fn) {
87 - const listener = message => fn(message);
90 + const bridgeListener = message => fn(message);
91 // Store the reference so that we unsubscribe from the same object.
92 const portOnMessage = port.onMessage;
90 - portOnMessage.addListener(listener);
93 + portOnMessage.addListener(bridgeListener);
94 +
95 + lastSubscribedBridgeListener = bridgeListener;
96
97 return () => {
93 - portOnMessage.removeListener(listener);
98 + port?.onMessage.removeListener(bridgeListener);
99 + lastSubscribedBridgeListener = null;
100 };
101 },
102
103 send(event: string, payload: any, transferable?: Array<any>) {
98 - port.postMessage({event, payload}, transferable);
104 + port?.postMessage({event, payload}, transferable);
105 },
106 });
107
@@ -469,9 +475,6 @@ function performInTabNavigationCleanup() {
475 bridge = null;
476 render = null;
477 root = null;
472 -
473 - port?.disconnect();
474 - port = null;
478 }
479
480 function performFullCleanup() {
@@ -499,23 +502,37 @@ function performFullCleanup() {
502 }
503
504 function connectExtensionPort() {
505 + if (port) {
506 + throw new Error('DevTools port was already connected');
507 + }
508 +
509 const tabId = chrome.devtools.inspectedWindow.tabId;
510 port = chrome.runtime.connect({
511 name: String(tabId),
512 });
513
514 + // If DevTools port was reconnected and Bridge was already created
515 + // We should subscribe bridge to this port events
516 + // This could happen if service worker dies and all ports are disconnected,
517 + // but later user continues the session and Chrome reconnects all ports
518 + // Bridge object is still in-memory, though
519 + if (lastSubscribedBridgeListener) {
520 + port.onMessage.addListener(lastSubscribedBridgeListener);
521 + }
522 +
523 // This port may be disconnected by Chrome at some point, this callback
524 // will be executed only if this port was disconnected from the other end
525 // so, when we call `port.disconnect()` from this script,
526 // this should not trigger this callback and port reconnection
511 - port.onDisconnect.addListener(connectExtensionPort);
527 + port.onDisconnect.addListener(() => {
528 + port = null;
529 + connectExtensionPort();
530 + });
531 }
532
533 function mountReactDevTools() {
534 registerEventsLogger();
535
517 - connectExtensionPort();
518 -
536 createBridgeAndStore();
537
538 setReactSelectionFromBrowser(bridge);
@@ -532,7 +549,7 @@ function mountReactDevToolsWhenReactHasLoaded() {
549 mountReactDevTools();
550 }
551
535 - executeIfReactHasLoaded(onReactReady);
552 + executeIfReactHasLoaded(onReactReady, 1);
553 }
554
555 let bridge = null;
@@ -555,11 +572,18 @@ let port = null;
572 // since global values stored on window get reset in this case.
573 chrome.devtools.network.onNavigated.addListener(syncSavedPreferences);
574
558 -// Cleanup previous page state and remount everything
559 -chrome.devtools.network.onNavigated.addListener(() => {
575 +// In case when multiple navigation events emitted in a short period of time
576 +// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results
577 +// into subscribing to the same events from Bridge and window multiple times
578 +// In this case, we will handle `operations` event twice or more and user will see
579 +// `Cannot add node "1" because a node with that id is already in the Store.`
580 +const debouncedOnNavigatedListener = debounce(() => {
581 performInTabNavigationCleanup();
582 mountReactDevToolsWhenReactHasLoaded();
562 -});
583 +}, 500);
584 +
585 +// Cleanup previous page state and remount everything
586 +chrome.devtools.network.onNavigated.addListener(debouncedOnNavigatedListener);
587
588 // Should be emitted when browser DevTools are closed
589 if (IS_FIREFOX) {
@@ -569,5 +593,7 @@ if (IS_FIREFOX) {
593 window.addEventListener('beforeunload', performFullCleanup);
594 }
595
596 +connectExtensionPort();
597 +
598 syncSavedPreferences();
599 mountReactDevToolsWhenReactHasLoaded();