Files
react/packages/react-devtools-shared/src/bridge.js
Sebastian Markbåge 4ea12a11d1 [DevTools] Make Element Inspection Feel Snappy (#30555)
There's two problems. The biggest one is that it turns out that Chrome
is throttling looping timers that we're using both while polling and for
batching bridge traffic. This means that bridge traffic a lot of the
time just slows down to 1 second at a time. No wonder it feels sluggish.
The only solution is to not use timers for this.

Even when it doesn't like in Firefox the batching into 100ms still feels
too sluggish.

The fix I use is to batch using a microtask instead so we can still
batch multiple commands sent in a single event but we never artificially
slow down an interaction.

I don't think we've reevaluated this for a long time since this was in
the initial commit of DevTools to this repo. If it causes other issues
we can follow up on those.

We really shouldn't use timers for debouncing and such. In fact, React
itself recommends against it because we have a better technique with
scheduling in Concurrent Mode. The correct way to implement this in the
bridge is using a form of back-pressure where we don't keep sending
messages until we get a message back and only send the last one that
matters. E.g. when moving the cursor over a the elements tab we
shouldn't let the backend one-by-one move the DOM node to each one we
have ever passed. We should just move to the last one we're currently
hovering over. But this can't be done at the bridge layer since it
doesn't know if it's a last-one-wins or imperative operation where each
one needs to be sent. It needs to be done higher. I'm not currently
seeing any perf problems with this new approach but I'm curious on React
Native or some thing. RN might need the back-pressure approach. That can
be a follow up if we ever find a test case.

Finally, the other problem is that we use a Suspense boundary around the
Element Inspection. Suspense boundaries are for things that are expected
to take a long time to load. This shows a loading state immediately. To
avoid flashing when it ends up being fast, React throttles the reveal to
200ms. This means that we take a minimum of 200ms to show the props. The
way to show fast async data in React is using a Transition (either using
startTransition or useDeferredValue). This lets the old value remaining
in place while we're loading the next one.

We already implement this using `inspectedElementID` which is the async
one. It would be more idiomatic to implement this with useDeferredValue
rather than the reducer we have now but same principle. We were just
using the wrong ID in a few places so when it synchronously updated they
suspended. So I just made them use the inspectedElementID instead.

Then I can simply remove the Suspense boundary. Now the selection
updates in the tree view synchronously and the sidebar lags a frame or
two but it feels instant. It doesn't flash to white between which is
key.
2024-08-01 11:04:56 -04:00

449 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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';
// 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 HighlightHostInstance = {
...ElementAndRendererID,
displayName: string | null,
hideAfterTimeout: boolean,
openBuiltinElementsPanel: 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],
selectElement: [number],
shutdown: [],
stopInspectingHost: [boolean],
syncSelectionFromBuiltinElementsPanel: [],
syncSelectionToBuiltinElementsPanel: [],
unsupportedRendererVersion: [RendererID],
// React Native style editor plug-in.
isNativeStyleEditorSupported: [
{isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
],
NativeStyleEditor_styleAndLayout: [StyleAndLayoutPayload],
};
type FrontendEvents = {
clearErrorsAndWarnings: [{rendererID: RendererID}],
clearErrorsForElementID: [ElementAndRendererID],
clearHostInstanceHighlight: [],
clearWarningsForElementID: [ElementAndRendererID],
copyElementPath: [CopyElementPathParams],
deletePath: [DeletePath],
getBackendVersion: [],
getBridgeProtocol: [],
getOwnersList: [ElementAndRendererID],
getProfilingData: [{rendererID: RendererID}],
getProfilingStatus: [],
highlightHostInstance: [HighlightHostInstance],
inspectElement: [InspectElementParams],
logElementToConsole: [ElementAndRendererID],
overrideError: [OverrideError],
overrideSuspense: [OverrideSuspense],
overrideValueAtPath: [OverrideValueAtPath],
profilingData: [ProfilingDataBackend],
reloadAndProfile: [boolean],
renamePath: [RenamePath],
savedPreferences: [SavedPreferencesParams],
setTraceUpdatesEnabled: [boolean],
shutdown: [],
startInspectingHost: [],
startProfiling: [boolean],
stopInspectingHost: [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> = [];
_scheduledFlush: boolean = false;
_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._scheduledFlush) {
this._scheduledFlush = true;
// $FlowFixMe
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
devtoolsJestTestScheduler(this._flush); // eslint-disable-line no-undef
} else {
queueMicrotask(this._flush);
}
}
}
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);
}
_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 += 2) {
this._wall.send(this._messageQueue[i], ...this._messageQueue[i + 1]);
}
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;
}
};
// 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;