/** * Copyright (c) Meta Platforms, Inc. and affiliates. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * * @flow */ import EventEmitter from './events'; import type {ComponentFilter, Wall, WallMessage} from './frontend/types'; import type { InspectedElementPayload, OwnersList, ProfilingDataBackend, RendererID, DevToolsHookSettings, ProfilingSettings, } from 'react-devtools-shared/src/backend/types'; import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-shared/src/backend/NativeStyleEditor/types'; // This message specifies the version of the DevTools protocol currently supported by the backend, // as well as the earliest NPM version (e.g. "4.13.0") that protocol is supported by on the frontend. // This enables an older frontend to display an upgrade message to users for a newer, unsupported backend. export type BridgeProtocol = { // Version supported by the current frontend/backend. version: number, // NPM version range of `react-devtools-inline` that also supports this version. // Note that 'maxNpmVersion' is only set when the version is bumped. minNpmVersion: string, maxNpmVersion: string | null, }; // Bump protocol version whenever a backwards breaking change is made // in the messages sent between BackendBridge and FrontendBridge. // This mapping is embedded in both frontend and backend builds. // // The backend protocol will always be the latest entry in the BRIDGE_PROTOCOL array. // // When an older frontend connects to a newer backend, // the backend can send the minNpmVersion and the frontend can display an NPM upgrade prompt. // // When a newer frontend connects with an older protocol version, // the frontend can use the embedded minNpmVersion/maxNpmVersion values to display a downgrade prompt. export const BRIDGE_PROTOCOL: Array = [ // This version technically never existed, // but a backwards breaking change was added in 4.11, // so the safest guess to downgrade the frontend would be to version 4.10. { version: 0, minNpmVersion: '"<4.11.0"', maxNpmVersion: '"<4.11.0"', }, // Versions 4.11.x – 4.12.x contained the backwards breaking change, // but we didn't add the "fix" of checking the protocol version until 4.13, // so we don't recommend downgrading to 4.11 or 4.12. { version: 1, minNpmVersion: '4.13.0', maxNpmVersion: '4.21.0', }, // Version 2 adds a StrictMode-enabled and supports-StrictMode bits to add-root operation. { version: 2, minNpmVersion: '4.22.0', maxNpmVersion: null, }, ]; export const currentBridgeProtocol: BridgeProtocol = BRIDGE_PROTOCOL[BRIDGE_PROTOCOL.length - 1]; type ElementAndRendererID = {id: number, rendererID: RendererID}; export type Message = WallMessage; type QueuedMessage = { event: string, payload: mixed, }; type EventArguments = Payload extends void ? [] : [Payload]; type EventEmitterEvents = { [Event in keyof Events]: EventArguments, }; type HighlightHostInstance = { ...ElementAndRendererID, displayName: string | null, hideAfterTimeout: boolean, openBuiltinElementsPanel: boolean, scrollIntoView: boolean, }; type HighlightHostInstances = { elements: Array, displayName: string | null, hideAfterTimeout: boolean, scrollIntoView: boolean, }; type ScrollToHostInstance = { ...ElementAndRendererID, }; type OverrideValue = { ...ElementAndRendererID, path: Array, wasForwarded?: boolean, value: mixed, }; type OverrideHookState = { ...OverrideValue, hookID: number, }; type PathType = 'props' | 'hooks' | 'state' | 'context'; type DeletePath = { ...ElementAndRendererID, type: PathType, hookID?: ?number, path: Array, }; type RenamePath = { ...ElementAndRendererID, type: PathType, hookID?: ?number, oldPath: Array, newPath: Array, }; type OverrideValueAtPath = { ...ElementAndRendererID, type: PathType, hookID?: ?number, path: Array, value: mixed, }; type OverrideError = { ...ElementAndRendererID, forceError: boolean, }; type OverrideSuspense = { ...ElementAndRendererID, forceFallback: boolean, }; type OverrideSuspenseMilestone = { rendererID: number, suspendedSet: Array, }; type CopyElementPathParams = { ...ElementAndRendererID, path: Array, }; type ViewAttributeSourceParams = { ...ElementAndRendererID, path: Array, }; type InspectElementParams = { ...ElementAndRendererID, forceFullData: boolean, path: Array | null, requestID: number, }; type InspectScreenParams = { requestID: number, id: number, forceFullData: boolean, path: Array | null, }; type StoreAsGlobalParams = { ...ElementAndRendererID, count: number, path: Array, }; type NativeStyleEditor_RenameAttributeParams = { ...ElementAndRendererID, oldName: string, newName: string, value: string, }; type NativeStyleEditor_SetValueParams = { ...ElementAndRendererID, name: string, value: string, }; export type SavedPreferencesParams = { componentFilters: Array, }; export type BackendEvents = { backendInitialized: void, backendVersion: string, bridgeProtocol: BridgeProtocol, extensionBackendInitialized: void, fastRefreshScheduled: void, getSavedPreferences: void, inspectedElement: InspectedElementPayload, inspectedScreen: InspectedElementPayload, isReloadAndProfileSupportedByBackend: boolean, operations: Array, ownersList: OwnersList, environmentNames: Array, profilingData: ProfilingDataBackend, profilingStatus: boolean, reloadAppForProfiling: void, saveToClipboard: string, selectElement: number | null, shutdown: void, stopInspectingHost: boolean, scrollTo: {left: number, top: number, right: number, bottom: number}, syncSelectionToBuiltinElementsPanel: void, unsupportedRendererVersion: void, extensionComponentsPanelShown: void, extensionComponentsPanelHidden: void, resumeElementPolling: void, pauseElementPolling: void, // React Native style editor plug-in. isNativeStyleEditorSupported: { isSupported: boolean, validAttributes: ?$ReadOnlyArray, }, NativeStyleEditor_styleAndLayout: StyleAndLayoutPayload, hookSettings: $ReadOnly, }; type StartProfilingParams = ProfilingSettings; type ReloadAndProfilingParams = ProfilingSettings; export type FrontendEvents = { clearErrorsAndWarnings: {rendererID: RendererID}, clearErrorsForElementID: ElementAndRendererID, clearHostInstanceHighlight: void, clearWarningsForElementID: ElementAndRendererID, copyElementPath: CopyElementPathParams, deletePath: DeletePath, getBackendVersion: void, getBridgeProtocol: void, getIfHasUnsupportedRendererVersion: void, getOwnersList: ElementAndRendererID, getProfilingData: {rendererID: RendererID}, getProfilingStatus: void, highlightHostInstance: HighlightHostInstance, highlightHostInstances: HighlightHostInstances, inspectElement: InspectElementParams, inspectScreen: InspectScreenParams, logElementToConsole: ElementAndRendererID, overrideError: OverrideError, overrideSuspense: OverrideSuspense, overrideSuspenseMilestone: OverrideSuspenseMilestone, overrideValueAtPath: OverrideValueAtPath, profilingData: ProfilingDataBackend, reloadAndProfile: ReloadAndProfilingParams, renamePath: RenamePath, savedPreferences: SavedPreferencesParams, setTraceUpdatesEnabled: boolean, shutdown: void, startInspectingHost: boolean, startProfiling: StartProfilingParams, stopInspectingHost: void, scrollToHostInstance: ScrollToHostInstance, scrollTo: {left: number, top: number, right: number, bottom: number}, requestScrollPosition: void, stopProfiling: void, storeAsGlobal: StoreAsGlobalParams, updateComponentFilters: Array, getEnvironmentNames: void, updateHookSettings: $ReadOnly, viewAttributeSource: ViewAttributeSourceParams, viewElementSource: ElementAndRendererID, syncSelectionFromBuiltinElementsPanel: void, // React Native style editor plug-in. NativeStyleEditor_measure: ElementAndRendererID, NativeStyleEditor_renameAttribute: NativeStyleEditor_RenameAttributeParams, NativeStyleEditor_setValue: NativeStyleEditor_SetValueParams, // Temporarily support newer standalone front-ends sending commands to older embedded backends. // We do this because React Native embeds the React DevTools backend, // but cannot control which version of the frontend users use. // // Note that nothing in the newer backend actually listens to these events, // but the new frontend still dispatches them (in case older backends are listening to them instead). // // Note that this approach does no support the combination of a newer backend with an older frontend. // It would be more work to support both approaches (and not run handlers twice) // so I chose to support the more likely/common scenario (and the one more difficult for an end user to "fix"). overrideContext: OverrideValue, overrideHookState: OverrideHookState, overrideProps: OverrideValue, overrideState: OverrideValue, getHookSettings: void, }; class Bridge< OutgoingEvents: Object, IncomingEvents: Object, > extends EventEmitter> { _isShutdown: boolean = false; _messageQueue: Array = []; _scheduledFlush: boolean = false; _wall: Wall; _wallUnlisten: (() => void) | null = null; constructor(wall: Wall) { super(); this._wall = wall; const wallUnlisten = wall.listen(this._handleMessage); if (typeof wallUnlisten !== 'function') { throw new TypeError('Wall.listen() must return an unlisten function.'); } this._wallUnlisten = wallUnlisten; // Temporarily support older standalone front-ends sending commands to newer embedded backends. // We do this because React Native embeds the React DevTools backend, // but cannot control which version of the frontend users use. this.addListener('overrideValueAtPath', this.overrideValueAtPath); } // Listening directly to the wall isn't advised. // It can be used to listen for legacy (v3) messages (since they use a different format). get wall(): Wall { return this._wall; } addListener>>( event: Event, listener: (...EventEmitterEvents[Event]) => mixed, ): void { this._assertNotShutdown('add a listener'); super.addListener(event, listener); } emit>>( event: Event, ...args: EventEmitterEvents[Event] ): void { this._assertNotShutdown('emit an event'); super.emit(event, ...args); } send>( event: EventName, payload?: OutgoingEvents[EventName], ): void { this._assertNotShutdown('send a message'); if (typeof event !== 'string' || event.length === 0) { throw new TypeError('Bridge event names must be non-empty strings.'); } // When we receive a message: // - we add it to our queue of messages to be sent // - if there hasn't been a message recently, we set a timer for 0 ms in // the future, allowing all messages created in the same tick to be sent // together // - if there *has* been a message flushed in the last BATCH_DURATION ms // (or we're waiting for our setTimeout-0 to fire), then _timeoutID will // be set, and we'll simply add to the queue and wait for that this._messageQueue.push({ event, payload, }); if (!this._scheduledFlush) { this._scheduledFlush = true; // $FlowFixMe[cannot-resolve-name] if (typeof devtoolsJestTestScheduler === 'function') { // This exists just for our own jest tests. // They're written in such a way that we can neither mock queueMicrotask // because then we break React DOM and we can't not mock it because then // we can't synchronously flush it. So they need to be rewritten. // $FlowFixMe[cannot-resolve-name] devtoolsJestTestScheduler(this._flush); // eslint-disable-line no-undef } else { queueMicrotask(this._flush); } } } shutdown(): void { this._assertNotShutdown('shut down'); // Queue the shutdown outgoing message for subscribers. this.emit('shutdown'); this.send('shutdown'); // Mark this bridge as destroyed, i.e. disable its public API. this._isShutdown = true; // Unsubscribe this bridge incoming message listeners to be sure, and so they don't have to do that. this.removeAllListeners(); // Stop accepting and emitting incoming messages from the wall. const wallUnlisten = this._wallUnlisten; this._wallUnlisten = null; try { if (wallUnlisten !== null) { wallUnlisten(); } } finally { // Synchronously flush all queued outgoing messages. // At this step the subscribers' code may run in this call stack. do { this._flush(); } while (this._messageQueue.length); } } _flush: () => void = () => { // This method is used after the bridge is marked as destroyed in shutdown sequence, // so we do not bail out if the bridge marked as destroyed. // It is a private method that the bridge ensures is only called at the right times. try { if (this._messageQueue.length) { for (let i = 0; i < this._messageQueue.length; i++) { const {event, payload} = this._messageQueue[i]; this._wall.send(event, payload); } this._messageQueue.length = 0; } } finally { // We set this at the end in case new messages are added synchronously above. // They're already handled so they shouldn't queue more flushes. this._scheduledFlush = false; } }; _assertNotShutdown(action: string): void { if (this._isShutdown) { throw new Error( `Cannot ${action} through a Bridge that has been shut down.`, ); } } _handleMessage: (message: mixed) => void = message => { // Some Walls share a transport with unrelated messages or legacy DevTools // protocols. A message without an event field does not belong to this Bridge. if ( message === null || typeof message !== 'object' || !('event' in message) ) { return; } const event = message.event; if (typeof event !== 'string' || event.length === 0) { throw new TypeError('Bridge event names must be non-empty strings.'); } this._assertNotShutdown('receive a message'); // The wire event name cannot be statically refined to a key of IncomingEvents. (this as any).emit(event, message.payload); }; // Temporarily support older standalone backends by forwarding "overrideValueAtPath" commands // to the older message types they may be listening to. overrideValueAtPath: OverrideValueAtPath => void = ({ id, path, rendererID, type, value, }: OverrideValueAtPath) => { switch (type) { case 'context': this.send('overrideContext', { id, path, rendererID, wasForwarded: true, value, }); break; case 'hooks': this.send('overrideHookState', { id, path, rendererID, wasForwarded: true, value, }); break; case 'props': this.send('overrideProps', { id, path, rendererID, wasForwarded: true, value, }); break; case 'state': this.send('overrideState', { id, path, rendererID, wasForwarded: true, value, }); break; } }; } export type BackendBridge = Bridge; export type FrontendBridge = Bridge; export default Bridge;