Skip to content

Commit 7223b24

Browse files
committed
WIP improve perf
1 parent 2eee5b4 commit 7223b24

File tree

2 files changed

+144
-97
lines changed

2 files changed

+144
-97
lines changed

packages/raga-app/src/client/components/common/tree.tsx

+143-97
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,109 @@ export interface ControlledTreeProps<T> {
4646
onSelect?: (nodes: TreeNode<T>[]) => void;
4747
}
4848

49+
interface MantineTreeController {
50+
isNodeChecked: (value: string) => boolean;
51+
isNodeIndeterminate: (value: string) => boolean;
52+
uncheckNode: (value: string) => void;
53+
checkNode: (value: string) => void;
54+
collapse: (value: string) => void;
55+
expand: (value: string) => void;
56+
deselect: (value: string) => void;
57+
select: (value: string) => void;
58+
}
59+
60+
interface TreeNodeRendererProps {
61+
node: TreeNodeData;
62+
expanded: boolean;
63+
selected: boolean;
64+
hasChildren: boolean;
65+
elementProps: {
66+
className?: string;
67+
[key: string]: unknown;
68+
};
69+
tree: MantineTreeController;
70+
selectionMode: TreeSelectionMode;
71+
}
72+
4973
// COMPONENTS
5074
// -------------------------------------------------------------------------------------------------
5175

