UID refactor 4/ Expose readonly UIState

Summary:
Currently state writes can either go through a named handler that is easy to find and debug or they can directly modify the state.

By exposing readonly atoms only we ensure that all state writes go through a UIACtions. This adds consistency and ease of future debugging.

E.g We could add a utility to wrap all ui actions with logging statements

Reviewed By: antonk52

Differential Revision: D47547531

fbshipit-source-id: f88651169d8e7c5f7e31068d64f9aa5b6b573647
This commit is contained in:
Luke De Feo
2023-07-21 07:17:31 -07:00
committed by Facebook GitHub Bot
parent 87a1b657c3
commit 957a336349
7 changed files with 86 additions and 69 deletions

View File

@@ -7,7 +7,7 @@
* @format * @format
*/ */
import {Atom} from 'flipper-plugin'; import {Atom, _ReadOnlyAtom} from 'flipper-plugin';
import { import {
Id, Id,
FrameworkEventType, FrameworkEventType,
@@ -35,6 +35,14 @@ export type UIState = {
filterMainThreadMonitoring: Atom<boolean>; filterMainThreadMonitoring: Atom<boolean>;
}; };
//enumerates the keys of input type and casts each to ReadOnlyAtom, this is so we only expose read only atoms to the UI
//and all writes come through UIActions
type TransformToReadOnly<T> = {
[P in keyof T]: T[P] extends Atom<infer U> ? _ReadOnlyAtom<U> : T[P];
};
export type ReadOnlyUIState = TransformToReadOnly<UIState>;
export type StreamFlowState = {paused: boolean}; export type StreamFlowState = {paused: boolean};
export type NestedNode = { export type NestedNode = {
@@ -62,7 +70,7 @@ export type OnSelectNode = (
) => void; ) => void;
export type UIActions = { export type UIActions = {
onHoverNode: (node?: Id) => void; onHoverNode: (...node: Id[]) => void;
onFocusNode: (focused?: Id) => void; onFocusNode: (focused?: Id) => void;
onContextMenuOpen: (open: boolean) => void; onContextMenuOpen: (open: boolean) => void;
onSelectNode: OnSelectNode; onSelectNode: OnSelectNode;
@@ -71,6 +79,12 @@ export type UIActions = {
setVisualiserWidth: (width: number) => void; setVisualiserWidth: (width: number) => void;
onSetFilterMainThreadMonitoring: (toggled: boolean) => void; onSetFilterMainThreadMonitoring: (toggled: boolean) => void;
onSetViewMode: (viewMode: ViewMode) => void; onSetViewMode: (viewMode: ViewMode) => void;
onSetFrameworkEventMonitored: (
eventType: FrameworkEventType,
monitored: boolean,
) => void;
onPlayPauseToggled: () => void;
onSearchTermUpdated: (searchTerm: string) => void;
}; };
export type SelectionSource = 'visualiser' | 'tree' | 'keyboard'; export type SelectionSource = 'visualiser' | 'tree' | 'keyboard';

View File

@@ -27,12 +27,6 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import {usePlugin, useValue, Layout} from 'flipper-plugin'; import {usePlugin, useValue, Layout} from 'flipper-plugin';
import {FrameworkEventType} from '../ClientTypes'; import {FrameworkEventType} from '../ClientTypes';
import {tracker} from '../utils/tracker';
import {debounce} from 'lodash';
const searchTermUpdated = debounce((searchTerm: string) => {
tracker.track('search-term-updated', {searchTerm});
}, 250);
export const Controls: React.FC = () => { export const Controls: React.FC = () => {
const instance = usePlugin(plugin); const instance = usePlugin(plugin);
@@ -49,23 +43,12 @@ export const Controls: React.FC = () => {
const [showFrameworkEventsModal, setShowFrameworkEventsModal] = const [showFrameworkEventsModal, setShowFrameworkEventsModal] =
useState(false); useState(false);
const onSetEventMonitored: (
eventType: FrameworkEventType,
monitored: boolean,
) => void = (eventType: FrameworkEventType, monitored: boolean) => {
tracker.track('framework-event-monitored', {eventType, monitored});
instance.uiState.frameworkEventMonitoring.update((draft) =>
draft.set(eventType, monitored),
);
};
return ( return (
<Layout.Horizontal pad="small" gap="small"> <Layout.Horizontal pad="small" gap="small">
<Input <Input
value={searchTerm} value={searchTerm}
onChange={(e) => { onChange={(e) => {
instance.uiState.searchTerm.set(e.target.value); instance.uiActions.onSearchTermUpdated(e.target.value);
searchTermUpdated(e.target.value);
}} }}
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
placeholder="Search" placeholder="Search"
@@ -73,11 +56,7 @@ export const Controls: React.FC = () => {
<Button <Button
type="default" type="default"
shape="circle" shape="circle"
onClick={() => { onClick={instance.uiActions.onPlayPauseToggled}
const isPaused = !instance.uiState.isPaused.get();
tracker.track('play-pause-toggled', {paused: isPaused});
instance.setPlayPause(isPaused);
}}
icon={ icon={
<Tooltip <Tooltip
title={isPaused ? 'Resume live updates' : 'Pause incoming updates'}> title={isPaused ? 'Resume live updates' : 'Pause incoming updates'}>
@@ -102,7 +81,7 @@ export const Controls: React.FC = () => {
instance.uiActions.onSetFilterMainThreadMonitoring instance.uiActions.onSetFilterMainThreadMonitoring
} }
frameworkEventTypes={[...frameworkEventMonitoring.entries()]} frameworkEventTypes={[...frameworkEventMonitoring.entries()]}
onSetEventMonitored={onSetEventMonitored} onSetEventMonitored={instance.uiActions.onSetFrameworkEventMonitored}
visible={showFrameworkEventsModal} visible={showFrameworkEventsModal}
onCancel={() => setShowFrameworkEventsModal(false)} onCancel={() => setShowFrameworkEventsModal(false)}
/> />

View File

@@ -14,7 +14,7 @@ import {
ClientNode, ClientNode,
FrameworkEvent, FrameworkEvent,
} from '../ClientTypes'; } from '../ClientTypes';
import {UIState} from '../DesktopTypes'; import {ReadOnlyUIState} from '../DesktopTypes';
import React, {useMemo} from 'react'; import React, {useMemo} from 'react';
import { import {
DataInspector, DataInspector,
@@ -26,7 +26,7 @@ import {
} from 'flipper-plugin'; } from 'flipper-plugin';
export function PerfStats(props: { export function PerfStats(props: {
uiState: UIState; uiState: ReadOnlyUIState;
nodes: Map<Id, ClientNode>; nodes: Map<Id, ClientNode>;
rootId?: Id; rootId?: Id;
events: DataSource<DynamicPerformanceStatsEvent, number>; events: DataSource<DynamicPerformanceStatsEvent, number>;

View File

@@ -253,7 +253,7 @@ export function Tree2({
}} }}
onMouseLeave={() => { onMouseLeave={() => {
if (isContextMenuOpen === false) { if (isContextMenuOpen === false) {
instance.uiState.hoveredNodes.set([]); instance.uiActions.onHoverNode();
} }
}}> }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => ( {rowVirtualizer.getVirtualItems().map((virtualRow) => (
@@ -756,7 +756,7 @@ function useKeyboardShortcuts(
selectedNode: Id | undefined, selectedNode: Id | undefined,
hoveredNodeId: Id | undefined, hoveredNodeId: Id | undefined,
onSelectNode: OnSelectNode, onSelectNode: OnSelectNode,
onHoverNode: (id?: Id) => void, onHoverNode: (...id: Id[]) => void,
onExpandNode: (id: Id) => void, onExpandNode: (id: Id) => void,
onCollapseNode: (id: Id) => void, onCollapseNode: (id: Id) => void,
isUsingKBToScrollUntill: React.MutableRefObject<number>, isUsingKBToScrollUntill: React.MutableRefObject<number>,
@@ -861,7 +861,7 @@ function moveSelectedNodeUpOrDown(
hoveredNode: Id | undefined, hoveredNode: Id | undefined,
selectedNode: Id | undefined, selectedNode: Id | undefined,
onSelectNode: OnSelectNode, onSelectNode: OnSelectNode,
onHoverNode: (id?: Id) => void, onHoverNode: (...id: Id[]) => void,
isUsingKBToScrollUntill: React.MutableRefObject<MillisSinceEpoch>, isUsingKBToScrollUntill: React.MutableRefObject<MillisSinceEpoch>,
) { ) {
const nodeToUse = selectedNode != null ? selectedNode : hoveredNode; const nodeToUse = selectedNode != null ? selectedNode : hoveredNode;
@@ -886,7 +886,7 @@ function moveSelectedNodeViaKeyBoard(
treeNodes: TreeNode[], treeNodes: TreeNode[],
rowVirtualizer: Virtualizer<HTMLDivElement, Element>, rowVirtualizer: Virtualizer<HTMLDivElement, Element>,
onSelectNode: OnSelectNode, onSelectNode: OnSelectNode,
onHoverNode: (id?: Id) => void, onHoverNode: (...id: Id[]) => void,
isUsingKBToScrollUntil: React.MutableRefObject<number>, isUsingKBToScrollUntil: React.MutableRefObject<number>,
) { ) {
if (newIdx >= 0 && newIdx < treeNodes.length) { if (newIdx >= 0 && newIdx < treeNodes.length) {

View File

@@ -80,7 +80,7 @@ export const Visualization2D: React.FC<
hitNodes.length > 0 && hitNodes.length > 0 &&
!isEqual(hitNodes, instance.uiState.hoveredNodes.get()) !isEqual(hitNodes, instance.uiState.hoveredNodes.get())
) { ) {
instance.uiState.hoveredNodes.set(hitNodes); instance.uiActions.onHoverNode(...hitNodes);
} }
}, MouseThrottle); }, MouseThrottle);
window.addEventListener('mousemove', mouseListener); window.addEventListener('mousemove', mouseListener);
@@ -95,6 +95,7 @@ export const Visualization2D: React.FC<
instance.uiState.isContextMenuOpen, instance.uiState.isContextMenuOpen,
width, width,
snapshotNode, snapshotNode,
instance.uiActions,
]); ]);
if (!focusState || !snapshotNode) { if (!focusState || !snapshotNode) {
@@ -110,7 +111,7 @@ export const Visualization2D: React.FC<
e.stopPropagation(); e.stopPropagation();
//the context menu triggers this callback but we dont want to remove hover effect //the context menu triggers this callback but we dont want to remove hover effect
if (!instance.uiState.isContextMenuOpen.get()) { if (!instance.uiState.isContextMenuOpen.get()) {
instance.uiState.hoveredNodes.set([]); instance.uiActions.onHoverNode();
} }
mouseInVisualiserRef.current = false; mouseInVisualiserRef.current = false;
@@ -338,7 +339,7 @@ const ContextMenu: React.FC<{nodes: Map<Id, ClientNode>}> = ({children}) => {
return ( return (
<Dropdown <Dropdown
onVisibleChange={(open) => { onVisibleChange={(open) => {
instance.uiState.isContextMenuOpen.set(open); instance.uiActions.onContextMenuOpen(open);
}} }}
trigger={['contextMenu']} trigger={['contextMenu']}
overlay={() => { overlay={() => {
@@ -358,7 +359,7 @@ const ContextMenu: React.FC<{nodes: Map<Id, ClientNode>}> = ({children}) => {
key="remove-focus" key="remove-focus"
text="Remove focus" text="Remove focus"
onClick={() => { onClick={() => {
instance.uiState.focusedNode.set(undefined); instance.uiActions.onFocusNode(undefined);
}} }}
/> />
)} )}

View File

@@ -39,7 +39,7 @@ export const UIDebuggerMenuItem: React.FC<{
disabled={onClick == null} disabled={onClick == null}
onClick={() => { onClick={() => {
onClick?.(); onClick?.();
instance.uiState.isContextMenuOpen.set(false); instance.uiActions.onContextMenuOpen(false);
}}> }}>
<Layout.Horizontal center gap="small"> <Layout.Horizontal center gap="small">
{icon} {icon}

View File

@@ -12,7 +12,6 @@ import {
createDataSource, createDataSource,
createState, createState,
PluginClient, PluginClient,
produce,
} from 'flipper-plugin'; } from 'flipper-plugin';
import { import {
Events, Events,
@@ -34,11 +33,13 @@ import {
StreamState, StreamState,
UIActions, UIActions,
ViewMode, ViewMode,
ReadOnlyUIState,
} from './DesktopTypes'; } from './DesktopTypes';
import {Draft} from 'immer'; import {Draft} from 'immer';
import {tracker} from './utils/tracker'; import {tracker} from './utils/tracker';
import {getStreamInterceptor} from './fb-stubs/StreamInterceptor'; import {getStreamInterceptor} from './fb-stubs/StreamInterceptor';
import {prefetchSourceFileLocation} from './components/fb-stubs/IDEContextMenu'; import {prefetchSourceFileLocation} from './components/fb-stubs/IDEContextMenu';
import {debounce} from 'lodash';
type LiveClientState = { type LiveClientState = {
snapshotInfo: SnapshotInfo | null; snapshotInfo: SnapshotInfo | null;
@@ -234,25 +235,9 @@ export function plugin(client: PluginClient<Events>) {
expandedNodes: createState<Set<Id>>(new Set()), expandedNodes: createState<Set<Id>>(new Set()),
}; };
const setPlayPause = (isPaused: boolean) => {
uiState.isPaused.set(isPaused);
if (!isPaused) {
//When going back to play mode then set the atoms to the live state to rerender the latest
//Also need to fixed expanded state for any change in active child state
uiState.expandedNodes.update((draft) => {
liveClientData.nodes.forEach((node) => {
collapseinActiveChildren(node, draft);
});
});
nodesAtom.set(liveClientData.nodes);
snapshot.set(liveClientData.snapshotInfo);
checkFocusedNodeStillActive(uiState, nodesAtom.get());
}
};
//this is the client data is what drives all of desktop UI //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 //it is always up-to-date with the client regardless of whether we are paused or not
let liveClientData: LiveClientState = { const mutableLiveClientData: LiveClientState = {
snapshotInfo: null, snapshotInfo: null,
nodes: new Map(), nodes: new Map(),
}; };
@@ -330,13 +315,10 @@ export function plugin(client: PluginClient<Events>) {
nodes: Map<Id, ClientNode>, nodes: Map<Id, ClientNode>,
snapshotInfo: SnapshotInfo | undefined, snapshotInfo: SnapshotInfo | undefined,
) { ) {
liveClientData = produce(liveClientData, (draft) => { if (snapshotInfo) {
if (snapshotInfo) { mutableLiveClientData.snapshotInfo = snapshotInfo;
draft.snapshotInfo = snapshotInfo; }
} mutableLiveClientData.nodes = nodes;
draft.nodes = nodes;
});
uiState.expandedNodes.update((draft) => { uiState.expandedNodes.update((draft) => {
for (const node of nodes.values()) { for (const node of nodes.values()) {
@@ -354,8 +336,8 @@ export function plugin(client: PluginClient<Events>) {
}); });
if (!uiState.isPaused.get()) { if (!uiState.isPaused.get()) {
nodesAtom.set(liveClientData.nodes); nodesAtom.set(mutableLiveClientData.nodes);
snapshot.set(liveClientData.snapshotInfo); snapshot.set(mutableLiveClientData.snapshotInfo);
checkFocusedNodeStillActive(uiState, nodesAtom.get()); checkFocusedNodeStillActive(uiState, nodesAtom.get());
} }
@@ -378,14 +360,13 @@ export function plugin(client: PluginClient<Events>) {
return { return {
rootId, rootId,
uiState, uiState: uiState as ReadOnlyUIState,
uiActions: uiActions(uiState, nodesAtom), uiActions: uiActions(uiState, nodesAtom, snapshot, mutableLiveClientData),
nodes: nodesAtom, nodes: nodesAtom,
frameworkEvents, frameworkEvents,
snapshot, snapshot,
metadata, metadata,
perfEvents, perfEvents,
setPlayPause,
os, os,
}; };
} }
@@ -393,6 +374,8 @@ export function plugin(client: PluginClient<Events>) {
function uiActions( function uiActions(
uiState: UIState, uiState: UIState,
nodes: Atom<Map<Id, ClientNode>>, nodes: Atom<Map<Id, ClientNode>>,
snapshot: Atom<SnapshotInfo | null>,
liveClientData: LiveClientState,
): UIActions { ): UIActions {
const onExpandNode = (node: Id) => { const onExpandNode = (node: Id) => {
uiState.expandedNodes.update((draft) => { uiState.expandedNodes.update((draft) => {
@@ -434,9 +417,9 @@ function uiActions(
}); });
}; };
const onHoverNode = (node?: Id) => { const onHoverNode = (...node: Id[]) => {
if (node != null) { if (node != null) {
uiState.hoveredNodes.set([node]); uiState.hoveredNodes.set(node);
} else { } else {
uiState.hoveredNodes.set([]); uiState.hoveredNodes.set([]);
} }
@@ -473,6 +456,43 @@ function uiActions(
uiState.viewMode.set(viewMode); uiState.viewMode.set(viewMode);
}; };
const onSetFrameworkEventMonitored = (
eventType: FrameworkEventType,
monitored: boolean,
) => {
tracker.track('framework-event-monitored', {eventType, monitored});
uiState.frameworkEventMonitoring.update((draft) =>
draft.set(eventType, monitored),
);
};
const onPlayPauseToggled = () => {
const isPaused = !uiState.isPaused.get();
tracker.track('play-pause-toggled', {paused: isPaused});
uiState.isPaused.set(isPaused);
if (!isPaused) {
//When going back to play mode then set the atoms to the live state to rerender the latest
//Also need to fixed expanded state for any change in active child state
uiState.expandedNodes.update((draft) => {
liveClientData.nodes.forEach((node) => {
collapseinActiveChildren(node, draft);
});
});
nodes.set(liveClientData.nodes);
snapshot.set(liveClientData.snapshotInfo);
checkFocusedNodeStillActive(uiState, nodes.get());
}
};
const searchTermUpdatedDebounced = debounce((searchTerm: string) => {
tracker.track('search-term-updated', {searchTerm});
}, 250);
const onSearchTermUpdated = (searchTerm: string) => {
uiState.searchTerm.set(searchTerm);
searchTermUpdatedDebounced(searchTerm);
};
return { return {
onExpandNode, onExpandNode,
onCollapseNode, onCollapseNode,
@@ -483,6 +503,9 @@ function uiActions(
setVisualiserWidth, setVisualiserWidth,
onSetFilterMainThreadMonitoring, onSetFilterMainThreadMonitoring,
onSetViewMode, onSetViewMode,
onSetFrameworkEventMonitored,
onPlayPauseToggled,
onSearchTermUpdated,
}; };
} }