Skip to content

Commit a27df56

Browse files
authored
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
1 parent 9b4f847 commit a27df56

File tree

5 files changed

+112
-111
lines changed

5 files changed

+112
-111
lines changed

packages/react-devtools-extensions/src/background/dynamicallyInjectContentScripts.js

Lines changed: 40 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,46 @@
22

33
import {IS_FIREFOX} from '../utils';
44

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+
];
2443

44+
async function dynamicallyInjectContentScripts() {
2545
try {
2646
const alreadyRegisteredContentScripts =
2747
await chrome.scripting.getRegisteredContentScripts();
@@ -48,6 +68,4 @@ async function dynamicallyInjectContentScripts() {
4868
}
4969
}
5070

51-
if (!IS_FIREFOX) {
52-
dynamicallyInjectContentScripts();
53-
}
71+
dynamicallyInjectContentScripts();

packages/react-devtools-extensions/src/background/index.js

Lines changed: 19 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ import {IS_FIREFOX, EXTENSION_CONTAINED_VERSIONS} from '../utils';
77
import './dynamicallyInjectContentScripts';
88
import './tabsManager';
99
import setExtensionIconAndPopup from './setExtensionIconAndPopup';
10-
import injectProxy from './injectProxy';
1110

1211
/*
1312
{
@@ -39,18 +38,6 @@ function registerExtensionPort(port, tabId) {
3938
ports[tabId].disconnectPipe?.();
4039

4140
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-
}
5441
});
5542
}
5643

@@ -59,36 +46,12 @@ function registerProxyPort(port, tabId) {
5946

6047
// In case proxy port was disconnected from the other end, from content script
6148
// 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
6450
port.onDisconnect.addListener(() => {
6551
ports[tabId].disconnectPipe?.();
6652

6753
delete ports[tabId].proxy;
6854
});
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();
9255
}
9356

9457
function isNumeric(str: string): boolean {
@@ -100,42 +63,38 @@ chrome.runtime.onConnect.addListener(port => {
10063
// Proxy content script is executed in tab, so it should have it specified.
10164
const tabId = port.sender.tab.id;
10265

66+
if (ports[tabId]?.proxy) {
67+
port.disconnect();
68+
return;
69+
}
70+
10371
registerTab(tabId);
10472
registerProxyPort(port, tabId);
10573

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+
}
11181

11282
return;
11383
}
11484

11585
if (isNumeric(port.name)) {
11686
// Extension port doesn't have tab id specified, because its sender is the extension.
11787
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-
}
12488

12589
registerTab(tabId);
12690
registerExtensionPort(port, tabId);
12791

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+
);
13998
}
14099

141100
return;

packages/react-devtools-extensions/src/contentScripts/proxy.js

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,18 +30,6 @@ function sayHelloToBackendManager() {
3030
}
3131

3232
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-
4533
window.postMessage(
4634
{
4735
source: 'react-devtools-content-script',
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 43 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -27,20 +27,21 @@ import injectBackendManager from './injectBackendManager';
2727
import syncSavedPreferences from './syncSavedPreferences';
2828
import registerEventsLogger from './registerEventsLogger';
2929
import getProfilingFlags from './getProfilingFlags';
30+
import debounce from './debounce';
3031
import './requestAnimationFramePolyfill';
3132

3233
// Try polling for at least 5 seconds, in case if it takes too long to load react
3334
const REACT_POLLING_TICK_COOLDOWN = 250;
3435
const REACT_POLLING_ATTEMPTS_THRESHOLD = 20;
3536

3637
let reactPollingTimeoutId = null;
37-
function clearReactPollingTimeout() {
38+
export function clearReactPollingTimeout() {
3839
clearTimeout(reactPollingTimeoutId);
3940
reactPollingTimeoutId = null;
4041
}
4142

42-
function executeIfReactHasLoaded(callback, attempt = 1) {
43-
reactPollingTimeoutId = null;
43+
export function executeIfReactHasLoaded(callback, attempt = 1) {
44+
clearReactPollingTimeout();
4445

4546
if (attempt > REACT_POLLING_ATTEMPTS_THRESHOLD) {
4647
return;
@@ -81,21 +82,26 @@ function executeIfReactHasLoaded(callback, attempt = 1) {
8182
);
8283
}
8384

85+
let lastSubscribedBridgeListener = null;
86+
8487
function createBridge() {
8588
bridge = new Bridge({
8689
listen(fn) {
87-
const listener = message => fn(message);
90+
const bridgeListener = message => fn(message);
8891
// Store the reference so that we unsubscribe from the same object.
8992
const portOnMessage = port.onMessage;
90-
portOnMessage.addListener(listener);
93+
portOnMessage.addListener(bridgeListener);
94+
95+
lastSubscribedBridgeListener = bridgeListener;
9196

9297
return () => {
93-
portOnMessage.removeListener(listener);
98+
port?.onMessage.removeListener(bridgeListener);
99+
lastSubscribedBridgeListener = null;
94100
};
95101
},
96102

97103
send(event: string, payload: any, transferable?: Array<any>) {
98-
port.postMessage({event, payload}, transferable);
104+
port?.postMessage({event, payload}, transferable);
99105
},
100106
});
101107

@@ -469,9 +475,6 @@ function performInTabNavigationCleanup() {
469475
bridge = null;
470476
render = null;
471477
root = null;
472-
473-
port?.disconnect();
474-
port = null;
475478
}
476479

477480
function performFullCleanup() {
@@ -499,23 +502,37 @@ function performFullCleanup() {
499502
}
500503

501504
function connectExtensionPort() {
505+
if (port) {
506+
throw new Error('DevTools port was already connected');
507+
}
508+
502509
const tabId = chrome.devtools.inspectedWindow.tabId;
503510
port = chrome.runtime.connect({
504511
name: String(tabId),
505512
});
506513

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+
507523
// This port may be disconnected by Chrome at some point, this callback
508524
// will be executed only if this port was disconnected from the other end
509525
// so, when we call `port.disconnect()` from this script,
510526
// this should not trigger this callback and port reconnection
511-
port.onDisconnect.addListener(connectExtensionPort);
527+
port.onDisconnect.addListener(() => {
528+
port = null;
529+
connectExtensionPort();
530+
});
512531
}
513532

514533
function mountReactDevTools() {
515534
registerEventsLogger();
516535

517-
connectExtensionPort();
518-
519536
createBridgeAndStore();
520537

521538
setReactSelectionFromBrowser(bridge);
@@ -532,7 +549,7 @@ function mountReactDevToolsWhenReactHasLoaded() {
532549
mountReactDevTools();
533550
}
534551

535-
executeIfReactHasLoaded(onReactReady);
552+
executeIfReactHasLoaded(onReactReady, 1);
536553
}
537554

538555
let bridge = null;
@@ -555,11 +572,18 @@ let port = null;
555572
// since global values stored on window get reset in this case.
556573
chrome.devtools.network.onNavigated.addListener(syncSavedPreferences);
557574

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(() => {
560581
performInTabNavigationCleanup();
561582
mountReactDevToolsWhenReactHasLoaded();
562-
});
583+
}, 500);
584+
585+
// Cleanup previous page state and remount everything
586+
chrome.devtools.network.onNavigated.addListener(debouncedOnNavigatedListener);
563587

564588
// Should be emitted when browser DevTools are closed
565589
if (IS_FIREFOX) {
@@ -569,5 +593,7 @@ if (IS_FIREFOX) {
569593
window.addEventListener('beforeunload', performFullCleanup);
570594
}
571595

596+
connectExtensionPort();
597+
572598
syncSavedPreferences();
573599
mountReactDevToolsWhenReactHasLoaded();

0 commit comments

Comments
 (0)