76+
const TreeNodeRenderer = memo(function TreeNodeRenderer({
77+
node,
78+
expanded,
79+
selected,
80+
hasChildren,
81+
elementProps,
82+
tree,
83+
selectionMode,
84+
}: TreeNodeRendererProps) {
85+
const checked = tree.isNodeChecked(node.value);
86+
const indeterminate = tree.isNodeIndeterminate(node.value);
87+
88+
const handleCheckboxClick = useCallback(() => {
89+
if (checked) {
90+
tree.uncheckNode(node.value);
91+
} else {
92+
tree.checkNode(node.value);
93+
}
94+
}, [checked, node.value, tree]);
95+
96+
const handleExpandClick = useCallback(() => {
97+
if (expanded) {
98+
tree.collapse(node.value);
99+
} else {
100+
tree.expand(node.value);
101+
}
102+
}, [expanded, node.value, tree]);
103+
104+
const handleNodeClick = useCallback(() => {
105+
if (selectionMode !== "single") {
106+
return;
107+
}
108+
109+
if (selected) {
110+
tree.deselect(node.value);
111+
} else {
112+
tree.select(node.value);
113+
}
114+
}, [node.value, selected, selectionMode, tree]);
115+
116+
return (
117+
<div
118+
{...elementProps}
119+
className={classNames(styles.node, elementProps.className, {
120+
[styles.selectOnClick]: selectionMode === "single",
121+
[styles.selectedPath]: selected,
122+
})}
123+
>
124+
{selectionMode === "multiple" && (
125+
<Checkbox.Indicator
126+
className={classNames(styles.checkbox, { [styles.filled]: checked || indeterminate })}
127+
checked={checked}
128+
indeterminate={indeterminate}
129+
onClick={handleCheckboxClick}
130+
/>
131+
)}
132+
133+
{hasChildren && (
134+
<ActionIcon
135+
size="compact-sm"
136+
ml={2}
137+
color="gray"
138+
variant="subtle"
139+
onClick={handleExpandClick}
140+
>
141+
{expanded ? <IoChevronDown /> : <IoChevronForward />}
142+
</ActionIcon>
143+
)}
144+
145+
<div className={styles.labelContainer} onClick={handleNodeClick}>
146+
<span>{node.label}</span>
147+
</div>
148+
</div>
149+
);
150+
});
151+
52152
/**
53153
* Wrapper around Mantine's Tree component with controlled state management.
54154
*
@@ -97,109 +197,52 @@ function ControlledTree<T extends object>({
97197
return path;
98198
}, [mantineNodes, nodes, selectedNodes, selectedMantineNodes]);
99199

100-
const tree = useTree({
101-
initialExpandedState: getTreeExpandedState(mantineNodes, pathToFirstSelectedNode),
102-
initialSelectedState: selectedMantineNodes.map((node) => node.value),
103-
});
200+
// Initialize tree with memoized initial state
201+
const initialTreeState = useMemo(
202+
() => ({
203+
initialExpandedState: getTreeExpandedState(mantineNodes, pathToFirstSelectedNode),
204+
initialSelectedState: selectedMantineNodes.map((node) => node.value),
205+
}),
206+
[mantineNodes, pathToFirstSelectedNode, selectedMantineNodes],
207+
);
208+
209+
const tree = useTree(initialTreeState);
104210
const { select, clearSelected, checkedState } = tree;
105211

106212
const allNodesChecked = checkedState.length === numLeafNodes;
107213
const someNodesChecked = checkedState.length > 0;
108-
useEffect(() => {
109-
if (selectionMode === "multiple") {
110-
onSelect?.(
111-
filterUndefined(
112-
checkedState.map((nodeId) => findNodeById(nodes, mantineNodeValueToId(nodeId))),
113-
),
114-
);
115-
}
116-
}, [checkedState, nodes, onSelect, selectionMode]);
117214

118-
const renderTreeNode = useCallback(
119-
({ node, expanded, selected, hasChildren, elementProps, tree }: RenderTreeNodePayload) => {
120-
const checked = tree.isNodeChecked(node.value);
121-
const indeterminate = tree.isNodeIndeterminate(node.value);
122-
123-
return (
124-
<div
125-
key={node.value}
126-
{...elementProps}
127-
className={classNames(styles.node, elementProps.className, {
128-
[styles.selectOnClick]: selectionMode === "single",
129-
[styles.selectedPath]: selected,
130-
})}
131-
>
132-
{selectionMode === "multiple" && (
133-
<Checkbox.Indicator
134-
className={classNames(styles.checkbox, { [styles.filled]: checked || indeterminate })}
135-
checked={checked}
136-
indeterminate={indeterminate}
137-
onClick={() => {
138-
if (checked) {
139-
tree.uncheckNode(node.value);
140-
} else {
141-
tree.checkNode(node.value);
142-
}
143-
}}
144-
/>
145-
)}
146-
147-
{hasChildren && (
148-
<ActionIcon
149-
size="compact-sm"
150-
ml={2}
151-
color="gray"
152-
variant="subtle"
153-
onClick={() => {
154-
if (expanded) {
155-
tree.collapse(node.value);
156-
} else {
157-
tree.expand(node.value);
158-
}
159-
}}
160-
>
161-
{expanded ? <IoChevronDown /> : <IoChevronForward />}
162-
</ActionIcon>
163-
)}
164-
165-
<div
166-
className={styles.labelContainer}
167-
onClick={() => {
168-
if (selectionMode !== "single") {
169-
return;
170-
}
171-
172-
if (selected) {
173-
tree.deselect(node.value);
174-
} else {
175-
tree.select(node.value);
176-
const selectedNode = findNodeById(nodes, mantineNodeValueToId(node.value));
177-
if (selectedNode) {
178-
onSelect?.([selectedNode]);
179-
}
180-
}
181-
}}
182-
>
183-
<span>{node.label}</span>
184-
</div>
185-
</div>
186-
);
215+
const handleSelectionChange = useCallback(
216+
(nodes: TreeNode<T>[]) => {
217+
if (selectionMode === "multiple") {
218+
onSelect?.(
219+
filterUndefined(
220+
checkedState.map((nodeId) => findNodeById(nodes, mantineNodeValueToId(nodeId))),
221+
),
222+
);
223+
}
187224
},
188-
[nodes, onSelect, selectionMode],
225+
[checkedState, onSelect, selectionMode],
189226
);
190227

228+
useEffect(() => {
229+
handleSelectionChange(nodes);
230+
}, [handleSelectionChange, nodes]);
231+
191232
// Update tree state controlled selection changes
192233
useEffect(() => {
193234
if (selectionMode === "single") {
194235
clearSelected();
195236
const selectedNode = selectedMantineNodes[0];
196-
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
197-
if (selectedNode) {
198-
select(selectedNode.value);
199-
}
237+
select(selectedNode.value);
200238
}
201239
}, [selectedMantineNodes, select, clearSelected, selectionMode]);
202240

241+
const renderTreeNode = useCallback(
242+
(props: RenderTreeNodePayload) => <TreeNodeRenderer {...props} selectionMode={selectionMode} />,
243+
[selectionMode],
244+
);
245+
203246
return (
204247
<>
205248
{selectionMode === "multiple" && (
@@ -237,22 +280,25 @@ export default memo(ControlledTree) as typeof ControlledTree;
237280
// UTILITIES
238281
// -------------------------------------------------------------------------------------------------
239282

240-
function mapNodesToMantineFormat<T>(
283+
const nodeValuePathsMap = new WeakMap<TreeNode<unknown>[], Map<string, string>>();
284+
285+
function mapNodesToMantineFormat<T extends object>(
241286
nodes: TreeNode<T>[],
242-
/** IDs of the selected node(s). */
243287
selectedNodeIds: string[],
244-
/**
245-
* Accumulated map of node id to node value path.
246-
* Example:
247-
* - node 1: "1"
248-
* - node 2: "1/2"
249-
* - node 3: "1/2/3"
250-
*/
251-
nodeValuePaths: Map<string, string> = new Map<string, string>(),
288+
nodeValuePaths: Map<string, string> = nodeValuePathsMap.get(nodes) ?? new Map<string, string>(),
252289
): { mantineNodes: TreeNodeData[]; numLeafNodes: number } {
290+
// Cache the nodeValuePaths map for this nodes array
291+
if (!nodeValuePathsMap.has(nodes)) {
292+
nodeValuePathsMap.set(nodes, nodeValuePaths);
293+
}
294+
253295
let numLeafNodes = 0;
254296

255297
function getNodeValue(node: TreeNode<T>) {
298+
// Check cache first
299+
const cachedValue = nodeValuePaths.get(node.id);
300+
if (cachedValue) return cachedValue;
301+
256302
const parentNodeValue = node.parentId ? nodeValuePaths.get(node.parentId) : undefined;
257303
const value = parentNodeValue ? `${parentNodeValue}/${node.id}` : node.id;
258304
nodeValuePaths.set(node.id, value);

packages/raga-app/src/client/components/playlistTable/playlistTable.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ function PlaylistTable({
4646
log.debug(`[client] selected playlist ${firstNode.id}: '${firstNode.data.Name}'`);
4747
onSelect?.([firstNode.id]);
4848
} else if (selectionMode === "multiple") {
49+
log.debug(`[client] selected ${nodes.length.toString()} playlists`);
4950
onSelect?.(nodes.map((n) => n.id));
5051
}
5152
},

0 commit comments

Comments
 (0)