main
js 534 lines 15.9 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 EventEmitter from './events';
11
12 import type {ComponentFilter, Wall, WallMessage} from './frontend/types';
13 import type {
14 InspectedElementPayload,
15 OwnersList,
16 ProfilingDataBackend,
17 RendererID,
18 DevToolsHookSettings,
19 ProfilingSettings,
20 } from 'react-devtools-shared/src/backend/types';
21 import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-shared/src/backend/NativeStyleEditor/types';
22
23 // This message specifies the version of the DevTools protocol currently supported by the backend,
24 // as well as the earliest NPM version (e.g. "4.13.0") that protocol is supported by on the frontend.
25 // This enables an older frontend to display an upgrade message to users for a newer, unsupported backend.
26 export type BridgeProtocol = {
27 // Version supported by the current frontend/backend.
28 version: number,
29
30 // NPM version range of `react-devtools-inline` that also supports this version.
31 // Note that 'maxNpmVersion' is only set when the version is bumped.
32 minNpmVersion: string,
33 maxNpmVersion: string | null,
34 };
35
36 // Bump protocol version whenever a backwards breaking change is made
37 // in the messages sent between BackendBridge and FrontendBridge.
38 // This mapping is embedded in both frontend and backend builds.
39 //
40 // The backend protocol will always be the latest entry in the BRIDGE_PROTOCOL array.
41 //
42 // When an older frontend connects to a newer backend,
43 // the backend can send the minNpmVersion and the frontend can display an NPM upgrade prompt.
44 //
45 // When a newer frontend connects with an older protocol version,
46 // the frontend can use the embedded minNpmVersion/maxNpmVersion values to display a downgrade prompt.
47 export const BRIDGE_PROTOCOL: Array<BridgeProtocol> = [
48 // This version technically never existed,
49 // but a backwards breaking change was added in 4.11,
50 // so the safest guess to downgrade the frontend would be to version 4.10.
51 {
52 version: 0,
53 minNpmVersion: '"<4.11.0"',
54 maxNpmVersion: '"<4.11.0"',
55 },
56 // Versions 4.11.x – 4.12.x contained the backwards breaking change,
57 // but we didn't add the "fix" of checking the protocol version until 4.13,
58 // so we don't recommend downgrading to 4.11 or 4.12.
59 {
60 version: 1,
61 minNpmVersion: '4.13.0',
62 maxNpmVersion: '4.21.0',
63 },
64 // Version 2 adds a StrictMode-enabled and supports-StrictMode bits to add-root operation.
65 {
66 version: 2,
67 minNpmVersion: '4.22.0',
68 maxNpmVersion: null,
69 },
70 ];
71
72 export const currentBridgeProtocol: BridgeProtocol =
73 BRIDGE_PROTOCOL[BRIDGE_PROTOCOL.length - 1];
74
75 type ElementAndRendererID = {id: number, rendererID: RendererID};
76
77 export type Message = WallMessage;
78
79 type QueuedMessage = {
80 event: string,
81 payload: mixed,
82 };
83
84 type EventArguments<Payload> = Payload extends void ? [] : [Payload];
85
86 type EventEmitterEvents<Events: Object> = {
87 [Event in keyof Events]: EventArguments<Events[Event]>,
88 };
89
90 type HighlightHostInstance = {
91 ...ElementAndRendererID,
92 displayName: string | null,
93 hideAfterTimeout: boolean,
94 openBuiltinElementsPanel: boolean,
95 scrollIntoView: boolean,
96 };
97 type HighlightHostInstances = {
98 elements: Array<ElementAndRendererID>,
99 displayName: string | null,
100 hideAfterTimeout: boolean,
101 scrollIntoView: boolean,
102 };
103
104 type ScrollToHostInstance = {
105 ...ElementAndRendererID,
106 };
107
108 type OverrideValue = {
109 ...ElementAndRendererID,
110 path: Array<string | number>,
111 wasForwarded?: boolean,
112 value: mixed,
113 };
114
115 type OverrideHookState = {
116 ...OverrideValue,
117 hookID: number,
118 };
119
120 type PathType = 'props' | 'hooks' | 'state' | 'context';
121
122 type DeletePath = {
123 ...ElementAndRendererID,
124 type: PathType,
125 hookID?: ?number,
126 path: Array<string | number>,
127 };
128
129 type RenamePath = {
130 ...ElementAndRendererID,
131 type: PathType,
132 hookID?: ?number,
133 oldPath: Array<string | number>,
134 newPath: Array<string | number>,
135 };
136
137 type OverrideValueAtPath = {
138 ...ElementAndRendererID,
139 type: PathType,
140 hookID?: ?number,
141 path: Array<string | number>,
142 value: mixed,
143 };
144
145 type OverrideError = {
146 ...ElementAndRendererID,
147 forceError: boolean,
148 };
149
150 type OverrideSuspense = {
151 ...ElementAndRendererID,
152 forceFallback: boolean,
153 };
154
155 type OverrideSuspenseMilestone = {
156 rendererID: number,
157 suspendedSet: Array<number>,
158 };
159
160 type CopyElementPathParams = {
161 ...ElementAndRendererID,
162 path: Array<string | number>,
163 };
164
165 type ViewAttributeSourceParams = {
166 ...ElementAndRendererID,
167 path: Array<string | number>,
168 };
169
170 type InspectElementParams = {
171 ...ElementAndRendererID,
172 forceFullData: boolean,
173 path: Array<number | string> | null,
174 requestID: number,
175 };
176
177 type InspectScreenParams = {
178 requestID: number,
179 id: number,
180 forceFullData: boolean,
181 path: Array<number | string> | null,
182 };
183
184 type StoreAsGlobalParams = {
185 ...ElementAndRendererID,
186 count: number,
187 path: Array<string | number>,
188 };
189
190 type NativeStyleEditor_RenameAttributeParams = {
191 ...ElementAndRendererID,
192 oldName: string,
193 newName: string,
194 value: string,
195 };
196
197 type NativeStyleEditor_SetValueParams = {
198 ...ElementAndRendererID,
199 name: string,
200 value: string,
201 };
202
203 export type SavedPreferencesParams = {
204 componentFilters: Array<ComponentFilter>,
205 };
206
207 export type BackendEvents = {
208 backendInitialized: void,
209 backendVersion: string,
210 bridgeProtocol: BridgeProtocol,
211 extensionBackendInitialized: void,
212 fastRefreshScheduled: void,
213 getSavedPreferences: void,
214 inspectedElement: InspectedElementPayload,
215 inspectedScreen: InspectedElementPayload,
216 isReloadAndProfileSupportedByBackend: boolean,
217 operations: Array<number>,
218 ownersList: OwnersList,
219 environmentNames: Array<string>,
220 profilingData: ProfilingDataBackend,
221 profilingStatus: boolean,
222 reloadAppForProfiling: void,
223 saveToClipboard: string,
224 selectElement: number | null,
225 shutdown: void,
226 stopInspectingHost: boolean,
227 scrollTo: {left: number, top: number, right: number, bottom: number},
228 syncSelectionToBuiltinElementsPanel: void,
229 unsupportedRendererVersion: void,
230
231 extensionComponentsPanelShown: void,
232 extensionComponentsPanelHidden: void,
233
234 resumeElementPolling: void,
235 pauseElementPolling: void,
236
237 // React Native style editor plug-in.
238 isNativeStyleEditorSupported: {
239 isSupported: boolean,
240 validAttributes: ?$ReadOnlyArray<string>,
241 },
242 NativeStyleEditor_styleAndLayout: StyleAndLayoutPayload,
243
244 hookSettings: $ReadOnly<DevToolsHookSettings>,
245 };
246
247 type StartProfilingParams = ProfilingSettings;
248 type ReloadAndProfilingParams = ProfilingSettings;
249
250 export type FrontendEvents = {
251 clearErrorsAndWarnings: {rendererID: RendererID},
252 clearErrorsForElementID: ElementAndRendererID,
253 clearHostInstanceHighlight: void,
254 clearWarningsForElementID: ElementAndRendererID,
255 copyElementPath: CopyElementPathParams,
256 deletePath: DeletePath,
257 getBackendVersion: void,
258 getBridgeProtocol: void,
259 getIfHasUnsupportedRendererVersion: void,
260 getOwnersList: ElementAndRendererID,
261 getProfilingData: {rendererID: RendererID},
262 getProfilingStatus: void,
263 highlightHostInstance: HighlightHostInstance,
264 highlightHostInstances: HighlightHostInstances,
265 inspectElement: InspectElementParams,
266 inspectScreen: InspectScreenParams,
267 logElementToConsole: ElementAndRendererID,
268 overrideError: OverrideError,
269 overrideSuspense: OverrideSuspense,
270 overrideSuspenseMilestone: OverrideSuspenseMilestone,
271 overrideValueAtPath: OverrideValueAtPath,
272 profilingData: ProfilingDataBackend,
273 reloadAndProfile: ReloadAndProfilingParams,
274 renamePath: RenamePath,
275 savedPreferences: SavedPreferencesParams,
276 setTraceUpdatesEnabled: boolean,
277 shutdown: void,
278 startInspectingHost: boolean,
279 startProfiling: StartProfilingParams,
280 stopInspectingHost: void,
281 scrollToHostInstance: ScrollToHostInstance,
282 scrollTo: {left: number, top: number, right: number, bottom: number},
283 requestScrollPosition: void,
284 stopProfiling: void,
285 storeAsGlobal: StoreAsGlobalParams,
286 updateComponentFilters: Array<ComponentFilter>,
287 getEnvironmentNames: void,
288 updateHookSettings: $ReadOnly<DevToolsHookSettings>,
289 viewAttributeSource: ViewAttributeSourceParams,
290 viewElementSource: ElementAndRendererID,
291
292 syncSelectionFromBuiltinElementsPanel: void,
293
294 // React Native style editor plug-in.
295 NativeStyleEditor_measure: ElementAndRendererID,
296 NativeStyleEditor_renameAttribute: NativeStyleEditor_RenameAttributeParams,
297 NativeStyleEditor_setValue: NativeStyleEditor_SetValueParams,
298
299 // Temporarily support newer standalone front-ends sending commands to older embedded backends.
300 // We do this because React Native embeds the React DevTools backend,
301 // but cannot control which version of the frontend users use.
302 //
303 // Note that nothing in the newer backend actually listens to these events,
304 // but the new frontend still dispatches them (in case older backends are listening to them instead).
305 //
306 // Note that this approach does no support the combination of a newer backend with an older frontend.
307 // It would be more work to support both approaches (and not run handlers twice)
308 // so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix").
309 overrideContext: OverrideValue,
310 overrideHookState: OverrideHookState,
311 overrideProps: OverrideValue,
312 overrideState: OverrideValue,
313
314 getHookSettings: void,
315 };
316
317 class Bridge<
318 OutgoingEvents: Object,
319 IncomingEvents: Object,
320 > extends EventEmitter<EventEmitterEvents<IncomingEvents>> {
321 _isShutdown: boolean = false;
322 _messageQueue: Array<QueuedMessage> = [];
323 _scheduledFlush: boolean = false;
324 _wall: Wall;
325 _wallUnlisten: (() => void) | null = null;
326
327 constructor(wall: Wall) {
328 super();
329
330 this._wall = wall;
331
332 const wallUnlisten = wall.listen(this._handleMessage);
333 if (typeof wallUnlisten !== 'function') {
334 throw new TypeError('Wall.listen() must return an unlisten function.');
335 }
336 this._wallUnlisten = wallUnlisten;
337
338 // Temporarily support older standalone front-ends sending commands to newer embedded backends.
339 // We do this because React Native embeds the React DevTools backend,
340 // but cannot control which version of the frontend users use.
341 this.addListener('overrideValueAtPath', this.overrideValueAtPath);
342 }
343
344 // Listening directly to the wall isn't advised.
345 // It can be used to listen for legacy (v3) messages (since they use a different format).
346 get wall(): Wall {
347 return this._wall;
348 }
349
350 addListener<Event: $Keys<EventEmitterEvents<IncomingEvents>>>(
351 event: Event,
352 listener: (...EventEmitterEvents<IncomingEvents>[Event]) => mixed,
353 ): void {
354 this._assertNotShutdown('add a listener');
355 super.addListener(event, listener);
356 }
357
358 emit<Event: $Keys<EventEmitterEvents<IncomingEvents>>>(
359 event: Event,
360 ...args: EventEmitterEvents<IncomingEvents>[Event]
361 ): void {
362 this._assertNotShutdown('emit an event');
363 super.emit(event, ...args);
364 }
365
366 send<EventName: $Keys<OutgoingEvents>>(
367 event: EventName,
368 payload?: OutgoingEvents[EventName],
369 ): void {
370 this._assertNotShutdown('send a message');
371
372 if (typeof event !== 'string' || event.length === 0) {
373 throw new TypeError('Bridge event names must be non-empty strings.');
374 }
375
376 // When we receive a message:
377 // - we add it to our queue of messages to be sent
378 // - if there hasn't been a message recently, we set a timer for 0 ms in
379 // the future, allowing all messages created in the same tick to be sent
380 // together
381 // - if there *has* been a message flushed in the last BATCH_DURATION ms
382 // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will
383 // be set, and we'll simply add to the queue and wait for that
384 this._messageQueue.push({
385 event,
386 payload,
387 });
388 if (!this._scheduledFlush) {
389 this._scheduledFlush = true;
390 // $FlowFixMe[cannot-resolve-name]
391 if (typeof devtoolsJestTestScheduler === 'function') {
392 // This exists just for our own jest tests.
393 // They're written in such a way that we can neither mock queueMicrotask
394 // because then we break React DOM and we can't not mock it because then
395 // we can't synchronously flush it. So they need to be rewritten.
396 // $FlowFixMe[cannot-resolve-name]
397 devtoolsJestTestScheduler(this._flush); // eslint-disable-line no-undef
398 } else {
399 queueMicrotask(this._flush);
400 }
401 }
402 }
403
404 shutdown(): void {
405 this._assertNotShutdown('shut down');
406
407 // Queue the shutdown outgoing message for subscribers.
408 this.emit('shutdown');
409 this.send('shutdown');
410
411 // Mark this bridge as destroyed, i.e. disable its public API.
412 this._isShutdown = true;
413
414 // Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that.
415 this.removeAllListeners();
416
417 // Stop accepting and emitting incoming messages from the wall.
418 const wallUnlisten = this._wallUnlisten;
419 this._wallUnlisten = null;
420 try {
421 if (wallUnlisten !== null) {
422 wallUnlisten();
423 }
424 } finally {
425 // Synchronously flush all queued outgoing messages.
426 // At this step the subscribers' code may run in this call stack.
427 do {
428 this._flush();
429 } while (this._messageQueue.length);
430 }
431 }
432
433 _flush: () => void = () => {
434 // This method is used after the bridge is marked as destroyed in shutdown sequence,
435 // so we do not bail out if the bridge marked as destroyed.
436 // It is a private method that the bridge ensures is only called at the right times.
437 try {
438 if (this._messageQueue.length) {
439 for (let i = 0; i < this._messageQueue.length; i++) {
440 const {event, payload} = this._messageQueue[i];
441 this._wall.send(event, payload);
442 }
443 this._messageQueue.length = 0;
444 }
445 } finally {
446 // We set this at the end in case new messages are added synchronously above.
447 // They're already handled so they shouldn't queue more flushes.
448 this._scheduledFlush = false;
449 }
450 };
451
452 _assertNotShutdown(action: string): void {
453 if (this._isShutdown) {
454 throw new Error(
455 `Cannot ${action} through a Bridge that has been shut down.`,
456 );
457 }
458 }
459
460 _handleMessage: (message: mixed) => void = message => {
461 // Some Walls share a transport with unrelated messages or legacy DevTools
462 // protocols. A message without an event field does not belong to this Bridge.
463 if (
464 message === null ||
465 typeof message !== 'object' ||
466 !('event' in message)
467 ) {
468 return;
469 }
470
471 const event = message.event;
472 if (typeof event !== 'string' || event.length === 0) {
473 throw new TypeError('Bridge event names must be non-empty strings.');
474 }
475
476 this._assertNotShutdown('receive a message');
477 // The wire event name cannot be statically refined to a key of IncomingEvents.
478 (this as any).emit(event, message.payload);
479 };
480
481 // Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands
482 // to the older message types they may be listening to.
483 overrideValueAtPath: OverrideValueAtPath => void = ({
484 id,
485 path,
486 rendererID,
487 type,
488 value,
489 }: OverrideValueAtPath) => {
490 switch (type) {
491 case 'context':
492 this.send('overrideContext', {
493 id,
494 path,
495 rendererID,
496 wasForwarded: true,
497 value,
498 });
499 break;
500 case 'hooks':
501 this.send('overrideHookState', {
502 id,
503 path,
504 rendererID,
505 wasForwarded: true,
506 value,
507 });
508 break;
509 case 'props':
510 this.send('overrideProps', {
511 id,
512 path,
513 rendererID,
514 wasForwarded: true,
515 value,
516 });
517 break;
518 case 'state':
519 this.send('overrideState', {
520 id,
521 path,
522 rendererID,
523 wasForwarded: true,
524 value,
525 });
526 break;
527 }
528 };
529 }
530
531 export type BackendBridge = Bridge<BackendEvents, FrontendEvents>;
532 export type FrontendBridge = Bridge<FrontendEvents, BackendEvents>;
533
534 export default Bridge;