diff --git a/apps/yaak-client/components/GrpcRequestPane.tsx b/apps/yaak-client/components/GrpcRequestPane.tsx index 9884e263..b280d6ed 100644 --- a/apps/yaak-client/components/GrpcRequestPane.tsx +++ b/apps/yaak-client/components/GrpcRequestPane.tsx @@ -1,4 +1,4 @@ -import { type GrpcRequest, type HttpRequestHeader, patchModel } from "@yaakapp-internal/models"; +import { type GrpcRequest, type HttpRequestHeader, patchModel, patchModelDebounced} from "@yaakapp-internal/models"; import { HStack, Icon, useContainerSize, VStack } from "@yaakapp-internal/ui"; import classNames from "classnames"; import type { CSSProperties } from "react"; @@ -75,11 +75,13 @@ export function GrpcRequestPane({ const { width: paneWidth } = useContainerSize(urlContainerEl); const handleChangeUrl = useCallback( - (url: string) => patchModel(activeRequest, { url }), + (url: string) => patchModelDebounced(activeRequest, { url }), [activeRequest], ); const handleChangeMessage = useCallback( + // Not debounced: handleSend reads message from the store, so a pending + // debounced patch would send stale text (message: string) => patchModel(activeRequest, { message }), [activeRequest], ); @@ -146,12 +148,12 @@ export function GrpcRequestPane({ ); const handleMetadataChange = useCallback( - (metadata: HttpRequestHeader[]) => patchModel(activeRequest, { metadata }), + (metadata: HttpRequestHeader[]) => patchModelDebounced(activeRequest, { metadata }), [activeRequest], ); const handleDescriptionChange = useCallback( - (description: string) => patchModel(activeRequest, { description }), + (description: string) => patchModelDebounced(activeRequest, { description }), [activeRequest], ); @@ -299,7 +301,7 @@ export function GrpcRequestPane({ className="font-sans text-xl! px-0!" containerClassName="border-0" placeholder={resolvedModelName(activeRequest)} - onChange={(name) => patchModel(activeRequest, { name })} + onChange={(name) => patchModelDebounced(activeRequest, { name })} /> { - const activeRequestId = get(activeRequestIdAtom); - const requests = get(allRequestsAtom); - return requests - .filter((r) => r.id !== activeRequestId) - .map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); -}); - -const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom); +// Derived from the identity-stable URL list so this only recomputes when a URL +// actually changes. The active request's own URL is included, but exact matches +// are filtered out at completion time by genericCompletion. +const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] => + get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })), +); export function HttpRequestPane({ style, fullHeight, className, activeRequest }: Props) { const activeRequestId = activeRequest.id; @@ -274,16 +269,16 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: const { mutate: importCurl } = useImportCurl(); const handleBodyChange = useCallback( - (body: HttpRequest["body"]) => patchModel(activeRequest, { body }), + (body: HttpRequest["body"]) => patchModelDebounced(activeRequest, { body }), [activeRequest], ); const handleBodyTextChange = useCallback( - (text: string) => patchModel(activeRequest, { body: { ...activeRequest.body, text } }), + (text: string) => patchModelDebounced(activeRequest, { body: { ...activeRequest.body, text } }), [activeRequest], ); - const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); + const autocompleteUrls = useAtomValue(requestUrlOptionsAtom); const autocomplete: GenericCompletionConfig = useMemo( () => getUrlCompletionConfig(autocompleteUrls), @@ -323,7 +318,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const handleUrlChange = useCallback( - (url: string) => patchModel(activeRequest, { url }), + (url: string) => patchModelDebounced(activeRequest, { url }), [activeRequest], ); @@ -369,7 +364,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: forceUpdateKey={`${forceUpdateHeaderEditorKey}::${forceUpdateKey}`} headers={activeRequest.headers} stateKey={`headers.${activeRequest.id}`} - onChange={(headers) => patchModel(activeRequest, { headers })} + onChange={(headers) => patchModelDebounced(activeRequest, { headers })} /> @@ -377,7 +372,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: stateKey={`params.${activeRequest.id}`} forceUpdateKey={forceUpdateKey + urlParametersKey} pairs={urlParameterPairs} - onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} + onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })} /> @@ -429,7 +424,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: requestId={activeRequest.id} contentType={contentType} body={activeRequest.body} - onChange={(body) => patchModel(activeRequest, { body })} + onChange={(body) => patchModelDebounced(activeRequest, { body })} onChangeContentType={handleContentTypeChange} /> ) : typeof activeRequest.bodyType === "string" ? ( diff --git a/apps/yaak-client/components/Sidebar.tsx b/apps/yaak-client/components/Sidebar.tsx index eb8c7803..9e897418 100644 --- a/apps/yaak-client/components/Sidebar.tsx +++ b/apps/yaak-client/components/Sidebar.tsx @@ -112,6 +112,7 @@ function Sidebar({ className }: { className?: string }) { const treeId = `tree.${activeWorkspaceId ?? "unknown"}`; const filterText = useAtomValue(sidebarFilterAtom); const [tree, allFields, emptyFilterSuggestions] = useAtomValue(sidebarTreeAtom) ?? []; + const wrapperRef = useRef(null); const treeRef = useRef(null); const filterRef = useRef(null); @@ -724,7 +725,11 @@ function Sidebar({ className }: { className?: string }) { ); } -export default Sidebar; +// Memoized so route navigations (which re-render the workspace layout) don't +// re-render the sidebar subtree. In large workspaces a sidebar re-render is +// very expensive: it re-renders DndContext, whose context churn re-renders +// every visible TreeItem regardless of their memo comparators. +export default memo(Sidebar); function getGitContextMenuItems({ items, diff --git a/apps/yaak-client/components/WebsocketRequestPane.tsx b/apps/yaak-client/components/WebsocketRequestPane.tsx index 2929aa93..57edc40b 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -1,5 +1,5 @@ import type { WebsocketRequest } from "@yaakapp-internal/models"; -import { patchModel } from "@yaakapp-internal/models"; +import { flushAllModelWrites, patchModel, patchModelDebounced } from "@yaakapp-internal/models"; import type { GenericCompletionOption } from "@yaakapp-internal/plugins"; import { closeWebsocket, connectWebsocket, sendWebsocket } from "@yaakapp-internal/ws"; import classNames from "classnames"; @@ -8,8 +8,7 @@ import type { CSSProperties } from "react"; import { useCallback, useMemo, useRef } from "react"; import { getActiveCookieJar } from "../hooks/useActiveCookieJar"; import { getActiveEnvironment } from "../hooks/useActiveEnvironment"; -import { activeRequestIdAtom } from "../hooks/useActiveRequestId"; -import { allRequestsAtom } from "../hooks/useAllRequests"; +import { allRequestUrlsAtom } from "../hooks/useAllRequests"; import { useAuthTab } from "../hooks/useAuthTab"; import { useCancelHttpResponse } from "../hooks/useCancelHttpResponse"; import { useHeadersTab } from "../hooks/useHeadersTab"; @@ -18,7 +17,6 @@ import { usePinnedHttpResponse } from "../hooks/usePinnedHttpResponse"; import { activeWebsocketConnectionAtom } from "../hooks/usePinnedWebsocketConnection"; import { useRequestEditor, useRequestEditorEvent } from "../hooks/useRequestEditor"; import { useRequestUpdateKey } from "../hooks/useRequestUpdateKey"; -import { deepEqualAtom } from "../lib/atoms"; import { languageFromContentType } from "../lib/contentType"; import { generateId } from "../lib/generateId"; import { extractPathPlaceholders } from "../lib/pathPlaceholders"; @@ -55,15 +53,12 @@ const TAB_SETTINGS = "settings"; const TAB_DESCRIPTION = "description"; const TABS_STORAGE_KEY = "websocket_request_tabs"; -const nonActiveRequestUrlsAtom = atom((get) => { - const activeRequestId = get(activeRequestIdAtom); - const requests = get(allRequestsAtom); - return requests - .filter((r) => r.id !== activeRequestId) - .map((r): GenericCompletionOption => ({ type: "constant", label: r.url })); -}); - -const memoNotActiveRequestUrlsAtom = deepEqualAtom(nonActiveRequestUrlsAtom); +// Derived from the identity-stable URL list so this only recomputes when a URL +// actually changes. The active request's own URL is included, but exact matches +// are filtered out at completion time by genericCompletion. +const requestUrlOptionsAtom = atom((get): GenericCompletionOption[] => + get(allRequestUrlsAtom).map((url) => ({ type: "constant", label: url })), +); export function WebsocketRequestPane({ style, fullHeight, className, activeRequest }: Props) { const activeRequestId = activeRequest.id; @@ -128,7 +123,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque const { mutate: cancelResponse } = useCancelHttpResponse(activeResponse?.id ?? null); const connection = useAtomValue(activeWebsocketConnectionAtom); - const autocompleteUrls = useAtomValue(memoNotActiveRequestUrlsAtom); + const autocompleteUrls = useAtomValue(requestUrlOptionsAtom); const autocomplete: GenericCompletionConfig = useMemo( () => getUrlCompletionConfig(autocompleteUrls), @@ -136,6 +131,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque ); const handleConnect = useCallback(async () => { + await flushAllModelWrites(); // The backend reads the request from the DB await connectWebsocket({ requestId: activeRequest.id, environmentId: getActiveEnvironment()?.id ?? null, @@ -145,6 +141,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque const handleSend = useCallback(async () => { if (connection == null) return; + await flushAllModelWrites(); // The backend reads the message from the DB await sendWebsocket({ connectionId: connection?.id, environmentId: getActiveEnvironment()?.id ?? null, @@ -157,7 +154,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque }, [connection]); const handleUrlChange = useCallback( - (url: string) => patchModel(activeRequest, { url }), + (url: string) => patchModelDebounced(activeRequest, { url }), [activeRequest], ); @@ -241,7 +238,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque forceUpdateKey={forceUpdateKey} headers={activeRequest.headers} stateKey={`headers.${activeRequest.id}`} - onChange={(headers) => patchModel(activeRequest, { headers })} + onChange={(headers) => patchModelDebounced(activeRequest, { headers })} /> @@ -249,7 +246,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque stateKey={`params.${activeRequest.id}`} forceUpdateKey={forceUpdateKey + urlParametersKey} pairs={urlParameterPairs} - onChange={(urlParameters) => patchModel(activeRequest, { urlParameters })} + onChange={(urlParameters) => patchModelDebounced(activeRequest, { urlParameters })} /> @@ -261,7 +258,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque heightMode={fullHeight ? "full" : "auto"} defaultValue={activeRequest.message} language={messageLanguage} - onChange={(message) => patchModel(activeRequest, { message })} + onChange={(message) => patchModelDebounced(activeRequest, { message })} stateKey={`json.${activeRequest.id}`} /> @@ -278,7 +275,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque className="font-sans text-xl! px-0!" containerClassName="border-0" placeholder={resolvedModelName(activeRequest)} - onChange={(name) => patchModel(activeRequest, { name })} + onChange={(name) => patchModelDebounced(activeRequest, { name })} /> patchModel(activeRequest, { description })} + onChange={(description) => patchModelDebounced(activeRequest, { description })} /> diff --git a/apps/yaak-client/components/core/Editor/Editor.tsx b/apps/yaak-client/components/core/Editor/Editor.tsx index 2dc1c203..3dd40c8c 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -1,4 +1,5 @@ import { startCompletion } from "@codemirror/autocomplete"; +import { debounce } from "@yaakapp-internal/lib"; import { defaultKeymap, historyField, indentWithTab } from "@codemirror/commands"; import { foldState, forceParsing } from "@codemirror/language"; import type { EditorStateConfig, Extension } from "@codemirror/state"; @@ -381,6 +382,7 @@ function EditorInner({ const initEditorRef = useCallback( function initEditorRef(container: HTMLDivElement | null) { if (container === null) { + flushCachedEditorState(stateKey); cm.current?.view.destroy(); cm.current = null; return; @@ -639,7 +641,7 @@ function getExtensions({ onChange.current?.(update.state.doc.toString()); } - saveCachedEditorState(stateKey, update.state); + saveCachedEditorStateDebounced(stateKey, update.state); }), ]; } @@ -652,6 +654,27 @@ const placeholderElFromText = (text: string | undefined) => { return el; }; +// Serializing the state (full doc + history) and md5-ing the doc is too +// expensive to do on every update (each keystroke and cursor move), so +// debounce it per state key and flush when the editor unmounts. +const SAVE_STATE_DEBOUNCE_MS = 500; +const stateSavers = new Map>(); + +function saveCachedEditorStateDebounced(stateKey: string | null, state: EditorState) { + if (!stateKey) return; + let saver = stateSavers.get(stateKey); + if (saver == null) { + saver = debounce((s: EditorState) => saveCachedEditorState(stateKey, s), SAVE_STATE_DEBOUNCE_MS); + stateSavers.set(stateKey, saver); + } + saver(state); +} + +function flushCachedEditorState(stateKey: string | null) { + if (!stateKey) return; + stateSavers.get(stateKey)?.flush(); +} + function saveCachedEditorState(stateKey: string | null, state: EditorState | null) { if (!stateKey || state == null) return; const stateObj = state.toJSON(stateFields); diff --git a/apps/yaak-client/hooks/useAllRequests.ts b/apps/yaak-client/hooks/useAllRequests.ts index c8150e5b..9dd20d42 100644 --- a/apps/yaak-client/hooks/useAllRequests.ts +++ b/apps/yaak-client/hooks/useAllRequests.ts @@ -4,6 +4,7 @@ import { websocketRequestsAtom, } from "@yaakapp-internal/models"; import { atom, useAtomValue } from "jotai"; +import { selectAtom } from "jotai/utils"; export const allRequestsAtom = atom((get) => [ ...get(httpRequestsAtom), @@ -14,3 +15,26 @@ export const allRequestsAtom = atom((get) => [ export function useAllRequests() { return useAtomValue(allRequestsAtom); } + +const stringArrayEqual = (a: string[], b: string[]) => + a.length === b.length && a.every((v, i) => v === b[i]); + +// Identity-stable derivations so subscribers don't recompute or re-render when +// unrelated request fields change (eg. every debounced edit of a request) +export const allRequestIdsAtom = selectAtom( + allRequestsAtom, + (requests) => requests.map((r) => r.id), + stringArrayEqual, +); + +export const allRequestUrlsAtom = selectAtom( + allRequestsAtom, + (requests) => { + const urls = new Set(); + for (const r of requests) { + if (r.url) urls.add(r.url); + } + return Array.from(urls); + }, + stringArrayEqual, +); diff --git a/apps/yaak-client/hooks/useGrpc.ts b/apps/yaak-client/hooks/useGrpc.ts index c30b3e7e..940d2d0f 100644 --- a/apps/yaak-client/hooks/useGrpc.ts +++ b/apps/yaak-client/hooks/useGrpc.ts @@ -1,6 +1,7 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import { emit } from "@tauri-apps/api/event"; import type { GrpcConnection, GrpcRequest } from "@yaakapp-internal/models"; +import { flushAllModelWrites } from "@yaakapp-internal/models"; import { jotaiStore } from "../lib/jotai"; import { minPromiseMillis } from "../lib/minPromiseMillis"; import { invokeCmd } from "../lib/tauri"; @@ -22,8 +23,14 @@ export function useGrpc( const go = useMutation({ mutationKey: ["grpc_go", conn?.id], - mutationFn: () => - invokeCmd("cmd_grpc_go", { requestId, environmentId: environment?.id, protoFiles }), + mutationFn: async () => { + await flushAllModelWrites(); // The backend reads the request from the DB + return invokeCmd("cmd_grpc_go", { + requestId, + environmentId: environment?.id, + protoFiles, + }); + }, }); const send = useMutation({ diff --git a/apps/yaak-client/hooks/useParentFolders.ts b/apps/yaak-client/hooks/useParentFolders.ts index 92f5db71..9cea73a5 100644 --- a/apps/yaak-client/hooks/useParentFolders.ts +++ b/apps/yaak-client/hooks/useParentFolders.ts @@ -6,21 +6,19 @@ import { useMemo } from "react"; export function useParentFolders(m: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null) { const folders = useAtomValue(foldersAtom); - return useMemo(() => getParentFolders(folders, m), [folders, m]); + // Key on folderId, not the model itself, so edits to the model (eg. every URL + // keystroke replacing the active request) don't produce a new array identity + const folderId = m?.folderId ?? null; + return useMemo(() => getParentFolders(folders, folderId), [folders, folderId]); } -function getParentFolders( - folders: Folder[], - currentModel: Folder | HttpRequest | GrpcRequest | WebsocketRequest | null, -): Folder[] { - if (currentModel == null) return []; +function getParentFolders(folders: Folder[], folderId: string | null): Folder[] { + if (folderId == null) return []; - const parentFolder = currentModel.folderId - ? folders.find((f) => f.id === currentModel.folderId) - : null; + const parentFolder = folders.find((f) => f.id === folderId); if (parentFolder == null) { return []; } - return [parentFolder, ...getParentFolders(folders, parentFolder)]; + return [parentFolder, ...getParentFolders(folders, parentFolder.folderId ?? null)]; } diff --git a/crates/yaak-models/guest-js/store.ts b/crates/yaak-models/guest-js/store.ts index 92b700e4..201745c6 100644 --- a/crates/yaak-models/guest-js/store.ts +++ b/crates/yaak-models/guest-js/store.ts @@ -1,5 +1,6 @@ import { invoke } from "@tauri-apps/api/core"; import { getCurrentWebviewWindow } from "@tauri-apps/api/webviewWindow"; +import { debounce } from "@yaakapp-internal/lib"; import { AnyModel, ModelPayload } from "../bindings/gen_models"; import { modelStoreDataAtom } from "./atoms"; import { ExtractModel, JotaiStore, ModelStoreData } from "./types"; @@ -12,6 +13,9 @@ const pendingModelWrites = new Set>(); export function initModelStore(store: JotaiStore) { _store = store; + // Don't lose debounced patches if the window closes while one is pending + window.addEventListener("beforeunload", flushAllPendingPatches); + getCurrentWebviewWindow() .listen("model_write", ({ payload }) => { if (shouldIgnoreModel(payload)) return; @@ -53,6 +57,7 @@ function trackModelWrite(write: Promise): Promise { } export async function flushAllModelWrites(): Promise { + flushAllPendingPatches(); const results = await Promise.allSettled(pendingModelWrites); const rejected = results.find((result) => result.status === "rejected"); if (rejected?.status === "rejected") { @@ -60,6 +65,61 @@ export async function flushAllModelWrites(): Promise { } } +const PATCH_DEBOUNCE_MS = 400; + +interface PendingPatch { + model: AnyModel["model"]; + id: string; + patch: Record; + write: ReturnType; +} + +const pendingPatches = new Map(); + +/** + * Like patchModel, but coalesces rapid patches to the same model (eg. one per + * keystroke) into a single write. Later fields overwrite earlier ones, so it's + * only safe for whole-value fields like url, body, or headers. Pending patches + * flush after a short delay, and flushAllModelWrites() (called before sends and + * duplicates) flushes them immediately. + */ +export function patchModelDebounced< + M extends AnyModel["model"], + T extends ExtractModel, +>(base: Pick, patch: Partial): void { + const key = `${base.model}.${base.id}`; + let pending = pendingPatches.get(key); + if (pending == null) { + pending = { + model: base.model, + id: base.id, + patch: {}, + write: debounce(() => writePendingPatch(key), PATCH_DEBOUNCE_MS), + }; + pendingPatches.set(key, pending); + } + pending.patch = { ...pending.patch, ...patch }; + pending.write(); +} + +function writePendingPatch(key: string) { + const pending = pendingPatches.get(key); + if (pending == null) return; + pendingPatches.delete(key); + try { + void patchModelById(pending.model, pending.id, pending.patch); + } catch (err) { + // Model may have been deleted while the patch was pending + console.warn("Failed to flush pending patch", key, err); + } +} + +export function flushAllPendingPatches() { + for (const pending of Array.from(pendingPatches.values())) { + pending.write.flush(); + } +} + let _activeWorkspaceId: string | null = null; export async function changeModelStoreWorkspace(workspaceId: string | null) { diff --git a/packages/common-lib/debounce.ts b/packages/common-lib/debounce.ts index e6dc28aa..24fbc6bd 100644 --- a/packages/common-lib/debounce.ts +++ b/packages/common-lib/debounce.ts @@ -1,13 +1,32 @@ // oxlint-disable-next-line no-explicit-any export function debounce(fn: (...args: any[]) => void, delay = 500) { - let timer: ReturnType; + let timer: ReturnType | 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; } diff --git a/packages/ui/src/components/tree/Tree.tsx b/packages/ui/src/components/tree/Tree.tsx index cbe0d512..759b0988 100644 --- a/packages/ui/src/components/tree/Tree.tsx +++ b/packages/ui/src/components/tree/Tree.tsx @@ -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( ) { const store = useStore(); const treeRef = useRef(null); + const virtualizerRef = useRef | null>(null); + const getScrollElement = useCallback(() => treeRef.current, []); + const handleVirtualizerReady = useCallback((v: Virtualizer) => { + 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( }, []); const tryFocus = useCallback(() => { - const $el = treeRef.current?.querySelector( - '.tree-item button[tabindex="0"]', - ); - if ($el == null) { + const find = () => + treeRef.current?.querySelector('.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( 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( 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( }); } }, - [root.depth, root.item.id, selectableItems, treeId], + [root.depth, root.item.id, selectableItems, treeId, visibleItems], ); const handleDragStart = useCallback( @@ -680,12 +713,18 @@ function TreeInner( "[&_.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", )} > diff --git a/packages/ui/src/components/tree/TreeItemList.tsx b/packages/ui/src/components/tree/TreeItemList.tsx index cb7bd494..98066e7a 100644 --- a/packages/ui/src/components/tree/TreeItemList.tsx +++ b/packages/ui/src/components/tree/TreeItemList.tsx @@ -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 = 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) => void; }; -export function TreeItemList({ +export function TreeItemList(props: TreeItemListProps) { + if (props.getScrollElement != null) { + return ; + } + return ; +} + +function StaticTreeItemList({ className, getItemKey, nodes, @@ -32,6 +47,8 @@ export function TreeItemList({ treeId, forceDepth, addTreeItemRef, + getScrollElement: _getScrollElement, + onVirtualizerReady: _onVirtualizerReady, ...props }: TreeItemListProps) { return ( @@ -53,3 +70,89 @@ export function TreeItemList({ ); } + +// 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({ + className, + getItemKey, + nodes, + style, + treeId, + forceDepth, + addTreeItemRef, + getScrollElement, + onVirtualizerReady, + ...props +}: TreeItemListProps & { getScrollElement: () => HTMLElement | null }) { + const listRef = useRef(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 ( +
    + + {virtualizer.getVirtualItems().map((virtualItem) => { + const child = nodes[virtualItem.index]; + if (child == null) return null; + return ( +
    + + +
    + ); + })} +
+ ); +}