diff --git a/apps/desktop/src/components/sidebar/TreeItem.vue b/apps/desktop/src/components/sidebar/TreeItem.vue index 1116c06ba..73e864938 100644 --- a/apps/desktop/src/components/sidebar/TreeItem.vue +++ b/apps/desktop/src/components/sidebar/TreeItem.vue @@ -579,12 +579,14 @@ async function toggle() { const databaseObjectGroup = node.type === "group-tables" || node.type === "group-views" || node.type === "group-materialized-views" || node.type === "group-procedures" || node.type === "group-functions" || node.type === "group-sequences" || node.type === "group-packages"; if (databaseObjectGroup && connectionStore.isTreeNodeChildrenLoaded(node.id)) { node.isExpanded = !node.isExpanded; + if (wasExpanded) connectionStore.releaseCollapsedTreeNodeChildren(node.id); emit("node-toggled", node, wasExpanded); return; } if (node.isExpanded) { node.isExpanded = false; + connectionStore.releaseCollapsedTreeNodeChildren(node.id); emit("node-toggled", node, wasExpanded); return; } diff --git a/apps/desktop/src/stores/__tests__/connectionStore.treeMemory.spec.ts b/apps/desktop/src/stores/__tests__/connectionStore.treeMemory.spec.ts new file mode 100644 index 000000000..a762f8b35 --- /dev/null +++ b/apps/desktop/src/stores/__tests__/connectionStore.treeMemory.spec.ts @@ -0,0 +1,181 @@ +import { createPinia, setActivePinia } from "pinia"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { isReactive } from "vue"; +import type { ColumnInfo, ConnectionConfig, TreeNode } from "@/types/database"; + +function installLocalStorage() { + const data = new Map(); + vi.stubGlobal("localStorage", { + getItem: vi.fn((key: string) => data.get(key) ?? null), + setItem: vi.fn((key: string, value: string) => data.set(key, value)), + removeItem: vi.fn((key: string) => data.delete(key)), + }); +} + +function postgresConnection(): ConnectionConfig { + return { + id: "pg-1", + name: "Postgres", + db_type: "postgres", + host: "127.0.0.1", + port: 5432, + username: "postgres", + password: "", + database: "app", + } as ConnectionConfig; +} + +function columns(count: number): ColumnInfo[] { + return Array.from( + { length: count }, + (_, index) => + ({ + name: `col_${index}`, + data_type: "text", + is_nullable: true, + column_default: null, + is_primary_key: index === 0, + comment: null, + }) as unknown as ColumnInfo, + ); +} + +function findById(nodes: TreeNode[], id: string): TreeNode | undefined { + for (const node of nodes) { + if (node.id === id) return node; + if (node.children) { + const found = findById(node.children, id); + if (found) return found; + } + } + return undefined; +} + +const GROUP_ID = "pg-1:app:public:users:__columns"; + +async function setupStoreWithColumnGroup() { + vi.doMock("@/lib/backend/tauriRuntime", () => ({ isTauriRuntime: () => false })); + + async function loadStore(getColumns: ReturnType) { + vi.doMock("@/lib/backend/api", () => ({ + checkConnectionHealth: vi.fn().mockResolvedValue(undefined), + deleteSchemaCachePrefix: vi.fn().mockResolvedValue(undefined), + getColumns, + loadSchemaCache: vi.fn().mockResolvedValue(null), + saveSchemaCache: vi.fn().mockResolvedValue(undefined), + saveConnections: vi.fn().mockResolvedValue(undefined), + saveSidebarLayout: vi.fn().mockResolvedValue(undefined), + })); + + const { useConnectionStore } = await import("@/stores/connectionStore"); + const store = useConnectionStore(); + const connection = postgresConnection(); + store.connections = [connection]; + store.connectedIds.add(connection.id); + store.treeNodes = [ + { + id: connection.id, + label: connection.name, + type: "connection", + connectionId: connection.id, + isExpanded: true, + children: [ + { + id: "pg-1:app", + label: "app", + type: "database", + connectionId: connection.id, + database: "app", + isExpanded: true, + children: [ + { + id: "pg-1:app:public:users", + label: "users", + type: "table", + connectionId: connection.id, + database: "app", + schema: "public", + tableName: "users", + isExpanded: true, + children: [ + { + id: GROUP_ID, + label: "Columns", + type: "group-columns", + connectionId: connection.id, + database: "app", + schema: "public", + tableName: "users", + isExpanded: false, + children: [], + }, + ], + }, + ], + }, + ], + }, + ]; + return store; + } + + return { loadStore }; +} + +describe("connectionStore schema tree memory", () => { + beforeEach(() => { + vi.resetModules(); + vi.unstubAllGlobals(); + installLocalStorage(); + setActivePinia(createPinia()); + }); + + it("marks leaf column nodes raw so Vue does not deep-wrap them", async () => { + const { loadStore } = await setupStoreWithColumnGroup(); + const getColumns = vi.fn().mockResolvedValue(columns(5)); + const store = await loadStore(getColumns); + + await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID); + + const group = findById(store.treeNodes, GROUP_ID); + expect(group?.children).toHaveLength(5); + // The container group stays reactive so expand/collapse still drives the UI... + expect(isReactive(group!)).toBe(true); + // ...but every leaf column (and, transitively, its `meta`) is raw. + for (const leaf of group!.children ?? []) { + expect(isReactive(leaf)).toBe(false); + expect(isReactive(leaf.meta as object)).toBe(false); + } + }); + + it("releases a large collapsed subtree and reloads it on demand", async () => { + const { loadStore } = await setupStoreWithColumnGroup(); + const getColumns = vi.fn().mockResolvedValue(columns(500)); + const store = await loadStore(getColumns); + + await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID); + expect(findById(store.treeNodes, GROUP_ID)?.children).toHaveLength(500); + expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(true); + + const released = store.releaseCollapsedTreeNodeChildren(GROUP_ID); + + expect(released).toBe(true); + expect(findById(store.treeNodes, GROUP_ID)?.children).toEqual([]); + // Forgetting the loaded id is what makes a later expand reload the children. + expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(false); + }); + + it("keeps a small collapsed subtree so routine expand/collapse stays instant", async () => { + const { loadStore } = await setupStoreWithColumnGroup(); + const getColumns = vi.fn().mockResolvedValue(columns(20)); + const store = await loadStore(getColumns); + + await store.loadColumns("pg-1", "app", "users", "public", GROUP_ID); + + const released = store.releaseCollapsedTreeNodeChildren(GROUP_ID); + + expect(released).toBe(false); + expect(findById(store.treeNodes, GROUP_ID)?.children).toHaveLength(20); + expect(store.isTreeNodeChildrenLoaded(GROUP_ID)).toBe(true); + }); +}); diff --git a/apps/desktop/src/stores/connectionStore.ts b/apps/desktop/src/stores/connectionStore.ts index 0c136155f..fabf4e589 100644 --- a/apps/desktop/src/stores/connectionStore.ts +++ b/apps/desktop/src/stores/connectionStore.ts @@ -1,6 +1,6 @@ import { defineStore } from "pinia"; import { uuid } from "@/lib/common/utils"; -import { ref, computed, watch } from "vue"; +import { ref, computed, watch, markRaw } from "vue"; import type { ColumnInfo, CompletionAssistantCandidate, CompletionAssistantObjectKind, CompletionAssistantRequest, ConnectionConfig, CatalogInfo, ForeignKeyInfo, ObjectInfo, SchemaInfo, SidebarLayout, TableInfo, TreeNode, TunnelProfile, VectorCollectionMeta } from "@/types/database"; import { applyPinnedTreeNodeState, updatePinnedTreeNodeInPlace } from "@/lib/app/pinnedItems"; import { @@ -938,6 +938,28 @@ export const useConnectionStore = defineStore("connection", () => { return [...existingMetadataChildren, ...nextUtilityChildren]; } + // Leaf tree nodes (table columns / indexes / foreign keys / triggers) are + // immutable data payloads: they never expand, never load children, and their + // fields are never mutated after creation. A large schema can produce tens of + // thousands of them, and Vue's deep reactivity wraps every node AND its nested + // `meta` object in a Proxy — the dominant memory cost of the schema tree. + // Marking each leaf raw keeps Vue from wrapping it (and, since Vue does not + // recurse into raw objects, its `meta` too), mirroring the markRaw() treatment + // queryStore already applies to result rows. Containers stay reactive so their + // children / isExpanded / isLoading mutations still drive the UI. + const LEAF_TREE_NODE_TYPES = new Set(["column", "index", "fkey", "trigger"]); + + function markRawLeafTreeNodes(nodes: TreeNode[]): TreeNode[] { + for (const node of nodes) { + if (LEAF_TREE_NODE_TYPES.has(node.type)) { + markRaw(node); + } else if (node.children && node.children.length > 0) { + markRawLeafTreeNodes(node.children); + } + } + return nodes; + } + function setChildren(parent: TreeNode, children: TreeNode[]) { children = preserveExistingConnectionMetadataChildren(parent, children); if (parent.children && parent.children.length > 0) { @@ -950,7 +972,7 @@ export const useConnectionStore = defineStore("connection", () => { return child; }); } - parent.children = applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value); + parent.children = markRawLeafTreeNodes(applyPinnedTreeNodeState(children, pinnedTreeNodeIds.value)); loadedTreeNodeChildrenIds.value.add(parent.id); } @@ -1478,6 +1500,47 @@ export const useConnectionStore = defineStore("connection", () => { return loadedTreeNodeChildrenIds.value.has(nodeId); } + // Collapsing a node only hides it — its loaded children stay in memory, so a + // long browsing session accumulates every schema the user ever expanded and + // the webview creeps upward. When a *large* subtree is collapsed we drop its + // children so the memory is reclaimed; re-expanding reloads them (fast, from + // the schema cache). Small subtrees are kept so routine expand/collapse stays + // instant and never triggers a reload. + const RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS = 400; + + function countTreeNodeDescendants(node: TreeNode, cap: number): number { + let count = 0; + const stack: TreeNode[] = [...(node.children ?? [])]; + while (stack.length) { + const current = stack.pop()!; + count += 1; + if (count >= cap) return count; + if (current.children?.length) stack.push(...current.children); + } + return count; + } + + function forgetLoadedChildrenIdsForSubtree(node: TreeNode) { + loadedTreeNodeChildrenIds.value.delete(node.id); + for (const child of node.children ?? []) { + forgetLoadedChildrenIdsForSubtree(child); + } + } + + // Returns true when the collapsed node's children were released. Caller should + // have already set node.isExpanded = false. Re-expanding reloads on demand + // because the node id is removed from loadedTreeNodeChildrenIds. + function releaseCollapsedTreeNodeChildren(nodeId: string): boolean { + const node = findNode(treeNodes.value, nodeId); + if (!node?.children?.length) return false; + if (countTreeNodeDescendants(node, RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) < RELEASE_COLLAPSED_SUBTREE_MIN_DESCENDANTS) { + return false; + } + forgetLoadedChildrenIdsForSubtree(node); + node.children = []; + return true; + } + function canApplyTreeMetadataResult(node: TreeNode): boolean { if (findNode(treeNodes.value, node.id) !== node) return false; if (node.connectionId && !connectedIds.value.has(node.connectionId)) return false; @@ -5146,6 +5209,7 @@ export const useConnectionStore = defineStore("connection", () => { closeDatabaseConnection, ensureConnected, isTreeNodeChildrenLoaded, + releaseCollapsedTreeNodeChildren, setBeforeConnectHandler, initFromDisk, loadDatabases,