Skip to content

Commit 79c5c39

Browse files
MariefayTrigger.dev RepoOps
authored andcommitted
perf(webapp): keep trace tree scrolling smooth on large runs
Scrolling the run trace tree no longer slows down as the trace grows. Mono-RevId: 97d00a66801f05663abb1b034e9fde52c9dd767e
1 parent 7152f89 commit 79c5c39

2 files changed

Lines changed: 137 additions & 5 deletions

File tree

‎apps/webapp/app/components/primitives/TreeView/TreeView.tsx‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { VirtualItem, Virtualizer } from "@tanstack/react-virtual";
22
import { useVirtualizer } from "@tanstack/react-virtual";
33
import { motion } from "framer-motion";
44
import type { MutableRefObject, RefObject } from "react";
5-
import { useEffect, useMemo, useReducer, useRef } from "react";
5+
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
66
import { cn } from "~/utils/cn";
77
import type { NodeState, NodesState } from "./reducer";
88
import { reducer } from "./reducer";
@@ -213,9 +213,11 @@ export function useTree<TData, TFilterValue>({
213213

214214
const [state, dispatch] = useReducer(
215215
reducer,
216-
concreteStateFromInput({ tree, selectedId, collapsedIds, filter })
216+
{ tree, selectedId, collapsedIds, filter },
217+
concreteStateFromInput
217218
);
218-
const currentSelectedId = selectedIdFromState(state.nodes);
219+
// Scans every node, so only redo it when node state changes, not on scroll renders.
220+
const currentSelectedId = useMemo(() => selectedIdFromState(state.nodes), [state.nodes]);
219221

220222
// id -> index lookup so getNodeProps resolves in O(1) instead of scanning
221223
// the whole tree per rendered row.
@@ -278,13 +280,22 @@ export function useTree<TData, TFilterValue>({
278280
dispatch({ type: "UPDATE_FILTER", payload: { filter: latestFilterRef.current } });
279281
}, [serializedFilterValue]);
280282

283+
// A new getItemKey makes the virtualizer re-measure every row, so only
284+
// change it when the visible rows change.
285+
const getItemKey = useCallback(
286+
(index: number) => state.visibleNodeIds[index],
287+
[state.visibleNodeIds]
288+
);
289+
281290
// oxlint-disable-next-line react/incompatible-library -- TanStack Virtual is not compatible with compiler memoization.
282291
const virtualizer = useVirtualizer({
283292
count: state.visibleNodeIds.length,
284-
getItemKey: (index) => state.visibleNodeIds[index],
293+
getItemKey,
285294
getScrollElement: () => parentRef.current,
286295
estimateSize: (index: number) => {
287-
const treeItem = tree[index];
296+
const id = state.visibleNodeIds[index];
297+
const treeIndex = id === undefined ? undefined : treeIndexById.get(id);
298+
const treeItem = treeIndex === undefined ? undefined : tree[treeIndex];
288299
if (!treeItem) return 0;
289300
return estimatedRowHeight({
290301
node: treeItem,
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
// @vitest-environment jsdom
2+
import { createElement } from "react";
3+
import { createRoot, type Root } from "react-dom/client";
4+
import { act } from "react-dom/test-utils";
5+
import { afterEach, describe, expect, it } from "vitest";
6+
import {
7+
flattenTree,
8+
useTree,
9+
type FlatTreeItem,
10+
type UseTreeStateOutput,
11+
} from "~/components/primitives/TreeView/TreeView";
12+
13+
type Data = { title: string };
14+
15+
const tree = flattenTree<Data>({
16+
id: "root",
17+
data: { title: "root" },
18+
children: [
19+
{
20+
id: "a",
21+
data: { title: "a" },
22+
children: [
23+
{ id: "a1", data: { title: "a1" } },
24+
{ id: "a2", data: { title: "a2" } },
25+
],
26+
},
27+
{ id: "b", data: { title: "b" } },
28+
],
29+
});
30+
31+
let container: HTMLDivElement | undefined;
32+
let root: Root | undefined;
33+
34+
afterEach(() => {
35+
if (root) act(() => root!.unmount());
36+
container?.remove();
37+
container = undefined;
38+
root = undefined;
39+
});
40+
41+
function render() {
42+
let output: UseTreeStateOutput | undefined;
43+
const estimates = new Map<number, string>();
44+
const parentRef = { current: null };
45+
const estimatedRowHeight = ({ node, index }: { node: FlatTreeItem<Data>; index: number }) => {
46+
estimates.set(index, node.id);
47+
return 32;
48+
};
49+
let filterCalls = 0;
50+
const filter = {
51+
value: "match-all",
52+
fn: () => {
53+
filterCalls++;
54+
return true;
55+
},
56+
};
57+
58+
function Harness({ tick: _tick }: { tick: number }) {
59+
output = useTree({ tree, parentRef, estimatedRowHeight, filter });
60+
return null;
61+
}
62+
63+
let tick = 0;
64+
container = document.createElement("div");
65+
document.body.appendChild(container);
66+
root = createRoot(container);
67+
act(() => root!.render(createElement(Harness, { tick })));
68+
69+
return {
70+
output: () => output!,
71+
estimates,
72+
filterCalls: () => filterCalls,
73+
rerender: () => {
74+
tick++;
75+
act(() => root!.render(createElement(Harness, { tick })));
76+
},
77+
};
78+
}
79+
80+
describe("useTree virtualizer options", () => {
81+
it("keeps getItemKey identity across re-renders with unchanged rows", () => {
82+
const harness = render();
83+
const before = harness.output().virtualizer.options.getItemKey;
84+
85+
harness.rerender();
86+
87+
expect(harness.output().virtualizer.options.getItemKey).toBe(before);
88+
});
89+
90+
it("changes getItemKey when the visible rows change", () => {
91+
const harness = render();
92+
const before = harness.output().virtualizer.options.getItemKey;
93+
94+
act(() => harness.output().collapseNode("a"));
95+
96+
expect(harness.output().virtualizer.options.getItemKey).not.toBe(before);
97+
});
98+
99+
it("doesn't rebuild the initial tree state on re-render", () => {
100+
const harness = render();
101+
const before = harness.filterCalls();
102+
103+
harness.rerender();
104+
105+
expect(harness.filterCalls()).toBe(before);
106+
});
107+
108+
it("estimates each row from the node shown in that row", () => {
109+
const harness = render();
110+
act(() => harness.output().collapseNode("a"));
111+
harness.estimates.clear();
112+
113+
harness.output().virtualizer.getTotalSize();
114+
115+
expect([...harness.estimates.entries()].sort(([a], [b]) => a - b)).toEqual([
116+
[0, "root"],
117+
[1, "a"],
118+
[2, "b"],
119+
]);
120+
});
121+
});

0 commit comments

Comments
 (0)