@samitouri / QOS-React / commits / 28cd4bb08f

[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.