From 3e4a155a24565d8fc06a38792bb501777f7a0c3b Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Thu, 5 Nov 2026 13:54:22 +0100 Subject: [PATCH 1/4] perf(web): Expand all no longer rescans every folder for each expansion Expanding every folder makes the Pierre tree emit one event per folder, and two listeners walked all folders on each event: the one that loads expanded folders and the "all expanded" check behind the button's icon. That is quadratic in the folder count: with 4,646 folders one Expand all held the main thread for up to 4.7 s in a single task in the browser harness. Both listeners now run once per task, however many events the tree emitted in it, and the expand-all pass only visits folders listed since its last pass. --- .../files/FileBrowserPanel.browser.test.tsx | 34 ++++++++++++++++++- .../src/components/files/FileBrowserPanel.tsx | 32 ++++++++++++----- .../src/components/files/fileTreeExpansion.ts | 24 +++++++++++++ 3 files changed, 81 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 1c0e1bc914bed..23f595ca8a079 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -1,4 +1,5 @@ import { EnvironmentId, type ProjectEntry } from "@t3tools/contracts"; +import { FileTree } from "@pierre/trees"; import { describe, expect, it, vi } from "vite-plus/test"; import { page, userEvent } from "vite-plus/test/browser"; @@ -12,7 +13,7 @@ const entries: readonly ProjectEntry[] = [ { path: "README.md", kind: "file" }, ]; const loadingDirectories: ReadonlySet = new Set(); -const directory = { +let directory = { entries, load: () => Promise.resolve(), refresh: () => {}, @@ -115,4 +116,35 @@ describe("FileBrowserPanel", () => { row.click(); await vi.waitFor(() => expect(onOpenFile).toHaveBeenCalledTimes(1)); }); + + it("expands every folder without asking the tree about all folders for each one", async () => { + const folders: readonly ProjectEntry[] = Array.from({ length: 300 }, (_, index) => ({ + path: `folder-${index}`, + kind: "directory", + })); + const load = vi.fn(() => Promise.resolve()); + const original = directory; + directory = { ...directory, entries: folders, load }; + using getItem = vi.spyOn(FileTree.prototype, "getItem"); + using _restore = { [Symbol.dispose]: () => (directory = original) }; + await using _screen = await render( + {}} + workspaceMutationId={null} + />, + ); + const expandAll = page.getByRole("button", { name: "Expand all folders" }); + await expect.element(expandAll).toBeVisible(); + getItem.mockClear(); + await userEvent.click(expandAll); + await expect.element(page.getByRole("button", { name: "Collapse all folders" })).toBeVisible(); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(folders.length)); + // Each of the 300 expansions used to look up all 300 folders again, about 137,000 lookups. + expect(getItem.mock.calls.length).toBeLessThan(folders.length * 20); + }); }); diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index b02baf31b0acf..0a1651c6153ab 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -4,13 +4,13 @@ import type { ContextMenuOpenContext as TreeContextMenuOpenContext, } from "@pierre/trees"; import type { EnvironmentId, ProjectEntry } from "@t3tools/contracts"; -import { FileTree, useFileTree, useFileTreeSearch, useFileTreeSelector } from "@pierre/trees/react"; +import { FileTree, useFileTree, useFileTreeSearch } from "@pierre/trees/react"; import { settlePromise } from "@t3tools/client-runtime/state/runtime"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import * as AsyncResult from "effect/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; import { ChevronsDownUp, ChevronsUpDown } from "lucide"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useSyncExternalStore } from "react"; import { Button } from "~/components/ui/button"; import { InputGroup, InputGroupInput } from "~/components/ui/input-group"; @@ -29,7 +29,11 @@ import { PIERRE_TREE_UNSAFE_CSS, pierreTreeStyle } from "~/pierre-tree-theme"; import { joinHostPath } from "./filePath"; import { createFileTreeDragMentionController } from "./fileTreeDragMention"; -import { areAllDirectoriesExpanded, setAllDirectoriesExpanded } from "./fileTreeExpansion"; +import { + areAllDirectoriesExpanded, + setAllDirectoriesExpanded, + subscribeOncePerTask, +} from "./fileTreeExpansion"; import { buildFileTreePathUpdates } from "./fileTreePathReconciliation"; import { loadFileAncestors } from "./loadFileAncestors"; import { useDirectoryEntries } from "./useDirectoryEntries"; @@ -305,8 +309,9 @@ export default function FileBrowserPanel({ const search = useFileTreeSearch(model); // Pierre restores the unfiltered tree when a search has no matches. const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0; - const allDirectoriesExpanded = useFileTreeSelector(model, (currentModel) => - areAllDirectoriesExpanded(currentModel, expandableDirectoryPaths), + const allDirectoriesExpanded = useSyncExternalStore( + (listener) => subscribeOncePerTask(model, listener), + () => areAllDirectoriesExpanded(model, expandableDirectoryPaths), ); const toggleAllDirectories = () => { const expanded = !(expandAll || allDirectoriesExpanded); @@ -322,6 +327,7 @@ export default function FileBrowserPanel({ search.close(); }; const expandedPathsRef = useRef(new Set()); + const expandedByExpandAllRef = useRef(new Set()); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded useEffect(() => { const currentPaths = new Set(directoryPaths); @@ -344,7 +350,7 @@ export default function FileBrowserPanel({ } }; loadExpanded(); - return model.subscribe(loadExpanded); + return subscribeOncePerTask(model, loadExpanded); }, [directoryPaths, load, model]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model useEffect(() => { @@ -405,8 +411,18 @@ export default function FileBrowserPanel({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- expands every directory in the Pierre tree model while expand-all is on and no search is active useEffect(() => { - if (expandAll && !query.trim()) - setAllDirectoriesExpanded(model, expandableDirectoryPaths, true); + if (!expandAll || query.trim()) { + expandedByExpandAllRef.current.clear(); + return; + } + // Only folders listed since the last pass; asking the tree about every folder each time is quadratic. + const previous = expandedByExpandAllRef.current; + setAllDirectoriesExpanded( + model, + expandableDirectoryPaths.filter((path) => !previous.has(path)), + true, + ); + expandedByExpandAllRef.current = new Set(expandableDirectoryPaths); }, [expandableDirectoryPaths, expandAll, model, query]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reveals an externally selected path in the Pierre tree model by expanding, selecting and scrolling diff --git a/apps/web/src/components/files/fileTreeExpansion.ts b/apps/web/src/components/files/fileTreeExpansion.ts index 221e62b64c962..b88946065a55c 100644 --- a/apps/web/src/components/files/fileTreeExpansion.ts +++ b/apps/web/src/components/files/fileTreeExpansion.ts @@ -28,6 +28,30 @@ function asDirectoryHandle(item: unknown): DirectoryHandle | null { return item as DirectoryHandle; } +/** + * Calls `listener` once per task, however many events the model emitted in it. Expanding every + * folder emits one event per folder, and a listener that rescans the folders is quadratic otherwise. + */ +export function subscribeOncePerTask( + model: { subscribe(listener: () => void): () => void }, + listener: () => void, +): () => void { + let queued = false; + let subscribed = true; + const unsubscribe = model.subscribe(() => { + if (queued) return; + queued = true; + queueMicrotask(() => { + queued = false; + if (subscribed) listener(); + }); + }); + return () => { + subscribed = false; + unsubscribe(); + }; +} + export function areAllDirectoriesExpanded( model: FileTreeExpansionModel, directoryPaths: readonly string[], From fa4c0b80d3ec86a8223001ce5cd26c9f3dd0b999 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:12:01 +0100 Subject: [PATCH 2/4] test(web): cover folders that arrive after Expand all --- .../files/FileBrowserPanel.browser.test.tsx | 90 +++++++++++++++---- 1 file changed, 72 insertions(+), 18 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 23f595ca8a079..b438523b82b07 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -5,7 +5,7 @@ import { page, userEvent } from "vite-plus/test/browser"; import { COMPOSER_MENTION_DRAG_TYPE } from "~/components/chat/composerMentionDrag"; import { dismissContextMenu } from "~/contextMenuFallback"; -import { render } from "../../../test/render"; +import { act, render } from "../../../test/render"; import FileBrowserPanel from "./FileBrowserPanel"; const entries: readonly ProjectEntry[] = [ @@ -13,15 +13,16 @@ const entries: readonly ProjectEntry[] = [ { path: "README.md", kind: "file" }, ]; const loadingDirectories: ReadonlySet = new Set(); -let directory = { +const defaultDirectory = { entries, - load: () => Promise.resolve(), + load: (_path: string) => Promise.resolve(), refresh: () => {}, ready: true, error: null, isPending: false, loadingDirectories, }; +let directory = defaultDirectory; const pathSearch = { entries: [], isPending: false, @@ -30,13 +31,35 @@ const pathSearch = { refresh: () => {}, }; -vi.mock("./useDirectoryEntries", () => ({ useDirectoryEntries: () => directory })); +const directoryListeners = new Set<() => void>(); +vi.mock("./useDirectoryEntries", async () => { + const { useSyncExternalStore } = await import("react"); + const subscribe = (listener: () => void) => { + directoryListeners.add(listener); + return () => void directoryListeners.delete(listener); + }; + return { useDirectoryEntries: () => useSyncExternalStore(subscribe, () => directory) }; +}); vi.mock("~/state/queries", () => ({ useProjectPathSearch: () => pathSearch })); vi.mock("~/fileContextMenu", () => ({ useFileContextMenu: () => ({ buildItems: () => [], activate: () => Promise.resolve() }), })); vi.mock("~/hooks/useWorkspaceMutationRefresh", () => ({ useWorkspaceMutationRefresh: () => {} })); +function Panel() { + return ( + {}} + workspaceMutationId={null} + /> + ); +} + function findTreeRow(path: string): HTMLElement | null { for (const host of document.querySelectorAll("*")) { const row = host.shadowRoot?.querySelector(`[data-item-path="${path}"]`); @@ -45,8 +68,14 @@ function findTreeRow(path: string): HTMLElement | null { return null; } +// The useDirectoryEntries mock reads `directory`, so every test starts from the default. +function setup() { + directory = defaultDirectory; +} + describe("FileBrowserPanel", () => { it("copies with the pointer and returns to the row after keyboard dismissal", async () => { + setup(); await using _screen = await render( { }); it("dragging a file row carries a composer mention without opening it, and dragend releases it", async () => { + setup(); const onOpenFile = vi.fn(); await using _screen = await render( { }); it("expands every folder without asking the tree about all folders for each one", async () => { + setup(); const folders: readonly ProjectEntry[] = Array.from({ length: 300 }, (_, index) => ({ path: `folder-${index}`, kind: "directory", })); - const load = vi.fn(() => Promise.resolve()); - const original = directory; + const load = vi.fn((_path: string) => Promise.resolve()); directory = { ...directory, entries: folders, load }; using getItem = vi.spyOn(FileTree.prototype, "getItem"); - using _restore = { [Symbol.dispose]: () => (directory = original) }; - await using _screen = await render( - {}} - workspaceMutationId={null} - />, - ); + await using _screen = await render(); const expandAll = page.getByRole("button", { name: "Expand all folders" }); await expect.element(expandAll).toBeVisible(); getItem.mockClear(); @@ -147,4 +166,39 @@ describe("FileBrowserPanel", () => { // Each of the 300 expansions used to look up all 300 folders again, about 137,000 lookups. expect(getItem.mock.calls.length).toBeLessThan(folders.length * 20); }); + + it("expands folders that arrive after Expand all without visiting the earlier ones again", async () => { + setup(); + const batches = 5; + const perBatch = 60; + const folder = (index: number): ProjectEntry => ({ + path: `folder-${index}`, + kind: "directory", + }); + const load = vi.fn((_path: string) => Promise.resolve()); + directory = { + ...directory, + entries: Array.from({ length: perBatch }, (_, i) => folder(i)), + load, + }; + using getItem = vi.spyOn(FileTree.prototype, "getItem"); + await using _screen = await render(); + await userEvent.click(page.getByRole("button", { name: "Expand all folders" })); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(perBatch)); + getItem.mockClear(); + // An asynchronous read returns: the listing grows by a batch of folders at a time. + for (let batch = 1; batch < batches; batch++) { + const total = (batch + 1) * perBatch; + directory = { ...directory, entries: Array.from({ length: total }, (_, i) => folder(i)) }; + await act(() => directoryListeners.forEach((listener) => listener())); + await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(total)); + } + const total = batches * perBatch; + await expect.element(page.getByRole("button", { name: "Collapse all folders" })).toBeVisible(); + // Every folder, early and late, loaded exactly once. + expect(new Set(load.mock.calls.map(([path]) => path)).size).toBe(total); + // About 7,100 lookups with the fix. Expanding all listed folders on each update instead of + // only the new ones adds 600, and asking the tree about all folders per expansion makes 100,000. + expect(getItem.mock.calls.length).toBeLessThan(7_400); + }); }); From dcd43a3ba534be8b3928d66a18672dd79ca387d8 Mon Sep 17 00:00:00 2001 From: Pyseph <45090858+Pyseph@users.noreply.github.com> Date: Thu, 5 Nov 2026 14:23:02 +0100 Subject: [PATCH 3/4] test(web): create the lookup spies after rendering They dispose before the unmount, as the test guidelines ask. Both tests clear them before the part they count. --- .../src/components/files/FileBrowserPanel.browser.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index b438523b82b07..a62982704006c 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -155,8 +155,8 @@ describe("FileBrowserPanel", () => { })); const load = vi.fn((_path: string) => Promise.resolve()); directory = { ...directory, entries: folders, load }; - using getItem = vi.spyOn(FileTree.prototype, "getItem"); await using _screen = await render(); + using getItem = vi.spyOn(FileTree.prototype, "getItem"); const expandAll = page.getByRole("button", { name: "Expand all folders" }); await expect.element(expandAll).toBeVisible(); getItem.mockClear(); @@ -181,8 +181,8 @@ describe("FileBrowserPanel", () => { entries: Array.from({ length: perBatch }, (_, i) => folder(i)), load, }; - using getItem = vi.spyOn(FileTree.prototype, "getItem"); await using _screen = await render(); + using getItem = vi.spyOn(FileTree.prototype, "getItem"); await userEvent.click(page.getByRole("button", { name: "Expand all folders" })); await vi.waitFor(() => expect(load).toHaveBeenCalledTimes(perBatch)); getItem.mockClear(); From f82da16e28d299cd20e17284cc3a5b9e3b44b66c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 15 Nov 2026 04:56:22 +0000 Subject: [PATCH 4/4] perf(web): Expand all stops rescanning every folder for each listing While Expand all is on and no search is active, every expandable folder stays open and a collapse turns Expand all off, so the button reads true without walking the tree. The expansion listener now subscribes once and reads the latest folders from a ref: rerunning it per listing scanned every folder a second time, next to the model's own notification for that listing. Co-Authored-By: Claude Opus 5.5 --- .../files/FileBrowserPanel.browser.test.tsx | 6 +++--- .../src/components/files/FileBrowserPanel.tsx | 16 ++++++++++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx index 047a484231b8b..095a80a1726f6 100644 --- a/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.browser.test.tsx @@ -300,9 +300,9 @@ describe("FileBrowserPanel", () => { } await expect.element(page.getByRole("button", { name: "Collapse all folders" })).toBeVisible(); expect(new Set(load.mock.calls.map(([path]) => path)).size).toBe(batches * perBatch); - // Expanding every listed folder on each update instead of only the new ones adds about 600 - // lookups, and asking the tree about all folders per expansion about 100,000. - expect(getItem.mock.calls.length).toBeLessThan(7_400); + // About 1,100 lookups. Rescanning every folder for the button state adds about 5,000, and a + // second scan of every folder per listing about 800. + expect(getItem.mock.calls.length).toBeLessThan(1_400); }); it("mod+f from a tree row, or the panel find command, focuses the file search", async () => { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 35ec92a51835e..abb1cda430342 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -316,7 +316,9 @@ export default function FileBrowserPanel({ const hasNoSearchMatches = search.value.trim().length > 0 && search.matchingPaths.length === 0; const allDirectoriesExpanded = useSyncExternalStore( (listener) => subscribeOncePerTask(model, listener), - () => areAllDirectoriesExpanded(model, expandableDirectoryPaths), + // The expand-all effect keeps every folder open, and collapsing one turns Expand all off. + () => + (expandAll && !query.trim()) || areAllDirectoriesExpanded(model, expandableDirectoryPaths), ); const toggleAllDirectories = () => { const expanded = !(expandAll || allDirectoriesExpanded); @@ -350,16 +352,22 @@ export default function FileBrowserPanel({ const expansionRoot = `${environmentId}:${cwd}`; // Folders expanded when the panel last unmounted; each reopens once lazy loading lists it. const [pendingExpandedPaths] = useState(() => new Set(readExpandedDirectories(expansionRoot))); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded + const directoryPathsRef = useRef(directoryPaths); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- forgets expanded folders that left the listing before the Pierre tree model notifies about them useEffect(() => { + directoryPathsRef.current = directoryPaths; const currentPaths = new Set(directoryPaths); for (const path of expandedPathsRef.current) { if (!currentPaths.has(path)) expandedPathsRef.current.delete(path); } + }, [directoryPaths]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to the Pierre tree model to load directories as they are expanded + useEffect(() => { + // Not rerun per listing: adding its paths to the model notifies this listener once. const loadExpanded = () => { if (model.isSearchOpen()) return; let changed = false; - for (const path of directoryPaths) { + for (const path of directoryPathsRef.current) { const item = model.getItem(path); if (item?.isDirectory() && pendingExpandedPaths.delete(path) && "expand" in item) { item.expand(); @@ -384,7 +392,7 @@ export default function FileBrowserPanel({ }; loadExpanded(); return subscribeOncePerTask(model, loadExpanded); - }, [directoryPaths, expansionRoot, load, model, pendingExpandedPaths]); + }, [expansionRoot, load, model, pendingExpandedPaths]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes ignored-file status into the imperative Pierre tree model useEffect(() => { model.setGitStatus(