/**
* 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} from './frontend/types';
import type {
InspectedElementPayload,
OwnersList,
ProfilingDataBackend,
RendererID,
ConsolePatchSettings,
} from 'react-devtools-shared/src/backend/types';
import type {StyleAndLayout as StyleAndLayoutPayload} from 'react-devtools-shared/src/backend/NativeStyleEditor/types';
const BATCH_DURATION = 100;
// 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 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<BridgeProtocol> = [
// 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};
type Message = {
event: string,
payload: any,
};
type HighlightElementInDOM = {
...ElementAndRendererID,
displayName: string | null,
hideAfterTimeout: boolean,
openNativeElementsPanel: boolean,
scrollIntoView: boolean,
};
type OverrideValue = {
...ElementAndRendererID,
path: Array<string | number>,
wasForwarded?: boolean,
value: any,
};
type OverrideHookState = {
...OverrideValue,
hookID: number,
};
type PathType = 'props' | 'hooks' | 'state' | 'context';
type DeletePath = {
...ElementAndRendererID,
type: PathType,
hookID?: ?number,
path: Array<string | number>,
};
type RenamePath = {
...ElementAndRendererID,
type: PathType,
hookID?: ?number,
oldPath: Array<string | number>,
newPath: Array<string | number>,
};
type OverrideValueAtPath = {
...ElementAndRendererID,
type: PathType,
hookID?: ?number,
path: Array<string | number>,
value: any,
};
type OverrideError = {
...ElementAndRendererID,
forceError: boolean,
};
type OverrideSuspense = {
...ElementAndRendererID,
forceFallback: boolean,
};
type CopyElementPathParams = {
...ElementAndRendererID,
path: Array<string | number>,
};
type ViewAttributeSourceParams = {
...ElementAndRendererID,
path: Array<string | number>,
};
type InspectElementParams = {
...ElementAndRendererID,
forceFullData: boolean,
path: Array<number | string> | null,
requestID: number,
};
type StoreAsGlobalParams = {
...ElementAndRendererID,
count: number,
path: Array<string | number>,
};
type NativeStyleEditor_RenameAttributeParams = {
...ElementAndRendererID,
oldName: string,
newName: string,
value: string,
};
type NativeStyleEditor_SetValueParams = {
...ElementAndRendererID,
name: string,
value: string,
};
type SavedPreferencesParams = {
appendComponentStack: boolean,
breakOnConsoleErrors: boolean,
componentFilters: Array<ComponentFilter>,
showInlineWarningsAndErrors: boolean,
hideConsoleLogsInStrictMode: boolean,
};
export type BackendEvents = {
backendVersion: [string],
bridgeProtocol: [BridgeProtocol],
extensionBackendInitialized: [],
fastRefreshScheduled: [],
getSavedPreferences: [],
inspectedElement: [InspectedElementPayload],
isBackendStorageAPISupported: [boolean],
isSynchronousXHRSupported: [boolean],
operations: [Array<number>],
ownersList: [OwnersList],
overrideComponentFilters: [Array<ComponentFilter>],
profilingData: [ProfilingDataBackend],
profilingStatus: [boolean],
reloadAppForProfiling: [],
saveToClipboard: [string],
selectFiber: [number],
shutdown: [],
stopInspectingNative: [boolean],
syncSelectionFromNativeElementsPanel: [],
syncSelectionToNativeElementsPanel: [],
unsupportedRendererVersion: [RendererID],
// React Native style editor plug-in.
isNativeStyleEditorSupported: [
{isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
],
NativeStyleEditor_styleAndLayout: [StyleAndLayoutPayload],
};
type FrontendEvents = {
clearErrorsAndWarnings: [{rendererID: RendererID}],
clearErrorsForFiberID: [ElementAndRendererID],
clearNativeElementHighlight: [],
clearWarningsForFiberID: [ElementAndRendererID],
copyElementPath: [CopyElementPathParams],
deletePath: [DeletePath],
getBackendVersion: [],
getBridgeProtocol: [],
getOwnersList: [ElementAndRendererID],
getProfilingData: [{rendererID: RendererID}],
getProfilingStatus: [],
highlightNativeElement: [HighlightElementInDOM],
inspectElement: [InspectElementParams],
logElementToConsole: [ElementAndRendererID],
overrideError: [OverrideError],
overrideSuspense: [OverrideSuspense],
overrideValueAtPath: [OverrideValueAtPath],
profilingData: [ProfilingDataBackend],
reloadAndProfile: [boolean],
renamePath: [RenamePath],
savedPreferences: [SavedPreferencesParams],
selectFiber: [number],
setTraceUpdatesEnabled: [boolean],
shutdown: [],
startInspectingNative: [],
startProfiling: [boolean],
stopInspectingNative: [boolean],
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
updateComponentFilters: [Array<ComponentFilter>],
updateConsolePatchSettings: [ConsolePatchSettings],
viewAttributeSource: [ViewAttributeSourceParams],
viewElementSource: [ElementAndRendererID],
// 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],
resumeElementPolling: [],
pauseElementPolling: [],
};
class Bridge<
OutgoingEvents: Object,
IncomingEvents: Object,
> extends EventEmitter<{
...IncomingEvents,
...OutgoingEvents,
}> {
_isShutdown: boolean = false;
_messageQueue: Array<any> = [];
_timeoutID: TimeoutID | null = null;
_wall: Wall;
_wallUnlisten: Function | null = null;
constructor(wall: Wall) {
super();
this._wall = wall;
this._wallUnlisten =
wall.listen((message: Message) => {
if (message && message.event) {
(this: any).emit(message.event, message.payload);
}
}) || null;
// 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;
}
send<EventName: $Keys<OutgoingEvents>>(
event: EventName,
...payload: $ElementType<OutgoingEvents, EventName>
) {
if (this._isShutdown) {
console.warn(
`Cannot send message "${event}" through a Bridge that has been shutdown.`,
);
return;
}
// 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._timeoutID) {
this._timeoutID = setTimeout(this._flush, 0);
}
}
shutdown() {
if (this._isShutdown) {
console.warn('Bridge was already shutdown.');
return;
}
// 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;
// Disable the API inherited from EventEmitter that can add more listeners and send more messages.
// $FlowFixMe[cannot-write] This property is not writable.
this.addListener = function () {};
// $FlowFixMe[cannot-write] This property is not writable.
this.emit = function () {};
// NOTE: There's also EventEmitter API like `on` and `prependListener` that we didn't add to our Flow type of EventEmitter.
// 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;
if (wallUnlisten) {
wallUnlisten();
}
// 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);
// Make sure once again that there is no dangling timer.
if (this._timeoutID !== null) {
clearTimeout(this._timeoutID);
this._timeoutID = null;
}
}
_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.
if (this._timeoutID !== null) {
clearTimeout(this._timeoutID);
this._timeoutID = null;
}
if (this._messageQueue.length) {
for (let i = 0; i < this._messageQueue.length; i += 2) {
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
}
this._messageQueue.length = 0;
// Check again for queued messages in BATCH_DURATION ms. This will keep
// flushing in a loop as long as messages continue to be added. Once no
// more are, the timer expires.
this._timeoutID = setTimeout(this._flush, BATCH_DURATION);
}
};
// 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<BackendEvents, FrontendEvents>;
export type FrontendBridge = Bridge<FrontendEvents, BackendEvents>;
export default Bridge;