Files
flipper/desktop/plugins/public/ui-debugger/index.tsx
Luke De Feo 795d1de10d Dont auto expand nodes
Summary:
Rather than automatically collapsing siblings when using the visualiser instead we take a different approach:
1. The tree starts out fully collapsed
2. Every time you click on the visualiser we expand it and its ancestory chain to expanded nodes.

This is exactly how the Dom inspector works. The previous approach of constantly collapsing all siblings when uinsg the visualiser felt too intrusive and taking control from the user. The option is still there but only in the context menu

Some ultilities around autocollapsing nodes were removed as they dont make sense anymore that we now send complete frames

Changelog: UIDebugger Tree starts collapsed and expands as you click from the visualiser

Reviewed By: aigoncharov

Differential Revision: D47949843

fbshipit-source-id: 4381d22b12874dde5a89267572bee95f084380e3
2023-08-01 10:32:29 -07:00

350 lines
10 KiB
TypeScript

/**
* 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.
*
* @format
*/
import {createDataSource, createState, PluginClient} from 'flipper-plugin';
import {
Events,
FrameScanEvent,
FrameworkEvent,
FrameworkEventType,
Id,
Metadata,
MetadataId,
PerformanceStatsEvent,
SnapshotInfo,
ClientNode,
} from './ClientTypes';
import {
UIState,
NodeSelection,
StreamInterceptorError,
StreamState,
ReadOnlyUIState,
LiveClientState,
WireFrameMode,
} from './DesktopTypes';
import {getStreamInterceptor} from './fb-stubs/StreamInterceptor';
import {prefetchSourceFileLocation} from './components/fb-stubs/IDEContextMenu';
import {checkFocusedNodeStillActive} from './plugin/ClientDataUtils';
import {uiActions} from './plugin/uiActions';
type PendingData = {
metadata: Record<MetadataId, Metadata>;
frame: FrameScanEvent | null;
};
export function plugin(client: PluginClient<Events>) {
const rootId = createState<Id | undefined>(undefined);
const metadata = createState<Map<MetadataId, Metadata>>(new Map());
const streamInterceptor = getStreamInterceptor(client.device.os);
const snapshot = createState<SnapshotInfo | null>(null);
const nodesAtom = createState<Map<Id, ClientNode>>(new Map());
const frameworkEvents = createDataSource<FrameworkEvent>([], {
indices: [['nodeId']],
limit: 10000,
});
const uiState: UIState = createUIState();
//this is the client data is what drives all of desktop UI
//it is always up-to-date with the client regardless of whether we are paused or not
const mutableLiveClientData: LiveClientState = {
snapshotInfo: null,
nodes: new Map(),
};
const perfEvents = createDataSource<PerformanceStatsEvent, 'txId'>([], {
key: 'txId',
limit: 10 * 1024,
});
//this holds pending any pending data that needs to be applied in the event of a stream interceptor error
//while in the error state more metadata or a more recent frame may come in so both cases need to apply the same darta
const pendingData: PendingData = {frame: null, metadata: {}};
let lastFrameTime = 0;
client.onMessage('init', (event) => {
console.log('[ui-debugger] init');
rootId.set(event.rootId);
uiState.frameworkEventMonitoring.update((draft) => {
event.frameworkEventMetadata?.forEach((frameworkEventMeta) => {
draft.set(frameworkEventMeta.type, false);
});
});
});
client.onConnect(() => {
uiState.isConnected.set(true);
console.log('[ui-debugger] connected');
});
client.onDisconnect(() => {
uiState.isConnected.set(false);
console.log('[ui-debugger] disconnected');
});
async function processMetadata(
incomingMetadata: Record<MetadataId, Metadata>,
) {
try {
const mappedMeta = await Promise.all(
Object.values(incomingMetadata).map((metadata) =>
streamInterceptor.transformMetadata(metadata),
),
);
metadata.update((draft) => {
for (const metadata of mappedMeta) {
draft.set(metadata.id, metadata);
}
});
return true;
} catch (error) {
for (const metadata of Object.values(incomingMetadata)) {
pendingData.metadata[metadata.id] = metadata;
}
handleStreamError('Metadata', error);
return false;
}
}
function handleStreamError(source: 'Frame' | 'Metadata', error: any) {
if (error instanceof StreamInterceptorError) {
const retryCallback = async () => {
uiState.streamState.set({state: 'RetryingAfterError'});
if (!(await processMetadata(pendingData.metadata))) {
//back into error state, dont proceed
return;
}
if (pendingData.frame != null) {
if (!(await processFrame(pendingData.frame))) {
//back into error state, dont proceed
return;
}
}
uiState.streamState.set({state: 'Ok'});
pendingData.frame = null;
pendingData.metadata = {};
};
uiState.streamState.set({
state: 'StreamInterceptorRetryableError',
retryCallback: retryCallback,
error: error,
});
} else {
console.error(
`[ui-debugger] Unexpected Error processing ${source}`,
error,
);
uiState.streamState.set({
state: 'FatalError',
error: error,
clearCallBack: async () => {
uiState.streamState.set({state: 'Ok'});
nodesAtom.set(new Map());
frameworkEvents.clear();
snapshot.set(null);
},
});
}
}
client.onMessage('metadataUpdate', async (event) => {
if (!event.attributeMetadata) {
return;
}
await processMetadata(event.attributeMetadata);
});
/**
* The message handling below is a temporary measure for a couple of weeks until
* clients migrate to the newer message/format.
*/
client.onMessage('perfStats', (event) => {
const stat = {
txId: event.txId,
observerType: event.observerType,
nodesCount: event.nodesCount,
start: event.start,
traversalMS: event.traversalComplete - event.start,
snapshotMS: event.snapshotComplete - event.traversalComplete,
queuingMS: event.queuingComplete - event.snapshotComplete,
deferredComputationMS:
event.deferredComputationComplete - event.queuingComplete,
serializationMS:
event.serializationComplete - event.deferredComputationComplete,
socketMS: event.socketComplete - event.serializationComplete,
};
client.logger.track('performance', 'subtreeUpdate', stat, 'ui-debugger');
perfEvents.append(stat);
});
client.onMessage('performanceStats', (event) => {
client.logger.track('performance', 'subtreeUpdate', event, 'ui-debugger');
perfEvents.append(event);
});
const processFrame = async (frameScan: FrameScanEvent) => {
try {
const [processedNodes, additionalMetadata] =
await streamInterceptor.transformNodes(
new Map(frameScan.nodes.map((node) => [node.id, {...node}])),
);
metadata.update((draft) => {
for (const metadata of additionalMetadata) {
draft.set(metadata.id, metadata);
}
});
if (frameScan.frameTime > lastFrameTime) {
applyFrameData(processedNodes, frameScan.snapshot);
lastFrameTime = frameScan.frameTime;
}
applyFrameworkEvents(frameScan);
return true;
} catch (error) {
pendingData.frame = frameScan;
handleStreamError('Frame', error);
return false;
}
};
function applyFrameworkEvents(frameScan: FrameScanEvent) {
for (const frameworkEvent of frameScan.frameworkEvents ?? []) {
frameworkEvents.append(frameworkEvent);
}
if (uiState.isPaused.get() === true) {
return;
}
const monitoredEvents = uiState.frameworkEventMonitoring.get();
const filterMainThread = uiState.filterMainThreadMonitoring.get();
const nodesToHighlight =
frameScan.frameworkEvents
?.filter(
(frameworkEvent) => monitoredEvents.get(frameworkEvent.type) === true,
)
.filter(
(frameworkEvent) =>
filterMainThread === false || frameworkEvent.thread === 'main',
)
.map((event) => event.nodeId) ?? [];
uiState.highlightedNodes.update((draft) => {
for (const node of nodesToHighlight) {
draft.add(node);
}
});
setTimeout(() => {
uiState.highlightedNodes.update((draft) => {
for (const nodeId of nodesToHighlight) {
draft.delete(nodeId);
}
});
}, HighlightTime);
}
//todo deal with racecondition, where bloks screen is fetching, takes time then you go back get more recent frame then bloks screen comes and overrites it
function applyFrameData(
nodes: Map<Id, ClientNode>,
snapshotInfo: SnapshotInfo | undefined,
) {
if (snapshotInfo) {
mutableLiveClientData.snapshotInfo = snapshotInfo;
}
mutableLiveClientData.nodes = nodes;
if (!uiState.isPaused.get()) {
nodesAtom.set(mutableLiveClientData.nodes);
snapshot.set(mutableLiveClientData.snapshotInfo);
checkFocusedNodeStillActive(uiState, nodesAtom.get());
}
setTimeout(() => {
//let react render, this can happen async
for (const node of nodes.values()) {
prefetchSourceFileLocation(node);
}
}, 0);
}
client.onMessage('subtreeUpdate', (subtreeUpdate) => {
processFrame({
frameTime: subtreeUpdate.txId,
nodes: subtreeUpdate.nodes,
snapshot: {data: subtreeUpdate.snapshot, nodeId: subtreeUpdate.rootId},
frameworkEvents: subtreeUpdate.frameworkEvents,
});
});
client.onMessage('frameScan', processFrame);
return {
rootId,
uiState: uiState as ReadOnlyUIState,
uiActions: uiActions(uiState, nodesAtom, snapshot, mutableLiveClientData),
nodes: nodesAtom,
frameworkEvents,
snapshot,
metadata,
perfEvents,
os: client.device.os,
};
}
const HighlightTime = 300;
export {Component} from './components/main';
export * from './ClientTypes';
function createUIState(): UIState {
return {
isConnected: createState(false),
viewMode: createState({mode: 'default'}),
//used to disabled hover effects which cause rerenders and mess up the existing context menu
isContextMenuOpen: createState<boolean>(false),
streamState: createState<StreamState>({state: 'Ok'}),
visualiserWidth: createState(Math.min(window.innerWidth / 4.5, 500)),
highlightedNodes: createState(new Set<Id>()),
selectedNode: createState<NodeSelection | undefined>(undefined),
//used to indicate whether we will higher the visualizer / tree when a matching event comes in
//also whether or not will show running total in the tree
frameworkEventMonitoring: createState(
new Map<FrameworkEventType, boolean>(),
),
filterMainThreadMonitoring: createState(false),
isPaused: createState(false),
//The reason for the array as that user could be hovering multiple overlapping nodes at once in the visualiser.
//The nodes are sorted by area since you most likely want to select the smallest node under your cursor
hoveredNodes: createState<Id[]>([]),
searchTerm: createState<string>(''),
focusedNode: createState<Id | undefined>(undefined),
expandedNodes: createState<Set<Id>>(new Set()),
wireFrameMode: createState<WireFrameMode>('All'),
};
}