@samitouri / QOS-React-2 / commits / 0f42eac25e

[DevTools] Reset extension backend on pagehide (#37155)

I have noticed inconsistency errors being thrown during browser navigations that involve entries from BFCache. The main argument on why this could be affecting React DevTools backend lifecycle is the fact that Chrome kills the port manually, while freezing and preserving the JavaScript heap - https://developer.chrome.com/blog/bfcache-extension-messaging-changes. Basically, we could end up in a permutation, where port is dead, but Backend / Agent are alive. Such setup is not expected by React DevTools. On `main`: https://github.com/user-attachments/assets/9ca10286-b545-4384-bd6b-33d9a4ddde3d With these changes: https://github.com/user-attachments/assets/c4639c4a-6385-4248-a5c4-a39895d7fff6 I couldn't come up with a good test for this yet, but I will try to add something. I am not convinced yet that emulating `pagehide` / `pageshow` would be sufficient to reproduce browser environment during BFCache entries.

Ruslan Lesiutin committed Jul 30, 2026 at 21:17 UTC 0f42eac25e44090837e92304ac08d8d430ceef0a
2 files changed +100 -10
packages/react-devtools-extensions/src/contentScripts/backendManager.js
+75 -6
@@ -22,6 +22,37 @@ import {
22
23 let welcomeHasInitialized = false;
24 const requiredBackends = new Set<string>();
25 +const activeBackendsShutdownCallbacks = new Set<() => void>();
26 +let cleanupBackendManagerSetup: (() => void) | null = null;
27 +let hasShutdownBackendManager = false;
28 +
29 +function finishBackendManagerShutdown() {
30 + if (hasShutdownBackendManager) {
31 + return;
32 + }
33 + hasShutdownBackendManager = true;
34 +
35 + window.removeEventListener('message', welcome);
36 + window.removeEventListener('pagehide', handlePageHide);
37 +
38 + const cleanup = cleanupBackendManagerSetup;
39 + cleanupBackendManagerSetup = null;
40 + cleanup?.();
41 +
42 + delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__;
43 +}
44 +
45 +function handlePageHide() {
46 + // A document in the back-forward cache keeps its JavaScript heap but loses
47 + // its extension messaging port. Shut down locally while the document is
48 + // still active so a restored page can attach a new Agent and replay its tree.
49 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
50 + for (const shutdownBackend of activeBackendsShutdownCallbacks) {
51 + shutdownBackend();
52 + }
53 +
54 + finishBackendManagerShutdown();
55 +}
56
57 function welcome(event: $FlowFixMe) {
58 if (
@@ -89,11 +120,26 @@ function setup(hook: ?DevToolsHook) {
120 },
121 );
122
92 - const unsubscribeShutdownListener: () => void = hook.sub('shutdown', () => {
123 + let didCleanup = false;
124 + let unsubscribeShutdownListener: (() => void) | null = null;
125 + const cleanup = () => {
126 + if (didCleanup) {
127 + return;
128 + }
129 + didCleanup = true;
130 +
131 unsubscribeRendererListener();
132 unsubscribeBackendInstallationListener();
95 - unsubscribeShutdownListener();
96 - });
133 + unsubscribeShutdownListener?.();
134 + unsubscribeShutdownListener = null;
135 +
136 + if (cleanupBackendManagerSetup === cleanup) {
137 + cleanupBackendManagerSetup = null;
138 + }
139 + };
140 +
141 + unsubscribeShutdownListener = hook.sub('shutdown', cleanup);
142 + cleanupBackendManagerSetup = cleanup;
143 }
144
145 function registerRenderer(renderer: ReactRenderer, hook: DevToolsHook) {
@@ -115,6 +161,7 @@ function activateBackend(version: string, hook: DevToolsHook) {
161 }
162
163 const {Agent, Bridge, initBackend, setupNativeStyleEditor} = backend;
164 + let shouldSendMessages = true;
165 const bridge = new Bridge({
166 listen(fn) {
167 const listener = (event: $FlowFixMe) => {
@@ -134,6 +181,10 @@ function activateBackend(version: string, hook: DevToolsHook) {
181 };
182 },
183 send(event: string, payload: mixed, transferable?: $ReadOnlyArray<mixed>) {
184 + if (!shouldSendMessages) {
185 + return;
186 + }
187 +
188 window.postMessage(
189 {
190 source: 'react-devtools-bridge',
@@ -154,11 +205,28 @@ function activateBackend(version: string, hook: DevToolsHook) {
205 // Clean up flags, so that next reload won't start profiling
206 onReloadAndProfileFlagsReset();
207
208 + let hasShutdownBackend = false;
209 + const shutdownBackend = () => {
210 + if (hasShutdownBackend) {
211 + return;
212 + }
213 + hasShutdownBackend = true;
214 + shouldSendMessages = false;
215 +
216 + bridge.shutdown();
217 + };
218 + activeBackendsShutdownCallbacks.add(shutdownBackend);
219 +
220 agent.addListener('shutdown', () => {
158 - // If we received 'shutdown' from `agent`, we assume the `bridge` is already shutting down,
159 - // and that caused the 'shutdown' event on the `agent`, so we don't need to call `bridge.shutdown()` here.
221 + hasShutdownBackend = true;
222 + shouldSendMessages = false;
223 + activeBackendsShutdownCallbacks.delete(shutdownBackend);
224 +
225 hook.emit('shutdown');
161 - delete window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__;
226 +
227 + if (activeBackendsShutdownCallbacks.size === 0) {
228 + finishBackendManagerShutdown();
229 + }
230 });
231
232 initBackend(hook, agent, window, getIsReloadAndProfileSupported());
@@ -207,4 +275,5 @@ if (!window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__) {
275 window.__REACT_DEVTOOLS_BACKEND_MANAGER_INJECTED__ = true;
276
277 window.addEventListener('message', welcome);
278 + window.addEventListener('pagehide', handlePageHide);
279 }
packages/react-devtools-extensions/src/contentScripts/proxy.js
+25 -4
@@ -17,6 +17,8 @@ import {
17 } from '../constants';
18
19 function injectProxy() {
20 + isTransportActive = true;
21 +
22 // Firefox's behaviour for injecting this content script can be unpredictable
23 // While navigating the history, some content scripts might not be re-injected and still be alive
24 if (!window.__REACT_DEVTOOLS_PROXY_INJECTED__) {
@@ -29,9 +31,9 @@ function injectProxy() {
31 // The backend waits to install the global hook until notified by the content script.
32 // In the event of a page reload, the content script might be loaded before the backend manager is injected.
33 // Because of this we need to poll the backend manager until it has been initialized.
32 - const intervalID: IntervalID = setInterval(() => {
34 + backendManagerHelloIntervalID = setInterval(() => {
35 if (backendInitialized) {
34 - clearInterval(intervalID);
36 + stopPollingForBackendManager();
37 } else {
38 sayHelloToBackendManager();
39 }
@@ -62,14 +64,29 @@ window.addEventListener('pagehide', function ({target}) {
64 return;
65 }
66
67 + isTransportActive = false;
68 + backendInitialized = false;
69 + isBridgeConnected = false;
70 + pendingMessages.length = 0;
71 + stopPollingForBackendManager();
72 +
73 delete window.__REACT_DEVTOOLS_PROXY_INJECTED__;
74 });
75
76 let port: ExtensionRuntimePort | null = null;
77 +let isTransportActive: boolean = true;
78 let backendInitialized: boolean = false;
79 let isBridgeConnected: boolean = false;
80 let isListeningToMessagesFromBackend: boolean = false;
81 const pendingMessages: Array<mixed> = [];
82 +let backendManagerHelloIntervalID: IntervalID | null = null;
83 +
84 +function stopPollingForBackendManager() {
85 + if (backendManagerHelloIntervalID !== null) {
86 + clearInterval(backendManagerHelloIntervalID);
87 + backendManagerHelloIntervalID = null;
88 + }
89 +}
90
91 function listenToMessagesFromBackend() {
92 if (!isListeningToMessagesFromBackend) {
@@ -116,7 +133,7 @@ function handleMessageFromDevtools(
133 sourcePort: ExtensionRuntimePort,
134 message: mixed,
135 ) {
119 - if (port !== sourcePort) {
136 + if (!isTransportActive || port !== sourcePort) {
137 return;
138 }
139
@@ -153,7 +170,7 @@ function handleMessageFromDevtools(
170 }
171
172 function handleMessageFromPage(event: any) {
156 - if (event.source !== window || !event.data) {
173 + if (!isTransportActive || event.source !== window || !event.data) {
174 return;
175 }
176
@@ -206,6 +223,10 @@ function handleDisconnect(disconnectedPort: ExtensionRuntimePort) {
223 // Creates port from application page to the React DevTools' service worker
224 // Which then connects it with extension port
225 function connectPort() {
226 + if (!isTransportActive) {
227 + return;
228 + }
229 +
230 isBridgeConnected = false;
231 const nextPort = chrome.runtime.connect({
232 name: 'proxy',