mirror of
https://github.com/facebook/react.git
synced 2025-11-01 09:12:30 +00:00
af16f755dc
DevTools was built with a fork of an early idea for how Suspense cache might work. This idea is incompatible with newer APIs like `useTransition` which unfortunately prevented me from making certain UX improvements. This PR swaps out the primary usage of this cache (there are a few) in favor of the newer `unstable_getCacheForType` and `unstable_useCacheRefresh` APIs. We can go back and update the others in follow up PRs.
### Messaging changes
I've refactored the way the frontend loads component props/state/etc to hopefully make it better match the Suspense+cache model. Doing this gave up some of the small optimizations I'd added but hopefully the actual performance impact of that is minor and the overall ergonomic improvements of working with the cache API make this worth it.
The backend no longer remembers inspected paths. Instead, the frontend sends them every time and the backend sends a response with those paths. I've also added a new "force" parameter that the frontend can use to tell the backend to send a response even if the component hasn't rendered since the last time it asked. (This is used to get data for newly inspected paths.)
_Initial inspection..._
```
front | | back
| -- "inspect" (id:1, paths:[], force:true) ---------> |
| <------------------------ "inspected" (full-data) -- |
```
_1 second passes with no updates..._
```
| -- "inspect" (id:1, paths:[], force:false) --------> |
| <------------------------ "inspected" (no-change) -- |
```
_User clicks to expand a path, aka hydrate..._
```
| -- "inspect" (id:1, paths:['foo'], force:true) ----> |
| <------------------------ "inspected" (full-data) -- |
```
_1 second passes during which there is an update..._
```
| -- "inspect" (id:1, paths:['foo'], force:false) ---> |
| <----------------- "inspectedElement" (full-data) -- |
```
### Clear errors/warnings transition
Previously this meant there would be a delay after clicking the "clear" button. The UX after this change is much improved.
### Hydrating paths transition
I also added a transition to hydration (expanding "dehyrated" paths).
### Better error boundaries
I also added a lower-level error boundary in case the new suspense operation ever failed. It provides a better "retry" mechanism (select a new element) so DevTools doesn't become entirely useful. Here I'm intentionally causing an error every time I select an element.
### Improved snapshot tests
I also migrated several of the existing snapshot tests to use inline snapshots and added a new serializer for dehydrated props. Inline snapshots are easier to verify and maintain and the new serializer means dehydrated props will be formatted in a way that makes sense rather than being empty (in external snapshots) or super verbose (default inline snapshot format).
376 lines
11 KiB
JavaScript
376 lines
11 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;
|
|
|
|
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,
|
|
forceUpdate: boolean,
|
|
inspectedPaths: Object,
|
|
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 = {|
|
|
extensionBackendInitialized: [],
|
|
inspectedElement: [InspectedElementPayload],
|
|
isBackendStorageAPISupported: [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],
|
|
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;
|