[DevTools] Buffer Bridge messages during extension reconnects (#37075)
Buffers Bridge messages during extension port reconnects and adds a readiness handshake for ordered queue flushing. Includes regression coverage for reconnect delivery and listener cleanup. Potential scenario could be a long user session, where Chrome kills one of the extension ports to save resources and then user re-connects by navigating back to the DevTools UI.
Ruslan Lesiutin committed
Jul 23, 2026 at 10:39 UTC
28cd4bb08f1b66808bede284fca978cc9b065154
6 files changed
+393
-36
packages/react-devtools-extensions/src/background/index.js
+40
@@ -19,6 +19,11 @@ import {
19
handleReactDevToolsHookMessage,
20
handleFetchResourceContentScriptMessage,
21
} from './messageHandlers';
22
+import {
23
+ EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
24
+ EXTENSION_BRIDGE_CONNECTION_READY,
25
+} from '../constants';
26
+import type {ExtensionBridgeConnectionType} from '../constants';
27
28
const ports: {
29
// TODO: Check why we convert tab IDs to strings, and if we can avoid it
@@ -156,6 +161,20 @@ function connectExtensionAndProxyPorts(
161
}
162
const proxyPort = maybeProxyPort;
163
164
+ function sendBridgeConnectionMessage(
165
+ port: ExtensionRuntimePort,
166
+ type: ExtensionBridgeConnectionType,
167
+ ) {
168
+ try {
169
+ port.postMessage({
170
+ source: 'react-devtools-background',
171
+ payload: {type},
172
+ });
173
+ } catch (error) {
174
+ // The port disconnected before the status update could be delivered.
175
+ }
176
+ }
177
+
178
// $FlowFixMe[incompatible-type]
179
if (ports[tabId].disconnectPipe) {
180
throw new Error(
@@ -163,6 +182,8 @@ function connectExtensionAndProxyPorts(
182
);
183
}
184
185
+ let didDisconnect = false;
186
+
187
function extensionPortMessageListener(message: mixed) {
188
try {
189
proxyPort.postMessage(message);
@@ -188,9 +209,23 @@ function connectExtensionAndProxyPorts(
209
}
210
211
function disconnectListener() {
212
+ if (didDisconnect) {
213
+ return;
214
+ }
215
+ didDisconnect = true;
216
+
217
extensionPort.onMessage.removeListener(extensionPortMessageListener);
218
proxyPort.onMessage.removeListener(proxyPortMessageListener);
219
220
+ sendBridgeConnectionMessage(
221
+ extensionPort,
222
+ EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
223
+ );
224
+ sendBridgeConnectionMessage(
225
+ proxyPort,
226
+ EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
227
+ );
228
+
229
// We handle disconnect() calls manually, based on each specific case
230
// No need to disconnect other port here
231
@@ -205,6 +240,11 @@ function connectExtensionAndProxyPorts(
240
241
extensionPort.onDisconnect.addListener(disconnectListener);
242
proxyPort.onDisconnect.addListener(disconnectListener);
243
+
244
+ // The proxy owns the backend message queue. Once both forwarding listeners
245
+ // are installed, tell it to flush that queue through this pipe. It echoes the
246
+ // message to the frontend after the queued backend messages have been sent.
247
+ sendBridgeConnectionMessage(proxyPort, EXTENSION_BRIDGE_CONNECTION_READY);
248
}
249
250
chrome.runtime.onMessage.addListener((message, sender) => {
packages/react-devtools-extensions/src/constants.js
new
+45
@@ -0,0 +1,45 @@
1
+/**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ * @flow
8
+ */
9
+
10
+export type ExtensionBridgeConnectionType =
11
+ | 'react-devtools-extension-bridge-connection-ready'
12
+ | 'react-devtools-extension-bridge-connection-disconnected';
13
+
14
+export const EXTENSION_BRIDGE_CONNECTION_READY: ExtensionBridgeConnectionType =
15
+ 'react-devtools-extension-bridge-connection-ready';
16
+export const EXTENSION_BRIDGE_CONNECTION_DISCONNECTED: ExtensionBridgeConnectionType =
17
+ 'react-devtools-extension-bridge-connection-disconnected';
18
+
19
+export function getExtensionBridgeConnectionType(
20
+ message: mixed,
21
+): ExtensionBridgeConnectionType | null {
22
+ if (
23
+ message === null ||
24
+ typeof message !== 'object' ||
25
+ !('source' in message) ||
26
+ message.source !== 'react-devtools-background' ||
27
+ !('payload' in message)
28
+ ) {
29
+ return null;
30
+ }
31
+
32
+ const payload = message.payload;
33
+ if (payload === null || typeof payload !== 'object' || !('type' in payload)) {
34
+ return null;
35
+ }
36
+
37
+ const type = payload.type;
38
+ if (type === EXTENSION_BRIDGE_CONNECTION_READY) {
39
+ return EXTENSION_BRIDGE_CONNECTION_READY;
40
+ }
41
+ if (type === EXTENSION_BRIDGE_CONNECTION_DISCONNECTED) {
42
+ return EXTENSION_BRIDGE_CONNECTION_DISCONNECTED;
43
+ }
44
+ return null;
45
+}
packages/react-devtools-extensions/src/contentScripts/proxy.js
+90
-13
@@ -6,16 +6,23 @@
6
*
7
* @flow
8
*/
9
-/* global chrome */
9
+/* global chrome, ExtensionRuntimePort */
10
11
'use strict';
12
13
+import {
14
+ EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
15
+ EXTENSION_BRIDGE_CONNECTION_READY,
16
+ getExtensionBridgeConnectionType,
17
+} from '../constants';
18
+
19
function injectProxy() {
20
// Firefox's behaviour for injecting this content script can be unpredictable
21
// While navigating the history, some content scripts might not be re-injected and still be alive
22
if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) {
23
window.__REACT_DEVTOOLS_PROXY_INJECTED__ = true;
24
25
+ listenToMessagesFromBackend();
26
connectPort();
27
sayHelloToBackendManager();
28
@@ -58,8 +65,42 @@ window.addEventListener('pagehide', function ({target}) {
65
delete window.__REACT_DEVTOOLS_PROXY_INJECTED__;
66
});
67
61
-let port = null;
68
+let port: ExtensionRuntimePort | null = null;
69
let backendInitialized: boolean = false;
70
+let isBridgeConnected: boolean = false;
71
+let isListeningToMessagesFromBackend: boolean = false;
72
+const pendingMessages: Array<mixed> = [];
73
+
74
+function listenToMessagesFromBackend() {
75
+ if (!isListeningToMessagesFromBackend) {
76
+ window.addEventListener('message', handleMessageFromPage);
77
+ isListeningToMessagesFromBackend = true;
78
+ }
79
+}
80
+
81
+function flushPendingMessages(): boolean {
82
+ const currentPort = port;
83
+ if (!isBridgeConnected || currentPort === null) {
84
+ return false;
85
+ }
86
+
87
+ let sentCount = 0;
88
+ while (sentCount < pendingMessages.length) {
89
+ try {
90
+ currentPort.postMessage(pendingMessages[sentCount]);
91
+ sentCount++;
92
+ } catch (error) {
93
+ isBridgeConnected = false;
94
+ break;
95
+ }
96
+ }
97
+
98
+ if (sentCount > 0) {
99
+ pendingMessages.splice(0, sentCount);
100
+ }
101
+
102
+ return isBridgeConnected && pendingMessages.length === 0;
103
+}
104
105
function sayHelloToBackendManager() {
106
window.postMessage(
@@ -71,7 +112,37 @@ function sayHelloToBackendManager() {
112
);
113
}
114
74
-function handleMessageFromDevtools(message: mixed) {
115
+function handleMessageFromDevtools(
116
+ sourcePort: ExtensionRuntimePort,
117
+ message: mixed,
118
+) {
119
+ if (port !== sourcePort) {
120
+ return;
121
+ }
122
+
123
+ switch (getExtensionBridgeConnectionType(message)) {
124
+ case EXTENSION_BRIDGE_CONNECTION_READY:
125
+ isBridgeConnected = true;
126
+ if (flushPendingMessages()) {
127
+ const currentPort = port;
128
+ if (currentPort === null) {
129
+ // The port may disconnect synchronously while its queue is flushed.
130
+ return;
131
+ }
132
+ try {
133
+ // This travels through the forwarding pipe after all queued backend
134
+ // messages, so the frontend can safely flush its command queue.
135
+ currentPort.postMessage(message);
136
+ } catch (error) {
137
+ isBridgeConnected = false;
138
+ }
139
+ }
140
+ return;
141
+ case EXTENSION_BRIDGE_CONNECTION_DISCONNECTED:
142
+ isBridgeConnected = false;
143
+ return;
144
+ }
145
+
146
window.postMessage(
147
{
148
source: 'react-devtools-content-script',
@@ -91,8 +162,8 @@ function handleMessageFromPage(event: any) {
162
case 'react-devtools-bridge': {
163
backendInitialized = true;
164
94
- // $FlowFixMe[incompatible-use]
95
- port.postMessage(event.data.payload);
165
+ pendingMessages.push(event.data.payload);
166
+ flushPendingMessages();
167
break;
168
}
169
@@ -110,8 +181,12 @@ function handleMessageFromPage(event: any) {
181
}
182
}
183
113
-function handleDisconnect() {
114
- window.removeEventListener('message', handleMessageFromPage);
184
+function handleDisconnect(disconnectedPort: ExtensionRuntimePort) {
185
+ if (port !== disconnectedPort) {
186
+ return;
187
+ }
188
+
189
+ isBridgeConnected = false;
190
port = null;
191
192
// Mirrors the guard in handlePageShow(): the background script can evict/
@@ -131,16 +206,18 @@ function handleDisconnect() {
206
// Creates port from application page to the React DevTools' service worker
207
// Which then connects it with extension port
208
function connectPort() {
134
- port = chrome.runtime.connect({
209
+ isBridgeConnected = false;
210
+ const nextPort = chrome.runtime.connect({
211
name: 'proxy',
212
});
213
+ port = nextPort;
214
138
- window.addEventListener('message', handleMessageFromPage);
215
+ listenToMessagesFromBackend();
216
140
- // $FlowFixMe[incompatible-use]
141
- port.onMessage.addListener(handleMessageFromDevtools);
142
- // $FlowFixMe[incompatible-use]
143
- port.onDisconnect.addListener(handleDisconnect);
217
+ nextPort.onMessage.addListener(message =>
218
+ handleMessageFromDevtools(nextPort, message),
219
+ );
220
+ nextPort.onDisconnect.addListener(() => handleDisconnect(nextPort));
221
}
222
223
let evalRequestId = 0;
packages/react-devtools-extensions/src/main/index.js
+111
-17
@@ -48,6 +48,11 @@ import injectBackendManager from './injectBackendManager';
48
import registerEventsLogger from './registerEventsLogger';
49
import getProfilingFlags from './getProfilingFlags';
50
import debounce from './debounce';
51
+import {
52
+ EXTENSION_BRIDGE_CONNECTION_DISCONNECTED,
53
+ EXTENSION_BRIDGE_CONNECTION_READY,
54
+ getExtensionBridgeConnectionType,
55
+} from '../constants';
56
import './requestAnimationFramePolyfill';
57
58
const resolvedParseHookNames = Promise.resolve(parseHookNames);
@@ -56,24 +61,98 @@ const resolvedParseHookNames = Promise.resolve(parseHookNames);
61
// wrapper around calling the worker.
62
const hookNamesModuleLoaderFunction = () => resolvedParseHookNames;
63
64
+type PendingBridgeMessage = {
65
+ event: string,
66
+ payload: mixed,
67
+ transferable?: $ReadOnlyArray<mixed>,
68
+};
69
+
70
+function flushPendingBridgeMessages(): void {
71
+ const currentPort = port;
72
+ if (!isBridgeConnected || currentPort === null) {
73
+ return;
74
+ }
75
+
76
+ let sentCount = 0;
77
+ while (sentCount < pendingBridgeMessages.length) {
78
+ const {event, payload, transferable} = pendingBridgeMessages[sentCount];
79
+ try {
80
+ currentPort.postMessage({event, payload}, transferable);
81
+ sentCount++;
82
+ } catch (error) {
83
+ isBridgeConnected = false;
84
+ break;
85
+ }
86
+ }
87
+
88
+ if (sentCount > 0) {
89
+ pendingBridgeMessages.splice(0, sentCount);
90
+ }
91
+}
92
+
93
+function handleBridgeConnectionMessage(message: mixed): void {
94
+ switch (getExtensionBridgeConnectionType(message)) {
95
+ case EXTENSION_BRIDGE_CONNECTION_READY:
96
+ isBridgeConnected = true;
97
+ flushPendingBridgeMessages();
98
+ break;
99
+ case EXTENSION_BRIDGE_CONNECTION_DISCONNECTED:
100
+ isBridgeConnected = false;
101
+ break;
102
+ }
103
+}
104
+
105
+function removeBridgePortListener(): void {
106
+ if (subscribedBridgePort !== null && bridgePortListener !== null) {
107
+ subscribedBridgePort.onMessage.removeListener(bridgePortListener);
108
+ }
109
+ subscribedBridgePort = null;
110
+ bridgePortListener = null;
111
+}
112
+
113
+function addBridgePortListener(nextPort: ExtensionRuntimePort): void {
114
+ const bridgeListener = lastSubscribedBridgeListener;
115
+ if (bridgeListener === null) {
116
+ return;
117
+ }
118
+
119
+ removeBridgePortListener();
120
+
121
+ const nextBridgePortListener = (message: mixed) => {
122
+ if (port === nextPort) {
123
+ bridgeListener(message);
124
+ }
125
+ };
126
+ nextPort.onMessage.addListener(nextBridgePortListener);
127
+ subscribedBridgePort = nextPort;
128
+ bridgePortListener = nextBridgePortListener;
129
+}
130
+
131
function createBridge() {
132
bridge = new Bridge({
133
listen(fn) {
62
- const bridgeListener = (message: mixed) => fn(message);
63
- // Store the reference so that we unsubscribe from the same object.
64
- const portOnMessage = port.onMessage;
65
- portOnMessage.addListener(bridgeListener);
134
+ const currentPort = port;
135
+ if (currentPort === null) {
136
+ throw new Error('DevTools port is not connected.');
137
+ }
138
+ if (lastSubscribedBridgeListener !== null) {
139
+ throw new Error('The Bridge already has a Wall listener.');
140
+ }
141
67
- lastSubscribedBridgeListener = bridgeListener;
142
+ lastSubscribedBridgeListener = fn;
143
+ addBridgePortListener(currentPort);
144
145
return () => {
70
- port?.onMessage.removeListener(bridgeListener);
71
- lastSubscribedBridgeListener = null;
146
+ if (lastSubscribedBridgeListener === fn) {
147
+ lastSubscribedBridgeListener = null;
148
+ removeBridgePortListener();
149
+ }
150
};
151
},
152
153
send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
76
- port?.postMessage({event, payload}, transferable);
154
+ pendingBridgeMessages.push({event, payload, transferable});
155
+ flushPendingBridgeMessages();
156
},
157
});
158
@@ -490,6 +569,7 @@ function performInTabNavigationCleanup() {
569
bridge = null as $FlowFixMe;
570
render = null as $FlowFixMe;
571
root = null as $FlowFixMe;
572
+ pendingBridgeMessages.length = 0;
573
}
574
575
function performFullCleanup() {
@@ -517,9 +597,10 @@ function performFullCleanup() {
597
store = null as $FlowFixMe;
598
bridge = null as $FlowFixMe;
599
render = null as $FlowFixMe;
600
+ pendingBridgeMessages.length = 0;
601
602
port?.disconnect();
522
- port = null as $FlowFixMe;
603
+ port = null;
604
}
605
606
function connectExtensionPort(): void {
@@ -528,25 +609,34 @@ function connectExtensionPort(): void {
609
}
610
611
const tabId = chrome.devtools.inspectedWindow.tabId;
531
- port = chrome.runtime.connect({
612
+ isBridgeConnected = false;
613
+ const nextPort = chrome.runtime.connect({
614
name: String(tabId),
615
});
616
+ port = nextPort;
617
+ nextPort.onMessage.addListener(message => {
618
+ if (port === nextPort) {
619
+ handleBridgeConnectionMessage(message);
620
+ }
621
+ });
622
623
// If DevTools port was reconnected and Bridge was already created
624
// We should subscribe bridge to this port events
625
// This could happen if service worker dies and all ports are disconnected,
626
// but later user continues the session and Chrome reconnects all ports
627
// Bridge object is still in-memory, though
540
- if (lastSubscribedBridgeListener) {
541
- port.onMessage.addListener(lastSubscribedBridgeListener);
542
- }
628
+ addBridgePortListener(nextPort);
629
630
// This port may be disconnected by Chrome at some point, this callback
631
// will be executed only if this port was disconnected from the other end
632
// so, when we call `port.disconnect()` from this script,
633
// this should not trigger this callback and port reconnection
548
- port.onDisconnect.addListener(() => {
549
- port = null as $FlowFixMe;
634
+ nextPort.onDisconnect.addListener(() => {
635
+ if (port !== nextPort) {
636
+ return;
637
+ }
638
+ isBridgeConnected = false;
639
+ port = null;
640
connectExtensionPort();
641
});
642
}
@@ -600,7 +690,9 @@ function mountReactDevToolsWhenReactHasLoaded() {
690
}
691
692
let bridge: FrontendBridge = null as $FlowFixMe;
603
-let lastSubscribedBridgeListener = null;
693
+let lastSubscribedBridgeListener: ((message: mixed) => void) | null = null;
694
+let subscribedBridgePort: ExtensionRuntimePort | null = null;
695
+let bridgePortListener: ((message: mixed) => void) | null = null;
696
let store: Store = null as $FlowFixMe;
697
698
let profilingData = null;
@@ -622,7 +714,9 @@ let root: RootType = null as $FlowFixMe;
714
715
let currentSelectedSource: null | SourceSelection = null;
716
625
-let port: ExtensionRuntimePort = null as $FlowFixMe;
717
+let port: ExtensionRuntimePort | null = null;
718
+let isBridgeConnected: boolean = false;
719
+const pendingBridgeMessages: Array<PendingBridgeMessage> = [];
720
721
// In case when multiple navigation events emitted in a short period of time
722
// This debounced callback primarily used to avoid mounting React DevTools multiple times, which results
packages/react-devtools-shared/src/__tests__/setupTests.js
+55
-6
@@ -14,6 +14,18 @@ import type {
14
FrontendBridge,
15
} from 'react-devtools-shared/src/bridge';
16
17
+type TestBridgeMessage = {event: string, payload: mixed};
18
+type TestBridgeWall = {
19
+ disconnect: () => void,
20
+ reconnect: () => void,
21
+ listen: (callback: (message: mixed) => void) => () => void,
22
+ send: (
23
+ event: string,
24
+ payload: mixed,
25
+ transferable?: $ReadOnlyArray<mixed>,
26
+ ) => void,
27
+};
28
+
29
const {getTestFlags} = require('../../../../scripts/jest/TestFlags');
30
31
// Argument is serialized when passed from jest-cli script through to setupTests.
@@ -247,21 +259,58 @@ beforeEach(() => {
259
disableSecondConsoleLogDimmingInStrictMode: false,
260
});
261
250
- const bridgeListeners = [];
251
- const bridge = new Bridge({
262
+ let bridgeListeners: Array<(message: mixed) => void> = [];
263
+ let disconnectedBridgeListeners: Array<(message: mixed) => void> | null =
264
+ null;
265
+ let pendingBridgeMessages: Array<TestBridgeMessage> = [];
266
+ const bridgeWall: TestBridgeWall = {
267
+ disconnect() {
268
+ if (disconnectedBridgeListeners === null) {
269
+ disconnectedBridgeListeners = bridgeListeners;
270
+ bridgeListeners = [];
271
+ }
272
+ },
273
+ reconnect() {
274
+ if (disconnectedBridgeListeners !== null) {
275
+ bridgeListeners = disconnectedBridgeListeners;
276
+ disconnectedBridgeListeners = null;
277
+
278
+ const messages = pendingBridgeMessages;
279
+ pendingBridgeMessages = [];
280
+ messages.forEach(message => {
281
+ bridgeListeners.forEach(callback => callback(message));
282
+ });
283
+ }
284
+ },
285
listen(callback) {
253
- bridgeListeners.push(callback);
286
+ const listeners =
287
+ disconnectedBridgeListeners !== null
288
+ ? disconnectedBridgeListeners
289
+ : bridgeListeners;
290
+ listeners.push(callback);
291
return () => {
255
- const index = bridgeListeners.indexOf(callback);
292
+ let index = bridgeListeners.indexOf(callback);
293
if (index >= 0) {
294
bridgeListeners.splice(index, 1);
295
}
296
+ if (disconnectedBridgeListeners !== null) {
297
+ index = disconnectedBridgeListeners.indexOf(callback);
298
+ if (index >= 0) {
299
+ disconnectedBridgeListeners.splice(index, 1);
300
+ }
301
+ }
302
};
303
},
304
send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
262
- bridgeListeners.forEach(callback => callback({event, payload}));
305
+ const message = {event, payload};
306
+ if (disconnectedBridgeListeners === null) {
307
+ bridgeListeners.forEach(callback => callback(message));
308
+ } else {
309
+ pendingBridgeMessages.push(message);
310
+ }
311
},
264
- });
312
+ };
313
+ const bridge = new Bridge(bridgeWall);
314
315
const store = new Store(((bridge: any): FrontendBridge), {
316
supportsTimeline: true,
packages/react-devtools-shared/src/__tests__/store-test.js
+52
@@ -199,6 +199,58 @@ describe('Store', () => {
199
store.removeListener('error', errorListener);
200
});
201
202
+ // @reactVersion >= 18.0
203
+ it('receives operations queued while the frontend transport reconnects', () => {
204
+ const App = ({children}) => children ?? null;
205
+ const Parent = ({children}) => children ?? null;
206
+ const Child = () => null;
207
+
208
+ act(() => render(<App />));
209
+
210
+ const bridgeWall = (bridge.wall: any);
211
+
212
+ bridgeWall.disconnect();
213
+ try {
214
+ act(() =>
215
+ render(
216
+ <App>
217
+ <Parent />
218
+ </App>,
219
+ ),
220
+ );
221
+
222
+ expect(store).toMatchInlineSnapshot(`
223
+ [root]
224
+ <App>
225
+ `);
226
+ } finally {
227
+ bridgeWall.reconnect();
228
+ }
229
+
230
+ expect(store).toMatchInlineSnapshot(`
231
+ [root]
232
+ ▾ <App>
233
+ <Parent>
234
+ `);
235
+
236
+ act(() =>
237
+ render(
238
+ <App>
239
+ <Parent>
240
+ <Child />
241
+ </Parent>
242
+ </App>,
243
+ ),
244
+ );
245
+
246
+ expect(store).toMatchInlineSnapshot(`
247
+ [root]
248
+ ▾ <App>
249
+ ▾ <Parent>
250
+ <Child>
251
+ `);
252
+ });
253
+
254
// This test is not the same cause as what's reported on GitHub,
255
// but the resulting behavior (owner mounting after descendant) is the same.
256
// Thec ase below is admittedly contrived and relies on side effects.