Skip to content

Commit 93675f2

Browse files
committed
refactor[devtools/extension]: handle ports disconnection, instead of frequent reconnections
1 parent 9b4f847 commit 93675f2

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)