Improve typing and sidebar performance in large workspaces

This commit is contained in:
Gregory Schier
2026-07-25 07:14:46 -07:00
parent 3f098f95fe
commit a2a6cb17ca
12 changed files with 350 additions and 78 deletions
+22 -3
View File
@@ -1,13 +1,32 @@
// oxlint-disable-next-line no-explicit-any
export function debounce(fn: (...args: any[]) => void, delay = 500) {
let timer: ReturnType<typeof setTimeout>;
let timer: ReturnType<typeof setTimeout> | null = null;
// oxlint-disable-next-line no-explicit-any
let lastArgs: any[] | null = null;
// oxlint-disable-next-line no-explicit-any
const result = (...args: any[]) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), delay);
lastArgs = args;
if (timer != null) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
}, delay);
};
result.cancel = () => {
if (timer != null) clearTimeout(timer);
timer = null;
lastArgs = null;
};
// Invoke a pending call immediately instead of waiting out the delay
result.flush = () => {
if (timer == null) return;
clearTimeout(timer);
timer = null;
const argsToUse = lastArgs ?? [];
lastArgs = null;
fn(...argsToUse);
};
return result;
}
+53 -14
View File
@@ -1,4 +1,5 @@
import type { DragEndEvent, DragMoveEvent, DragStartEvent } from "@dnd-kit/core";
import type { Virtualizer } from "@tanstack/react-virtual";
import {
DndContext,
MeasuringStrategy,
@@ -23,7 +24,7 @@ import {
} from "react";
import { useKey, useKeyPressEvent } from "react-use";
import { computeSideForDragMove } from "../../lib/dnd";
import { useStore } from "jotai";
import { useAtomValue, useStore } from "jotai";
import { draggingIdsFamily, focusIdsFamily, hoveredParentFamily, selectedIdsFamily } from "./atoms";
import { type CollapsedAtom, CollapsedAtomContext } from "./context";
import type { ContextMenuRenderer, JotaiStore, SelectableTreeNode, TreeNode } from "./common";
@@ -87,7 +88,27 @@ function TreeInner<T extends { id: string }>(
) {
const store = useStore();
const treeRef = useRef<HTMLDivElement>(null);
const virtualizerRef = useRef<Virtualizer<HTMLElement, Element> | null>(null);
const getScrollElement = useCallback(() => treeRef.current, []);
const handleVirtualizerReady = useCallback((v: Virtualizer<HTMLElement, Element>) => {
virtualizerRef.current = v;
}, []);
const selectableItems = useSelectableItems(root);
// Only render nodes that are actually visible (not filtered out, and not
// inside a collapsed folder). Mounting every node regardless of visibility
// makes large workspaces unusable: thousands of hidden TreeItems each run
// their dnd/context hooks on every tree commit just to return null.
const collapsedMap = useAtomValue(collapsedAtom);
const visibleItems = useMemo(() => {
return selectableItems.filter((i) => {
if (i.node.hidden) return false;
for (let p = i.node.parent; p != null; p = p.parent) {
if (collapsedMap[p.item.id]) return false;
}
return true;
});
}, [selectableItems, collapsedMap]);
const [showContextMenu, setShowContextMenu] = useState<{
items: unknown[];
x: number;
@@ -125,16 +146,28 @@ function TreeInner<T extends { id: string }>(
}, []);
const tryFocus = useCallback(() => {
const $el = treeRef.current?.querySelector<HTMLButtonElement>(
'.tree-item button[tabindex="0"]',
);
if ($el == null) {
const find = () =>
treeRef.current?.querySelector<HTMLButtonElement>('.tree-item button[tabindex="0"]');
const $el = find();
if ($el != null) {
// preventScroll so scrolling stays single-sourced (focus() implicitly
// scrolls, which fights the virtualizer's scrollToIndex)
$el.focus({ preventScroll: true });
$el.scrollIntoView({ block: "nearest" });
return true;
}
// The focused row may be virtualized out of range. Scroll it into range,
// then focus it once it has mounted.
const lastFocusedId = store.get(focusIdsFamily(treeId)).lastId;
const index = visibleItems.findIndex((i) => i.node.item.id === lastFocusedId);
if (index < 0) {
return false;
}
$el.focus();
$el.scrollIntoView({ block: "nearest" });
virtualizerRef.current?.scrollToIndex(index, { align: "auto" });
requestAnimationFrame(() => find()?.focus({ preventScroll: true }));
return true;
}, []);
}, [store, treeId, visibleItems]);
const ensureTabbableItem = useCallback(() => {
const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId;
@@ -448,8 +481,8 @@ function TreeInner<T extends { id: string }>(
store.set(hoveredParentFamily(treeId), {
parentId: root.item.id,
parentDepth: root.depth,
index: selectableItems.length,
childIndex: selectableItems.length,
index: visibleItems.length,
childIndex: visibleItems.length,
});
return;
}
@@ -477,8 +510,8 @@ function TreeInner<T extends { id: string }>(
const item = node.item;
let hoveredParent = node.parent;
const dragIndex = selectableItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = selectableItems[dragIndex]?.node ?? null;
const dragIndex = visibleItems.findIndex((n) => n.node.item.id === item.id) ?? -1;
const hovered = visibleItems[dragIndex]?.node ?? null;
const hoveredIndex = dragIndex + (side === "before" ? 0 : 1);
let hoveredChildIndex = overSelectableItem.index + (side === "before" ? 0 : 1);
@@ -509,7 +542,7 @@ function TreeInner<T extends { id: string }>(
});
}
},
[root.depth, root.item.id, selectableItems, treeId],
[root.depth, root.item.id, selectableItems, treeId, visibleItems],
);
const handleDragStart = useCallback(
@@ -680,12 +713,18 @@ function TreeInner<T extends { id: string }>(
"[&_.tree-item.selected+.drop-marker+.tree-item.selected]:rounded-t-none",
"[&_.tree-item.selected:has(+.tree-item.selected)]:rounded-b-none",
"[&_.tree-item.selected:has(+.drop-marker+.tree-item.selected)]:rounded-b-none",
// Virtualized rows are wrapped in .tree-row divs, so the sibling
// relationships above need wrapper-aware equivalents
"[&_.tree-row:has(.tree-item.selected)+.tree-row_.tree-item.selected]:rounded-t-none",
"[&_.tree-row:has(.tree-item.selected):has(+.tree-row_.tree-item.selected)_.tree-item.selected]:rounded-b-none",
)}
>
<TreeItemList
addTreeItemRef={handleAddTreeItemRef}
nodes={selectableItems}
nodes={visibleItems}
treeId={treeId}
getScrollElement={getScrollElement}
onVirtualizerReady={handleVirtualizerReady}
{...treeItemListProps}
/>
</div>
@@ -1,5 +1,7 @@
import type { Virtualizer } from "@tanstack/react-virtual";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { CSSProperties } from "react";
import { Fragment } from "react";
import { Fragment, useLayoutEffect, useRef, useState } from "react";
import type { SelectableTreeNode } from "./common";
import type { TreeProps } from "./Tree";
import { TreeDropMarker } from "./TreeDropMarker";
@@ -22,9 +24,22 @@ export type TreeItemListProps<T extends { id: string }> = Pick<
className?: string;
forceDepth?: number;
addTreeItemRef?: (item: T, n: TreeItemHandle | null) => void;
/**
* Enable virtualization by providing the scroll container. Rows are then
* windowed with @tanstack/react-virtual and only visible rows mount.
*/
getScrollElement?: () => HTMLElement | null;
onVirtualizerReady?: (v: Virtualizer<HTMLElement, Element>) => void;
};
export function TreeItemList<T extends { id: string }>({
export function TreeItemList<T extends { id: string }>(props: TreeItemListProps<T>) {
if (props.getScrollElement != null) {
return <VirtualTreeItemList {...props} getScrollElement={props.getScrollElement} />;
}
return <StaticTreeItemList {...props} />;
}
function StaticTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
@@ -32,6 +47,8 @@ export function TreeItemList<T extends { id: string }>({
treeId,
forceDepth,
addTreeItemRef,
getScrollElement: _getScrollElement,
onVirtualizerReady: _onVirtualizerReady,
...props
}: TreeItemListProps<T>) {
return (
@@ -53,3 +70,89 @@ export function TreeItemList<T extends { id: string }>({
</ul>
);
}
// Rows are --height-sm (2rem). Derive the pixel estimate from the actual root
// font size so scroll math stays accurate under interface scaling.
function estimateRowHeightPx() {
const rem = Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || 16;
return 2 * rem;
}
function VirtualTreeItemList<T extends { id: string }>({
className,
getItemKey,
nodes,
style,
treeId,
forceDepth,
addTreeItemRef,
getScrollElement,
onVirtualizerReady,
...props
}: TreeItemListProps<T> & { getScrollElement: () => HTMLElement | null }) {
const listRef = useRef<HTMLUListElement>(null);
// Offset of the list within the scroll container (eg. container padding),
// so windowing and scrollToIndex targets aren't shifted by it
const [scrollMargin, setScrollMargin] = useState(0);
useLayoutEffect(() => {
const list = listRef.current;
const scroller = getScrollElement();
if (list == null || scroller == null) return;
const offset =
list.getBoundingClientRect().top - scroller.getBoundingClientRect().top + scroller.scrollTop;
setScrollMargin(offset);
}, [getScrollElement]);
const virtualizer = useVirtualizer({
count: nodes.length,
getScrollElement,
estimateSize: estimateRowHeightPx,
overscan: 10,
scrollMargin,
});
useLayoutEffect(() => {
onVirtualizerReady?.(virtualizer);
}, [virtualizer, onVirtualizerReady]);
return (
<ul
ref={listRef}
style={{ ...style, height: `${virtualizer.getTotalSize()}px`, position: "relative" }}
className={className}
>
<TreeDropMarker node={null} treeId={treeId} index={0} />
{virtualizer.getVirtualItems().map((virtualItem) => {
const child = nodes[virtualItem.index];
if (child == null) return null;
return (
<div
// Key by item so window shifts don't remount rows unnecessarily
key={getItemKey(child.node.item)}
ref={virtualizer.measureElement}
data-index={virtualItem.index}
className="tree-row"
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualItem.start - scrollMargin}px)`,
}}
>
<TreeItem
treeId={treeId}
setRef={addTreeItemRef}
node={child.node}
getItemKey={getItemKey}
depth={forceDepth == null ? child.depth : forceDepth}
{...props}
/>
<TreeDropMarker node={child.node} treeId={treeId} index={virtualItem.index + 1} />
</div>
);
})}
</ul>
);
}