main
js 279 lines 8.08 KB
Raw
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 import type {
11 DevToolsHook,
12 ReactRenderer,
13 } from 'react-devtools-shared/src/backend/types';
14 import {hasAssignedBackend} from 'react-devtools-shared/src/backend/utils';
15 import {COMPACT_VERSION_NAME} from 'react-devtools-extensions/src/utils';
16 import {getIsReloadAndProfileSupported} from 'react-devtools-shared/src/utils';
17 import {
18 getIfReloadedAndProfiling,
19 onReloadAndProfile,
20 onReloadAndProfileFlagsReset,
21 } from 'react-devtools-shared/src/utils';
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 (
59 event.source !== window ||
60 event.data.source !== 'react-devtools-content-script'
61 ) {
62 return;
63 }
64
65 // In some circumstances, this method is called more than once for a single welcome message.
66 // The exact circumstances of this are unclear, though it seems related to 3rd party event batching code.
67 //
68 // Regardless, call this method multiple times can cause DevTools to add duplicate elements to the Store
69 // (and throw an error) or worse yet, choke up entirely and freeze the browser.
70 //
71 // The simplest solution is to ignore the duplicate events.
72 // To be clear, this SHOULD NOT BE NECESSARY, since we remove the event handler below.
73 //
74 // See https://github.com/facebook/react/issues/24162
75 if (welcomeHasInitialized) {
76 console.warn(
77 'React DevTools detected duplicate welcome "message" events from the content script.',
78 );
79 return;
80 }
81
82 welcomeHasInitialized = true;
83
84 window.removeEventListener('message', welcome);
85
86 setup(window.__REACT_DEVTOOLS_GLOBAL_HOOK__);
87 }
88
89 function setup(hook: ?DevToolsHook) {
90 // this should not happen, but Chrome can be weird sometimes
91 if (hook == null) {
92 return;
93 }
94
95 // register renderers that have already injected themselves.
96 hook.renderers.forEach(renderer => {
97 registerRenderer(renderer, hook);
98 });
99
100 // Activate and remove from required all present backends, registered within the hook
101 hook.backends.forEach((_, backendVersion) => {
102 requiredBackends.delete(backendVersion);
103 activateBackend(backendVersion, hook);
104 });
105
106 updateRequiredBackends();
107
108 // register renderers that inject themselves later.
109 const unsubscribeRendererListener = hook.sub('renderer', ({renderer}) => {
110 registerRenderer(renderer, hook);
111 updateRequiredBackends();
112 });
113
114 // listen for backend installations.
115 const unsubscribeBackendInstallationListener = hook.sub(
116 'devtools-backend-installed',
117 version => {
118 activateBackend(version, hook);
119 updateRequiredBackends();
120 },
121 );
122
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();
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) {
146 let version = renderer.reconcilerVersion || renderer.version;
147 if (!hasAssignedBackend(version)) {
148 version = COMPACT_VERSION_NAME;
149 }
150
151 // Check if required backend is already activated, no need to require again
152 if (!hook.backends.has(version)) {
153 requiredBackends.add(version);
154 }
155 }
156
157 function activateBackend(version: string, hook: DevToolsHook) {
158 const backend = hook.backends.get(version);
159 if (!backend) {
160 throw new Error(`Could not find backend for version "${version}"`);
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) => {
168 if (
169 event.source !== window ||
170 !event.data ||
171 event.data.source !== 'react-devtools-content-script' ||
172 !event.data.payload
173 ) {
174 return;
175 }
176 fn(event.data.payload);
177 };
178 window.addEventListener('message', listener);
179 return () => {
180 window.removeEventListener('message', listener);
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',
191 payload: {event, payload},
192 },
193 '*',
194 transferable,
195 );
196 },
197 });
198
199 const agent = new Agent(
200 bridge,
201 getIfReloadedAndProfiling(),
202 onReloadAndProfile,
203 );
204 // Agent read flags successfully, we can count it as successful launch
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', () => {
221 hasShutdownBackend = true;
222 shouldSendMessages = false;
223 activeBackendsShutdownCallbacks.delete(shutdownBackend);
224
225 hook.emit('shutdown');
226
227 if (activeBackendsShutdownCallbacks.size === 0) {
228 finishBackendManagerShutdown();
229 }
230 });
231
232 initBackend(hook, agent, window, getIsReloadAndProfileSupported());
233
234 // Setup React Native style editor if a renderer like react-native-web has injected it.
235 if (typeof setupNativeStyleEditor === 'function' && hook.resolveRNStyle) {
236 setupNativeStyleEditor(
237 bridge,
238 agent,
239 hook.resolveRNStyle,
240 hook.nativeStyleEditorValidAttributes,
241 );
242 }
243
244 // Let the frontend know that the backend has attached listeners and is ready for messages.
245 // This covers the case of syncing saved values after reloading/navigating while DevTools remain open.
246 bridge.send('extensionBackendInitialized');
247
248 // this backend is activated
249 requiredBackends.delete(version);
250 }
251
252 // tell the service worker which versions of backends are needed for the current page
253 function updateRequiredBackends() {
254 if (requiredBackends.size === 0) {
255 return;
256 }
257
258 window.postMessage(
259 {
260 source: 'react-devtools-backend-manager',
261 payload: {
262 type: 'require-backends',
263 versions: Array.from(requiredBackends),
264 },
265 },
266 '*',
267 );
268 }
269
270 /*
271 * Make sure this is executed only once in case Frontend is reloaded multiple times while Backend is initializing
272 * We can't use `reactDevToolsAgent` field on a global Hook object, because it only cleaned up after both Frontend and Backend initialized
273 */
274 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 }