From 74d1b5d6cefde8540206ab9cdfd1d81b0e8dcab7 Mon Sep 17 00:00:00 2001 From: Gregory Schier Date: Thu, 13 Aug 2026 12:23:56 -0700 Subject: [PATCH] HUGE sidebar and typing performance improvements (#516) --- .../components/GrpcRequestPane.tsx | 17 +- .../components/HttpRequestPane.tsx | 35 ++-- apps/yaak-client/components/Sidebar.tsx | 7 +- .../components/WebsocketRequestPane.tsx | 42 ++-- .../components/core/Editor/Editor.tsx | 42 +++- apps/yaak-client/hooks/useAllRequests.ts | 24 +++ apps/yaak-client/hooks/useGrpc.ts | 11 +- apps/yaak-client/hooks/useParentFolders.ts | 18 +- apps/yaak-client/package.json | 2 +- crates/yaak-models/guest-js/store.ts | 94 ++++++++- package-lock.json | 16 +- packages/common-lib/debounce.test.ts | 76 +++++++ packages/common-lib/debounce.ts | 25 ++- packages/ui/src/components/tree/Tree.tsx | 193 +++++++++++++----- packages/ui/src/components/tree/TreeItem.tsx | 8 +- .../ui/src/components/tree/TreeItemList.tsx | 140 ++++++++++++- 16 files changed, 620 insertions(+), 130 deletions(-) create mode 100644 packages/common-lib/debounce.test.ts diff --git a/apps/yaak-client/components/GrpcRequestPane.tsx b/apps/yaak-client/components/GrpcRequestPane.tsx index 9884e263..3dd333b7 100644 --- a/apps/yaak-client/components/GrpcRequestPane.tsx +++ b/apps/yaak-client/components/GrpcRequestPane.tsx @@ -1,4 +1,9 @@ -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 +80,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 +153,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 +306,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; @@ -286,16 +281,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), @@ -335,7 +330,7 @@ export function HttpRequestPane({ style, fullHeight, className, activeRequest }: ); const handleUrlChange = useCallback( - (url: string) => patchModel(activeRequest, { url }), + (url: string) => patchModelDebounced(activeRequest, { url }), [activeRequest], ); @@ -381,7 +376,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 })} /> @@ -389,7 +384,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 })} /> @@ -441,7 +436,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 ad20e71a..134c6f27 100644 --- a/apps/yaak-client/components/WebsocketRequestPane.tsx +++ b/apps/yaak-client/components/WebsocketRequestPane.tsx @@ -1,5 +1,10 @@ import type { WebsocketRequest } from "@yaakapp-internal/models"; -import { getModel, patchModel } from "@yaakapp-internal/models"; +import { + flushAllModelWrites, + getModel, + 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 +13,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 +22,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 { derivePathPlaceholderPairs, renamePathPlaceholder } from "../lib/pathPlaceholders"; import { prepareImportQuerystring } from "../lib/prepareImportQuerystring"; @@ -53,15 +56,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; @@ -139,7 +139,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), @@ -147,6 +147,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, @@ -156,6 +157,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, @@ -168,7 +170,7 @@ export function WebsocketRequestPane({ style, fullHeight, className, activeReque }, [connection]); const handleUrlChange = useCallback( - (url: string) => patchModel(activeRequest, { url }), + (url: string) => patchModelDebounced(activeRequest, { url }), [activeRequest], ); @@ -252,7 +254,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 })} /> @@ -260,7 +262,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 })} /> @@ -272,7 +274,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}`} /> @@ -289,7 +291,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 73870048..606136af 100644 --- a/apps/yaak-client/components/core/Editor/Editor.tsx +++ b/apps/yaak-client/components/core/Editor/Editor.tsx @@ -8,6 +8,7 @@ import { emacs } from "@replit/codemirror-emacs"; import { vim } from "@replit/codemirror-vim"; import { vscodeKeymap } from "@replit/codemirror-vscode-keymap"; +import { debounce } from "@yaakapp-internal/lib"; import type { EditorKeymap } from "@yaakapp-internal/models"; import { settingsAtom } from "@yaakapp-internal/models"; import type { EditorLanguage, TemplateFunction } from "@yaakapp-internal/plugins"; @@ -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,44 @@ const placeholderElFromText = (text: string | undefined) => { return el; }; +// Caching the state is too expensive to do on every update (every keystroke and cursor move), +// so debounce it per state key and flush when the editor unmounts. +// +// The cost scales with the document, and every update pays it in full: +// - `state.toJSON` flattens the whole rope into a string, ~0.13 ms per 200 KB +// - the fingerprint is an exact md5 for editable documents, ~0.3 ms per 200 KB. `docFingerprint` +// only samples read-only ones, so editing a large body still hashes all of it +// - `sessionStorage.setItem` is synchronous and blocks the main thread +// Typing in a 200 KB body costs ~0.4 ms per keystroke before the storage write, and a 1 MB one +// ~2.3 ms. Read-only documents skip most of the hashing but still serialize, which is the larger +// half once documents get into the megabytes. +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); +} + +// NOTE: Only called when an editor unmounts, so the saver is dropped rather than left in the map +// for every state key the session has ever shown. A pending saver holds the last EditorState, +// which holds the whole document. +function flushCachedEditorState(stateKey: string | null) { + if (!stateKey) return; + const saver = stateSavers.get(stateKey); + if (saver == null) return; + saver.flush(); + stateSavers.delete(stateKey); +} + 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/apps/yaak-client/package.json b/apps/yaak-client/package.json index 1a339b14..691f514f 100644 --- a/apps/yaak-client/package.json +++ b/apps/yaak-client/package.json @@ -30,7 +30,7 @@ "@shopify/lang-jsonc": "^1.0.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.133.13", - "@tanstack/react-virtual": "^3.13.12", + "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", diff --git a/crates/yaak-models/guest-js/store.ts b/crates/yaak-models/guest-js/store.ts index 92b700e4..404dd85b 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,85 @@ 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(); + } +} + +/** + * Apply a model's pending patch, if it has one that hasn't been written yet. + * + * The store only moves forward when the backend echoes a write back, so between a keystroke and + * its debounced write the stored copy is behind what the user typed. Reading through the pending + * patch keeps that window invisible to the imperative readers below, which are the ones that go + * on to write the model back. + */ +function withPendingPatch(model: T | null): T | null { + if (model == null || pendingPatches.size === 0) return model; + const { model: modelType, id } = model as { model?: string; id?: string }; + const pending = pendingPatches.get(`${modelType}.${id}`); + return pending == null ? model : ({ ...model, ...pending.patch } as T); +} + +/** Drop a model's pending patch and cancel its scheduled write */ +function consumePendingPatch(model: AnyModel["model"], id: string) { + const key = `${model}.${id}`; + const pending = pendingPatches.get(key); + if (pending == null) return; + pending.write.cancel(); + pendingPatches.delete(key); +} + let _activeWorkspaceId: string | null = null; export async function changeModelStoreWorkspace(workspaceId: string | null) { @@ -96,7 +180,7 @@ export function getModel = Array.isArray(modelType) ? modelType : [modelType]; for (const t of types) { let v = data[t][id]; - if (v?.model === t) return v as T; + if (v?.model === t) return withPendingPatch(v as T); } return null; } @@ -106,7 +190,7 @@ export function getAnyModel(id: string): AnyModel | null { for (const t of Object.keys(data)) { // oxlint-disable-next-line no-explicit-any -- dynamic key access let v = (data as any)[t]?.[id]; - if (v?.model === t) return v; + if (v?.model === t) return withPendingPatch(v); } return null; } @@ -116,11 +200,17 @@ export function patchModelById | ((prev: T) => T), ): Promise { + // Reads through any pending debounced patch, so the merge below can't put a stale value back + // over something the user has already typed let prev = getModel(model, id); if (prev == null) { throw new Error(`Failed to get model to patch id=${id} model=${model}`); } + // `prev` already carries the pending patch, so this write supersedes it. Leaving it queued + // would let it land afterwards and undo whatever this write decided. + consumePendingPatch(model, id); + const newModel = typeof patch === "function" ? patch(prev) : { ...prev, ...patch }; return updateModel(newModel); } diff --git a/package-lock.json b/package-lock.json index a7b3bd78..39f0dd43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,7 +121,7 @@ "@shopify/lang-jsonc": "^1.0.1", "@tanstack/react-query": "^5.90.5", "@tanstack/react-router": "^1.133.13", - "@tanstack/react-virtual": "^3.13.12", + "@tanstack/react-virtual": "^3.14.9", "@tauri-apps/api": "^2.11.0", "@tauri-apps/plugin-clipboard-manager": "^2.3.2", "@tauri-apps/plugin-dialog": "^2.7.1", @@ -4342,12 +4342,12 @@ } }, "node_modules/@tanstack/react-virtual": { - "version": "3.13.18", - "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", - "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "version": "3.14.9", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.9.tgz", + "integrity": "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ==", "license": "MIT", "dependencies": { - "@tanstack/virtual-core": "3.13.18" + "@tanstack/virtual-core": "3.17.7" }, "funding": { "type": "github", @@ -4492,9 +4492,9 @@ } }, "node_modules/@tanstack/virtual-core": { - "version": "3.13.18", - "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", - "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "version": "3.17.7", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.7.tgz", + "integrity": "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==", "license": "MIT", "funding": { "type": "github", diff --git a/packages/common-lib/debounce.test.ts b/packages/common-lib/debounce.test.ts new file mode 100644 index 00000000..b92ec8bf --- /dev/null +++ b/packages/common-lib/debounce.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { debounce } from "./debounce"; + +describe("debounce", () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it("calls once with the latest args after the delay", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + + d("a"); + d("b"); + d("c"); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledExactlyOnceWith("c"); + }); + + it("flush invokes a pending call immediately", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + + d("a"); + d("b"); + d.flush(); + expect(fn).toHaveBeenCalledExactlyOnceWith("b"); + + // The scheduled call was cancelled, so waiting out the delay adds nothing + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + }); + + it("flush is a no-op with nothing pending", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + + d.flush(); + expect(fn).not.toHaveBeenCalled(); + + d("a"); + vi.advanceTimersByTime(100); + d.flush(); + expect(fn).toHaveBeenCalledExactlyOnceWith("a"); + }); + + it("cancel drops the pending call and its args", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + + d("a"); + d.cancel(); + vi.advanceTimersByTime(100); + expect(fn).not.toHaveBeenCalled(); + + // A cancelled call must not leak its args into the next one + d.flush(); + expect(fn).not.toHaveBeenCalled(); + }); + + it("starts a fresh delay after firing", () => { + const fn = vi.fn(); + const d = debounce(fn, 100); + + d("a"); + vi.advanceTimersByTime(100); + d("b"); + vi.advanceTimersByTime(99); + expect(fn).toHaveBeenCalledOnce(); + + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledTimes(2); + expect(fn).toHaveBeenLastCalledWith("b"); + }); +}); 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..e55017cf 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,10 +24,10 @@ 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"; +import type { ContextMenuRenderer, TreeNode } from "./common"; import { closestVisibleNode, equalSubtree, getSelectedItems, hasAncestor } from "./common"; import { TreeDragOverlay } from "./TreeDragOverlay"; import type { TreeItemClickEvent, TreeItemHandle, TreeItemProps } from "./TreeItem"; @@ -87,7 +88,39 @@ function TreeInner( ) { const store = useStore(); const treeRef = useRef(null); + const virtualizerRef = useRef | null>(null); + + // The scroll container is tracked in state as well as a ref, so the virtualizer re-resolves it + // once it exists. React attaches refs and runs layout effects child-first, so TreeItemList's + // effects run before this ancestor's ref is set: on a fresh mount a ref-only getScrollElement + // returns null, and the virtualizer renders nothing until some later render happens to wake it + // up. Usually one does, which is why this only showed when the tree remounted into settled data + // (filtering down to no results and back). + const [scrollEl, setScrollEl] = useState(null); + const setTreeRef = useCallback((el: HTMLDivElement | null) => { + treeRef.current = el; + setScrollEl(el); + }, []); + const getScrollElement = useCallback(() => scrollEl, [scrollEl]); + 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; @@ -97,6 +130,28 @@ function TreeInner( const handleAddTreeItemRef = useCallback((item: T, r: TreeItemHandle | null) => { if (r == null) { delete treeItemRefs.current[item.id]; + + // Keep keyboard focus inside the tree when the focused row is virtualized away. + // + // Scrolling the focused row out of the window destroys the button holding focus, and the + // browser drops focus to the body rather than moving it anywhere. Everything keyboard-driven + // is gated on the tree containing document.activeElement (arrow navigation here, rename, + // delete, duplicate and the context menu in the consumer), so the entire keyboard interface + // would go dead until the user clicked a row again. They all act on the selected id rather + // than the focused element, so parking focus on the container is enough to keep them live. + // + // NOTE: This has to hang off the unmount rather than a render or a focusout. Scrolling + // re-renders the list, not this component, and removing a focused element doesn't reliably + // fire focusout. + requestAnimationFrame(() => { + const el = treeRef.current; + if (el == null) return; + const active = document.activeElement; + const focusWasDropped = active == null || active === document.body || !active.isConnected; + if (focusWasDropped) { + el.focus({ preventScroll: true }); + } + }); } else { treeItemRefs.current[item.id] = r; } @@ -125,16 +180,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; @@ -187,13 +254,42 @@ function TreeInner( [treeId, tryFocus], ); + /** + * Run something against a row's handle, scrolling the row into view first when it isn't + * mounted. + * + * Virtualized rows only have a handle while they're in the window, but the row these act on is + * the selected one, which the user is free to scroll away from before hitting a hotkey. Without + * this, renaming an off-screen row silently does nothing and the context menu has no rect to + * open against. + */ + const withTreeItem = useCallback( + (id: string, action: (handle: TreeItemHandle) => void) => { + const mounted = treeItemRefs.current[id]; + if (mounted != null) { + action(mounted); + return; + } + + const index = visibleItems.findIndex((i) => i.node.item.id === id); + if (index < 0) return; + + virtualizerRef.current?.scrollToIndex(index, { align: "auto" }); + requestAnimationFrame(() => { + const handle = treeItemRefs.current[id]; + if (handle != null) action(handle); + }); + }, + [visibleItems], + ); + const treeHandle = useMemo( () => ({ treeId, focus: tryFocus, hasFocus: hasFocus, getSelectedItems: () => getSelectedItems(store, treeId, selectableItems), - renameItem: (id) => treeItemRefs.current[id]?.rename(), + renameItem: (id) => withTreeItem(id, (handle) => handle.rename()), selectItem: (id, focus) => { if (store.get(selectedIdsFamily(treeId)).includes(id)) { // Already selected @@ -207,12 +303,14 @@ function TreeInner( const items = getSelectedItems(store, treeId, selectableItems); const menuItems = await getContextMenu(items); const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId; - const rect = lastSelectedId ? treeItemRefs.current[lastSelectedId]?.rect() : null; - if (rect == null) return; - setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y }); + if (lastSelectedId == null) return; + withTreeItem(lastSelectedId, (handle) => { + const rect = handle.rect(); + setShowContextMenu({ items: menuItems, x: rect.x, y: rect.y }); + }); }, }), - [getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus], + [getContextMenu, hasFocus, selectableItems, setSelected, treeId, tryFocus, withTreeItem], ); useImperativeHandle(ref, (): TreeHandle => treeHandle, [treeHandle]); @@ -244,11 +342,8 @@ function TreeInner( store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, lastId: item.id })); if (shiftKey) { - const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems); - const anchorIndex = validSelectableItems.findIndex( - (i) => i.node.item.id === anchorSelectedId, - ); - const currIndex = validSelectableItems.findIndex((v) => v.node.item.id === item.id); + const anchorIndex = visibleItems.findIndex((i) => i.node.item.id === anchorSelectedId); + const currIndex = visibleItems.findIndex((v) => v.node.item.id === item.id); // Nothing was selected yet, so just select this item if (selectedIds.length === 0 || anchorIndex === -1 || currIndex === -1) { @@ -259,14 +354,14 @@ function TreeInner( if (currIndex > anchorIndex) { // Selecting down - const itemsToSelect = validSelectableItems.slice(anchorIndex, currIndex + 1); + const itemsToSelect = visibleItems.slice(anchorIndex, currIndex + 1); setSelected( itemsToSelect.map((v) => v.node.item.id), true, ); } else if (currIndex < anchorIndex) { // Selecting up - const itemsToSelect = validSelectableItems.slice(currIndex, anchorIndex + 1); + const itemsToSelect = visibleItems.slice(currIndex, anchorIndex + 1); setSelected( itemsToSelect.map((v) => v.node.item.id), true, @@ -289,7 +384,7 @@ function TreeInner( store.set(focusIdsFamily(treeId), (prev) => ({ ...prev, anchorId: item.id })); } }, - [selectableItems, setSelected, treeId], + [setSelected, treeId, visibleItems], ); const handleClick = useCallback["onClick"]>>( @@ -307,27 +402,25 @@ function TreeInner( const selectPrevItem = useCallback( (e: TreeItemClickEvent) => { const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId; - const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems); - const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); - const item = validSelectableItems[index - 1]; + const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId); + const item = visibleItems[index - 1]; if (item != null) { handleSelect(item.node.item, e); } }, - [handleSelect, selectableItems, treeId], + [handleSelect, treeId, visibleItems], ); const selectNextItem = useCallback( (e: TreeItemClickEvent) => { const lastSelectedId = store.get(focusIdsFamily(treeId)).lastId; - const validSelectableItems = getValidSelectableItems(store, collapsedAtom, selectableItems); - const index = validSelectableItems.findIndex((i) => i.node.item.id === lastSelectedId); - const item = validSelectableItems[index + 1]; + const index = visibleItems.findIndex((i) => i.node.item.id === lastSelectedId); + const item = visibleItems[index + 1]; if (item != null) { handleSelect(item.node.item, e); } }, - [handleSelect, selectableItems, treeId], + [handleSelect, treeId, visibleItems], ); const selectParentItem = useCallback( @@ -448,8 +541,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 +570,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 +602,7 @@ function TreeInner( }); } }, - [root.depth, root.item.id, selectableItems, treeId], + [root.depth, root.item.id, selectableItems, treeId, visibleItems], ); const handleDragStart = useCallback( @@ -659,7 +752,10 @@ function TreeInner( autoScroll >
( "[&_.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", )} >
@@ -731,20 +833,3 @@ function DropRegionAfterList({ // biome-ignore lint/a11y/noStaticElementInteractions: Meh return
; } - -function getValidSelectableItems( - store: JotaiStore, - collapsedAtom: CollapsedAtom, - selectableItems: SelectableTreeNode[], -) { - const collapsed = store.get(collapsedAtom); - return selectableItems.filter((i) => { - if (i.node.hidden) return false; - let p = i.node.parent; - while (p) { - if (collapsed[p.item.id]) return false; - p = p.parent; - } - return true; - }); -} diff --git a/packages/ui/src/components/tree/TreeItem.tsx b/packages/ui/src/components/tree/TreeItem.tsx index 3f14ffd7..87eb5c0d 100644 --- a/packages/ui/src/components/tree/TreeItem.tsx +++ b/packages/ui/src/components/tree/TreeItem.tsx @@ -107,8 +107,14 @@ function TreeItem_({ [editing, getEditOptions], ); + // NOTE: Unregisters on unmount, or the tree keeps a handle to a component that no longer exists. + // Harmless while every row stayed mounted, but virtualized rows unmount whenever they leave the + // window, and acting on a dead handle does nothing at best (rename) and reports a zeroed rect at + // worst (the context menu opening in the corner of the screen). useEffect(() => { - setRef?.(node.item, handle); + const item = node.item; + setRef?.(item, handle); + return () => setRef?.(item, null); }, [setRef, handle, node.item]); const ancestorIds = useMemo(() => { diff --git a/packages/ui/src/components/tree/TreeItemList.tsx b/packages/ui/src/components/tree/TreeItemList.tsx index cb7bd494..16f378ac 100644 --- a/packages/ui/src/components/tree/TreeItemList.tsx +++ b/packages/ui/src/components/tree/TreeItemList.tsx @@ -1,5 +1,9 @@ +import type { Virtualizer } from "@tanstack/react-virtual"; +import { useVirtualizer } from "@tanstack/react-virtual"; +import { useAtomValue } from "jotai"; import type { CSSProperties } from "react"; -import { Fragment } from "react"; +import { Fragment, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { draggingIdsFamily } from "./atoms"; import type { SelectableTreeNode } from "./common"; import type { TreeProps } from "./Tree"; import { TreeDropMarker } from "./TreeDropMarker"; @@ -22,9 +26,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 +49,8 @@ export function TreeItemList({ treeId, forceDepth, addTreeItemRef, + getScrollElement: _getScrollElement, + onVirtualizerReady: _onVirtualizerReady, ...props }: TreeItemListProps) { return ( @@ -53,3 +72,120 @@ 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]); + + const virtualItems = virtualizer.getVirtualItems(); + + // Rows being dragged stay mounted even after they scroll out of the window. + // + // dnd-kit keeps a live reference to the node being dragged and re-measures it whenever that + // node is replaced. Its scroll adjustment is a delta against a baseline captured when that rect + // was last measured, and re-measuring resets the baseline to wherever the list happens to be + // scrolled right then, discarding the scroll accumulated so far. Letting the dragged row unmount + // mid-drag therefore corrupts every coordinate dnd-kit derives, by a bit more each time it + // happens, which is why the drop indicator drifts further the longer a drag autoscrolls. + const draggingIds = useAtomValue(draggingIdsFamily(treeId)); + const pinnedIndexes = useMemo(() => { + if (draggingIds.length === 0) return []; + const rendered = new Set(virtualItems.map((v) => v.index)); + const out: number[] = []; + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + if (node == null || rendered.has(i)) continue; + if (draggingIds.includes(node.node.item.id)) out.push(i); + } + return out; + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed off the rendered range + }, [draggingIds, nodes, virtualItems]); + + const renderRow = (index: number, start: number, measure: boolean) => { + const child = nodes[index]; + if (child == null) return null; + return ( +
+ + +
+ ); + }; + + return ( +
    + + {virtualItems.map((virtualItem) => renderRow(virtualItem.index, virtualItem.start, true))} + {/* NOTE: Not measured. Measuring an out-of-window row would write its size into the + virtualizer's cache under an index the window isn't tracking. */} + {pinnedIndexes.map((index) => + renderRow(index, virtualizer.measurementsCache[index]?.start ?? 0, false), + )} +
+ ); +}