mirror of
https://github.com/facebook/react.git
synced 2026-02-25 13:13:03 +00:00
Add an explicit Bridge protocol version to the frontend and backend components as well as a check during initialization to ensure that both are compatible. If not, the frontend will display either upgrade or downgrade instructions. Note that only the `react-devtools-core` (React Native) and `react-devtools-inline` (Code Sandbox) packages implement this check. Browser extensions inject their own backend and so the check is unnecessary. (Arguably the `react-devtools-inline` check is also unlikely to be necessary _but_ has been added as an extra guard for use cases such as Replay.io.)
421 lines
12 KiB
JavaScript
421 lines
12 KiB
JavaScript
/**
|
|
* Copyright (c) Facebook, Inc. and its 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 './types';
|
|
import type {
|
|
InspectedElementPayload,
|
|
OwnersList,
|
|
ProfilingDataBackend,
|
|
RendererID,
|
|
} 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',
|
|
},
|
|
{
|
|
version: 1,
|
|
minNpmVersion: '4.13.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 OverrideSuspense = {|
|
|
...ElementAndRendererID,
|
|
forceFallback: boolean,
|
|
|};
|
|
|
|
type CopyElementPathParams = {|
|
|
...ElementAndRendererID,
|
|
path: Array<string | number>,
|
|
|};
|
|
|
|
type ViewAttributeSourceParams = {|
|
|
...ElementAndRendererID,
|
|
path: Array<string | number>,
|
|
|};
|
|
|
|
type InspectElementParams = {|
|
|
...ElementAndRendererID,
|
|
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 UpdateConsolePatchSettingsParams = {|
|
|
appendComponentStack: boolean,
|
|
breakOnConsoleErrors: boolean,
|
|
showInlineWarningsAndErrors: boolean,
|
|
|};
|
|
|
|
export type BackendEvents = {|
|
|
bridgeProtocol: [BridgeProtocol],
|
|
extensionBackendInitialized: [],
|
|
inspectedElement: [InspectedElementPayload],
|
|
isBackendStorageAPISupported: [boolean],
|
|
isSynchronousXHRSupported: [boolean],
|
|
operations: [Array<number>],
|
|
ownersList: [OwnersList],
|
|
overrideComponentFilters: [Array<ComponentFilter>],
|
|
profilingData: [ProfilingDataBackend],
|
|
profilingStatus: [boolean],
|
|
reloadAppForProfiling: [],
|
|
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],
|
|
getBridgeProtocol: [],
|
|
getOwnersList: [ElementAndRendererID],
|
|
getProfilingData: [{|rendererID: RendererID|}],
|
|
getProfilingStatus: [],
|
|
highlightNativeElement: [HighlightElementInDOM],
|
|
inspectElement: [InspectElementParams],
|
|
logElementToConsole: [ElementAndRendererID],
|
|
overrideSuspense: [OverrideSuspense],
|
|
overrideValueAtPath: [OverrideValueAtPath],
|
|
profilingData: [ProfilingDataBackend],
|
|
reloadAndProfile: [boolean],
|
|
renamePath: [RenamePath],
|
|
selectFiber: [number],
|
|
setTraceUpdatesEnabled: [boolean],
|
|
shutdown: [],
|
|
startInspectingNative: [],
|
|
startProfiling: [boolean],
|
|
stopInspectingNative: [boolean],
|
|
stopProfiling: [],
|
|
storeAsGlobal: [StoreAsGlobalParams],
|
|
updateComponentFilters: [Array<ComponentFilter>],
|
|
updateConsolePatchSettings: [UpdateConsolePatchSettingsParams],
|
|
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 suppot 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],
|
|
|};
|
|
|
|
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) => {
|
|
(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.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 This property is not writable.
|
|
this.addListener = function() {};
|
|
// $FlowFixMe 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 = () => {
|
|
// 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 = ({
|
|
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;
|