diff --git a/apps/desktop/src/components/document/DocumentBrowser.vue b/apps/desktop/src/components/document/DocumentBrowser.vue index bcfaf0bcbb..a7302f0685 100644 --- a/apps/desktop/src/components/document/DocumentBrowser.vue +++ b/apps/desktop/src/components/document/DocumentBrowser.vue @@ -1,5 +1,5 @@ diff --git a/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts b/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts index 9d8ef13b77..ed09734779 100644 --- a/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts +++ b/apps/desktop/src/components/document/__tests__/DocumentBrowserTabState.spec.ts @@ -17,7 +17,12 @@ const backend = vi.hoisted(() => ({ const dataGrid = vi.hoisted(() => ({ paginate: undefined as ((offset: number, limit: number) => Promise) | undefined, sort: undefined as ((column: string, columnIndex: number, direction: "asc" | "desc" | null) => void) | undefined, + reload: undefined as (() => Promise) | undefined, pageOffset: undefined as number | undefined, + rowCount: undefined as number | undefined, + columnCount: undefined as number | undefined, + viewStateKey: undefined as string | undefined, + viewGeneration: undefined as string | undefined, })); const settings = vi.hoisted(() => ({ @@ -76,10 +81,13 @@ vi.mock("@/components/grid/DataGrid.vue", () => { pageOffset: { type: Number, required: false }, pageLimit: { type: Number, required: false }, pageSizePreference: { type: String, required: false }, + viewStateKey: { type: String, required: false }, + viewGeneration: { type: String, required: false }, }, setup(props, { attrs, expose, slots }) { dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate; dataGrid.sort = attrs.onSort as typeof dataGrid.sort; + dataGrid.reload = attrs.onReload as typeof dataGrid.reload; expose({ visibleColumnCount: 2, displayableColumnCount: 2, @@ -102,6 +110,10 @@ vi.mock("@/components/grid/DataGrid.vue", () => { }); return () => { dataGrid.pageOffset = props.pageOffset; + dataGrid.viewStateKey = props.viewStateKey; + dataGrid.viewGeneration = props.viewGeneration; + dataGrid.rowCount = (props.result as { rows: unknown[] }).rows.length; + dataGrid.columnCount = (props.result as { columns: unknown[] }).columns.length; return h("div", [ // Rendering the real search-bar slot exposes the filter/sort inputs // the same way the actual grid toolbar does. @@ -151,6 +163,7 @@ vi.mock("@/components/ui/select", async () => { }); import DocumentBrowser from "@/components/document/DocumentBrowser.vue"; +import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache"; let app: App | null = null; let root: HTMLDivElement | null = null; @@ -214,8 +227,15 @@ beforeEach(async () => { backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 2)); dataGrid.paginate = undefined; dataGrid.sort = undefined; + dataGrid.reload = undefined; dataGrid.pageOffset = undefined; + dataGrid.rowCount = undefined; + dataGrid.columnCount = undefined; + dataGrid.viewStateKey = undefined; + dataGrid.viewGeneration = undefined; settings.editorSettings.infiniteScroll = false; + settings.editorSettings.tableOpenPageSize = 2; + resetBrowserStateCaches(); root = document.createElement("div"); document.body.appendChild(root); @@ -253,14 +273,20 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => { await flushUi(); await mountBrowser({ stateKey: "tab-filter-sort" }); - expect(backend.documentFindDocuments).toHaveBeenCalledTimes(4); - const restoredCall = backend.documentFindDocuments.mock.calls[3]!; - expect(restoredCall[5]).toBe(JSON.stringify({ name: "row_1" })); - expect(restoredCall[7]).toBe(JSON.stringify({ name: 1 })); + // The remount replays the cached page instead of re-querying (#8679). + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3); const [restoredFilter, restoredSort] = queryTextareas(); expect(restoredFilter.value).toBe('{"name":"row_1"}'); expect(restoredSort.value).toBe(JSON.stringify({ name: 1 })); + + // The restored conditions are live, not merely cosmetic: the next query + // built from them still carries the filter and the new sort direction. + dataGrid.sort!("name", 1, "desc"); + await flushUi(); + const nextCall = backend.documentFindDocuments.mock.calls[3]!; + expect(nextCall[5]).toBe(JSON.stringify({ name: "row_1" })); + expect(nextCall[7]).toBe(JSON.stringify({ name: -1 })); }); it("restores the page position for skip-based stores", async () => { @@ -280,9 +306,15 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => { await flushUi(); await mountBrowser({ stateKey: "tab-page" }); - expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3); - expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(2); + // Restored from cache: the page position survives with no extra query. + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); expect(dataGrid.pageOffset).toBe(2); + + // Paging still works from the restored page. + await dataGrid.paginate!(0, 2); + await flushUi(); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3); + expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(0); }); it("restarts from the first page for cursor-based stores (elasticsearch)", async () => { @@ -307,6 +339,127 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => { expect(restoredCall[10]).toBeUndefined(); }); + it("does not re-query the collection when a tab switch remounts a mongodb browser", async () => { + await mountBrowser({ stateKey: "tab-no-reload" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + expect(dataGrid.rowCount).toBe(2); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-no-reload" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + expect(dataGrid.rowCount).toBe(2); + }); + + it("still forces a real reload from the refresh button after a restore", async () => { + await mountBrowser({ stateKey: "tab-refresh" }); + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-refresh" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + + await dataGrid.reload!(); + await flushUi(); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); + }); + + it("re-queries when the page size changed while the tab was inactive", async () => { + await mountBrowser({ stateKey: "tab-page-size" }); + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + + settings.editorSettings.tableOpenPageSize = 10; + await mountBrowser({ stateKey: "tab-page-size" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); + }); + + it("re-queries when the previous load failed", async () => { + backend.documentFindDocuments.mockReset(); + backend.documentFindDocuments.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(documentResult(1, 2, 2)); + + await mountBrowser({ stateKey: "tab-error" }); + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-error" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); + }); + + it("keeps a loaded-but-empty collection distinguishable from a never-loaded one across a remount", async () => { + backend.documentFindDocuments.mockReset(); + backend.documentFindDocuments.mockResolvedValue({ documents: [], total: 0, total_is_exact: true }); + + await mountBrowser({ stateKey: "tab-empty" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + const loadedColumnCount = dataGrid.columnCount; + expect(loadedColumnCount).toBeGreaterThan(0); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-empty" }); + + // No reload, and the grid still reads as "a load has completed" so the + // refresh toolbar stays available (see the empty-collection behavior). + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + expect(dataGrid.columnCount).toBe(loadedColumnCount); + }); + + it("re-queries a cursor-based store on remount", async () => { + backend.documentFindDocuments.mockReset(); + backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 6)); + + await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" }); + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" }); + + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2); + }); + + it("hands the grid a stable view identity so scroll and selection survive the switch", async () => { + await mountBrowser({ stateKey: "tab-view-state" }); + expect(dataGrid.viewStateKey).toBe("tab-view-state"); + const generation = dataGrid.viewGeneration; + expect(generation).toBeTruthy(); + + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mountBrowser({ stateKey: "tab-view-state" }); + + // Same logical result, so DataGrid may replay its cached viewport. + expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1); + expect(dataGrid.viewGeneration).toBe(generation); + }); + + it("mints a new view identity when the data is actually reloaded", async () => { + await mountBrowser({ stateKey: "tab-view-generation" }); + const generation = dataGrid.viewGeneration; + + await dataGrid.reload!(); + await flushUi(); + + // A replacement dataset must not adopt the previous viewport. + expect(dataGrid.viewGeneration).not.toBe(generation); + }); + it("keeps tab states isolated from each other", async () => { await mountBrowser({ stateKey: "tab-a" }); dataGrid.sort!("name", 1, "asc"); diff --git a/apps/desktop/src/components/document/__tests__/MongoGridFsTabState.spec.ts b/apps/desktop/src/components/document/__tests__/MongoGridFsTabState.spec.ts new file mode 100644 index 0000000000..295c487893 --- /dev/null +++ b/apps/desktop/src/components/document/__tests__/MongoGridFsTabState.spec.ts @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, nextTick, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const backend = vi.hoisted(() => ({ + documentListGridFsBuckets: vi.fn(), + documentListGridFsFiles: vi.fn(), +})); + +const settings = vi.hoisted(() => ({ + editorSettings: { + pageSize: 50, + tableOpenPageSize: 50, + infiniteScroll: false, + tableFontFamily: "system-ui", + tableFontSize: 12, + }, + updateEditorSettings: vi.fn(), +})); + +vi.mock("vue-i18n", async (importOriginal) => ({ + ...(await importOriginal()), + useI18n: () => ({ t: (key: string) => key }), +})); + +vi.mock("@/lib/backend/api", () => ({ + documentListGridFsBuckets: backend.documentListGridFsBuckets, + documentListGridFsFiles: backend.documentListGridFsFiles, + documentCreateGridFsBucket: vi.fn(), + documentDeleteGridFsBucket: vi.fn(), + documentUploadGridFsFile: vi.fn(), + documentDownloadGridFsFile: vi.fn(), + documentDeleteGridFsFile: vi.fn(), +})); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ getConfig: () => ({}), ensureConnected: vi.fn() }), +})); + +vi.mock("@/stores/queryStore", () => ({ + useQueryStore: () => ({ openMongoBucket: vi.fn() }), +})); + +vi.mock("@/stores/settingsStore", () => ({ + TABLE_FONT_SIZE_MIN: 8, + TABLE_FONT_SIZE_MAX: 16, + useSettingsStore: () => settings, +})); + +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); + +function passthroughStub(name: string) { + return defineComponent({ + name, + inheritAttrs: false, + setup(_, { slots }) { + return () => h("div", slots.default?.()); + }, + }); +} + +vi.mock("@/components/ui/popover", () => ({ + Popover: passthroughStub("PopoverStub"), + PopoverTrigger: passthroughStub("PopoverTriggerStub"), + PopoverContent: passthroughStub("PopoverContentStub"), +})); + +vi.mock("@/components/ui/select", () => ({ + Select: passthroughStub("SelectStub"), + SelectContent: passthroughStub("SelectContentStub"), + SelectItem: passthroughStub("SelectItemStub"), + SelectTrigger: passthroughStub("SelectTriggerStub"), + SelectValue: passthroughStub("SelectValueStub"), +})); + +import MongoGridFsBrowser from "@/components/document/MongoGridFsBrowser.vue"; +import MongoBucketBrowser from "@/components/document/MongoBucketBrowser.vue"; +import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache"; + +let app: App | null = null; +let root: HTMLDivElement | null = null; + +async function flushUi() { + for (let index = 0; index < 4; index++) { + await Promise.resolve(); + await nextTick(); + } +} + +async function mount(component: unknown, props: Record) { + app = createApp(component as Parameters[0], props); + app.mount(root!); + await flushUi(); +} + +async function remount(component: unknown, props: Record) { + app!.unmount(); + app = null; + root!.replaceChildren(); + await flushUi(); + await mount(component, props); +} + +beforeEach(() => { + vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined, removeItem: () => undefined }); + backend.documentListGridFsBuckets.mockReset(); + backend.documentListGridFsFiles.mockReset(); + backend.documentListGridFsBuckets.mockResolvedValue([{ name: "fs", fileCount: 2, totalBytes: 100 }]); + backend.documentListGridFsFiles.mockResolvedValue([ + { id: "1", filename: "a.txt", length: 10, chunkSize: 1, uploadDate: "2026-01-01T00:00:00Z", metadata: null }, + { id: "2", filename: "b.txt", length: 20, chunkSize: 1, uploadDate: "2026-01-02T00:00:00Z", metadata: null }, + ]); + resetBrowserStateCaches(); + + root = document.createElement("div"); + document.body.appendChild(root); +}); + +afterEach(() => { + app?.unmount(); + root?.remove(); + app = null; + root = null; + vi.unstubAllGlobals(); +}); + +describe("MongoGridFsBrowser tab state", () => { + const props = { connectionId: "conn-1", database: "test", stateKey: "gridfs-tab" }; + + it("does not re-list buckets when a tab switch remounts the browser", async () => { + await mount(MongoGridFsBrowser, props); + expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1); + + await remount(MongoGridFsBrowser, props); + expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1); + }); + + it("re-lists when no state key ties the tab to a snapshot", async () => { + await mount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" }); + await remount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" }); + + expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2); + }); + + it("re-lists when the previous load failed", async () => { + backend.documentListGridFsBuckets.mockReset(); + backend.documentListGridFsBuckets.mockRejectedValueOnce(new Error("boom")).mockResolvedValue([]); + + await mount(MongoGridFsBrowser, props); + await remount(MongoGridFsBrowser, props); + + expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2); + }); +}); + +describe("MongoBucketBrowser tab state", () => { + const props = { connectionId: "conn-1", database: "test", bucket: "fs", stateKey: "bucket-tab" }; + + it("does not re-list files when a tab switch remounts the browser", async () => { + await mount(MongoBucketBrowser, props); + expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1); + + await remount(MongoBucketBrowser, props); + expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1); + }); + + it("re-lists when the bucket changed", async () => { + await mount(MongoBucketBrowser, props); + await remount(MongoBucketBrowser, { ...props, bucket: "other" }); + + expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2); + }); + + it("keeps the filter condition across a remount and re-lists under it", async () => { + await mount(MongoBucketBrowser, props); + + const filterInput = root!.querySelector('input[placeholder="{}"]'); + expect(filterInput).not.toBeNull(); + filterInput!.value = '{"filename":"a.txt"}'; + filterInput!.dispatchEvent(new Event("input", { bubbles: true })); + filterInput!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true })); + await flushUi(); + expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2); + + await remount(MongoBucketBrowser, props); + + // The condition survives, and the cached listing means no third request. + const restoredFilter = root!.querySelector('input[placeholder="{}"]'); + expect(restoredFilter?.value).toBe('{"filename":"a.txt"}'); + expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index ca38bd1ed0..257cb9b1b8 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -488,6 +488,13 @@ interface DataGridProps { cacheKey?: string; columnWidthCacheKey?: string; pendingStateKey?: string; + /** + * Owner key for the tab-switch view snapshot. Defaults to `cacheKey`, which + * is what query and data tabs use. Document tabs (MongoDB collections) set it + * on its own because `cacheKey` also scopes structured filters, column widths + * and pending edits, and they must keep those scopes as they are (#8679). + */ + viewStateKey?: string; /** * Logical-result identity (`QueryTab.resultViewGeneration`) the grid is * currently rendering. The tab-switch view snapshot is captured with this @@ -4970,6 +4977,9 @@ function cancelViewSnapshotRestoreFrame() { } /** Bounded integrity probe of the result the grid is currently rendering. */ +/** Snapshot owner; falls back to `cacheKey` for query and data tabs. */ +const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined); + function currentViewProbe(): string { const rows = props.result.rows; return buildDataGridViewProbe({ @@ -5096,13 +5106,14 @@ function expandRowRange(range: readonly number[], displayCount: number): number[ */ function captureTabSwitchViewSnapshot() { if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return; - if (!props.cacheKey || !props.viewGeneration) return; + const ownerKey = viewSnapshotOwnerKey.value; + if (!ownerKey || !props.viewGeneration) return; if (showTranspose.value) return; const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement(); if (!scroller) return; const { selection, droppedSelection } = captureViewSelection(); saveDataGridViewSnapshot({ - ownerKey: props.cacheKey, + ownerKey, viewGeneration: props.viewGeneration, probe: currentViewProbe(), renderer: useCanvasGridRows.value ? "canvas" : "dom", @@ -5122,7 +5133,7 @@ function captureTabSwitchViewSnapshot() { function restoreTabSwitchViewSnapshot() { if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return; if (!structuredFilterHydrationReady.value) return; - const ownerKey = props.cacheKey; + const ownerKey = viewSnapshotOwnerKey.value; if (!ownerKey || !props.viewGeneration) return; if (showTranspose.value) return; const snapshot = peekDataGridViewSnapshot(ownerKey); diff --git a/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts index 0160b6882b..e85f03ab58 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridViewSnapshotRestore.spec.ts @@ -17,13 +17,21 @@ describe("DataGrid tab-switch view snapshots", () => { const restoreSource = functionSource("restoreTabSwitchViewSnapshot", "const multiRowCount = computed("); it("captures only with an owner key and a generation, and skips transpose", () => { - expect(captureFnSource).toContain("if (!props.cacheKey || !props.viewGeneration) return;"); + expect(captureFnSource).toContain("if (!ownerKey || !props.viewGeneration) return;"); expect(captureFnSource).toContain("if (showTranspose.value) return;"); - expect(captureFnSource).toContain("ownerKey: props.cacheKey"); + expect(captureFnSource).toContain("ownerKey,"); expect(captureFnSource).toContain("viewGeneration: props.viewGeneration"); expect(captureFnSource).toContain("probe: currentViewProbe()"); }); + it("takes the snapshot owner from viewStateKey, falling back to cacheKey", () => { + // Document tabs need their own owner key: cacheKey also scopes structured + // filters, column widths and pending edits, which they leave untouched. + expect(dataGridSource).toContain("const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);"); + expect(captureFnSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;"); + expect(restoreSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;"); + }); + it("captures the selection in O(selection size), never through the refresh identity scan", () => { // Copies refs directly instead of captureCurrentSelectionForRefresh, which // builds an identity token for every row (O(row count) on a large result). diff --git a/apps/desktop/src/components/layout/ContentArea.vue b/apps/desktop/src/components/layout/ContentArea.vue index 719e9cd6c0..e6ce19cf53 100644 --- a/apps/desktop/src/components/layout/ContentArea.vue +++ b/apps/desktop/src/components/layout/ContentArea.vue @@ -2427,13 +2427,13 @@ defineExpose({ diff --git a/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts b/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts index 32429dacdc..f598ffd6a4 100644 --- a/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts +++ b/apps/desktop/src/lib/__tests__/tabs/documentBrowserStateCache.spec.ts @@ -1,5 +1,18 @@ -import { describe, expect, it } from "vitest"; -import { clearDocumentBrowserState, restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserStateSnapshot } from "@/lib/tabs/documentBrowserStateCache"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + beginClosingBrowserState, + clearDocumentBrowserState, + clearGridFsBrowserState, + MAX_DATA_ENTRIES, + MAX_RESTORED_DOCUMENT_ROWS, + resetBrowserStateCaches, + restoreDocumentBrowserState, + restoreGridFsBrowserState, + saveDocumentBrowserState, + saveGridFsBrowserState, + type DocumentBrowserDataSnapshot, + type DocumentBrowserStateSnapshot, +} from "@/lib/tabs/documentBrowserStateCache"; function snapshot(page: number): DocumentBrowserStateSnapshot { return { @@ -11,6 +24,25 @@ function snapshot(page: number): DocumentBrowserStateSnapshot { }; } +function data(rowCount: number, signature = "sig"): DocumentBrowserDataSnapshot { + return { + signature, + documents: Array.from({ length: rowCount }, (_, index) => ({ _id: String(index) })), + copyDocuments: [], + copyDocumentsAvailable: false, + gridColumns: ["_id"], + gridColumnTypes: ["objectId"], + total: rowCount, + totalIsExact: true, + paginationTotal: rowCount, + selectedIdx: null, + }; +} + +beforeEach(() => { + resetBrowserStateCaches(); +}); + describe("documentBrowserStateCache", () => { it("round-trips a snapshot for the same key", () => { saveDocumentBrowserState("round-trip", snapshot(3)); @@ -56,4 +88,75 @@ describe("documentBrowserStateCache", () => { expect(restoreDocumentBrowserState("clear-me")).toBeUndefined(); }); + + it("round-trips a row payload alongside the conditions", () => { + saveDocumentBrowserState("with-data", { ...snapshot(1), data: data(2) }); + + expect(restoreDocumentBrowserState("with-data")?.data?.documents).toHaveLength(2); + }); + + it("drops a stored payload on a conditions-only save", () => { + saveDocumentBrowserState("drop-data", { ...snapshot(1), data: data(2) }); + saveDocumentBrowserState("drop-data", snapshot(1)); + + expect(restoreDocumentBrowserState("drop-data")?.data).toBeUndefined(); + }); + + it("drops a payload larger than the row bound but keeps the conditions", () => { + saveDocumentBrowserState("too-big", { ...snapshot(2), data: data(MAX_RESTORED_DOCUMENT_ROWS + 1) }); + + const restored = restoreDocumentBrowserState("too-big"); + expect(restored?.data).toBeUndefined(); + expect(restored?.filterInput).toBe(snapshot(2).filterInput); + }); + + it("keeps payloads only for the most recently used tabs", () => { + for (let index = 0; index < MAX_DATA_ENTRIES + 2; index += 1) { + saveDocumentBrowserState(`payload-${index}`, { ...snapshot(index), data: data(1) }); + } + + expect(restoreDocumentBrowserState("payload-0")?.data).toBeUndefined(); + expect(restoreDocumentBrowserState("payload-0")?.filterInput).toBe(snapshot(0).filterInput); + expect(restoreDocumentBrowserState(`payload-${MAX_DATA_ENTRIES + 1}`)?.data).toBeDefined(); + }); + + it("blocks the unmount capture from resurrecting a closed tab", () => { + saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) }); + beginClosingBrowserState("closing"); + // DocumentBrowser unmounts after the store removed the tab and writes again. + saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) }); + + expect(restoreDocumentBrowserState("closing")).toBeUndefined(); + }); +}); + +describe("gridFsBrowserStateCache", () => { + it("round-trips conditions and rows", () => { + saveGridFsBrowserState("gridfs-a", { filterInput: "{}", sortInput: "", signature: "sig", rows: [{ name: "fs" }] }); + + const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-a"); + expect(restored?.rows).toEqual([{ name: "fs" }]); + expect(restored?.signature).toBe("sig"); + }); + + it("drops an oversized listing but keeps the conditions", () => { + const rows = Array.from({ length: MAX_RESTORED_DOCUMENT_ROWS + 1 }, (_, index) => ({ name: String(index) })); + saveGridFsBrowserState("gridfs-big", { filterInput: "{}", sortInput: "", signature: "sig", rows }); + + const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-big"); + expect(restored?.rows).toBeUndefined(); + expect(restored?.signature).toBeUndefined(); + expect(restored?.filterInput).toBe("{}"); + }); + + it("clears a key on demand and on close", () => { + saveGridFsBrowserState("gridfs-clear", { filterInput: "", sortInput: "" }); + clearGridFsBrowserState("gridfs-clear"); + expect(restoreGridFsBrowserState("gridfs-clear")).toBeUndefined(); + + saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" }); + beginClosingBrowserState("gridfs-closing"); + saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" }); + expect(restoreGridFsBrowserState("gridfs-closing")).toBeUndefined(); + }); }); diff --git a/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts b/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts index d6bc98c9a2..b1c51acb16 100644 --- a/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts +++ b/apps/desktop/src/lib/tabs/documentBrowserStateCache.ts @@ -1,5 +1,27 @@ import type { DocumentFilterRule } from "@/lib/app/documentStoreProvider"; +/** + * Rows and totals a completed load produced, replayed on remount instead of + * re-querying. `signature` pins the identity and conditions they were loaded + * under (store kind, connection/database/collection, filter, sort, page, page + * size, infinite-scroll mode); a restore that does not match it falls through + * to a normal load. + */ +export interface DocumentBrowserDataSnapshot { + signature: string; + /** DataGrid's logical-result identity, so it may replay scroll and selection. */ + viewGeneration?: string; + documents: Record[]; + copyDocuments: Record[]; + copyDocumentsAvailable: boolean; + gridColumns: string[]; + gridColumnTypes: string[]; + total?: number; + totalIsExact: boolean; + paginationTotal?: number; + selectedIdx: number | null; +} + export interface DocumentBrowserStateSnapshot { /** Manual JSON filter text from the collection filter input. */ filterInput: string; @@ -11,15 +33,55 @@ export interface DocumentBrowserStateSnapshot { documentFilterRules: DocumentFilterRule[]; /** Zero-based page position; only meaningful for skip-based stores. */ page: number; + /** + * Rows from the last completed load. Written only by the unmount capture; a + * conditions-only save (the keystroke path) drops it, which is the whole + * invalidation story — a dropped payload degrades to today's reload. + */ + data?: DocumentBrowserDataSnapshot; } // ContentArea renders only the active tab, so DocumentBrowser is unmounted on -// every tab switch and remounted when the user comes back. Query conditions -// survive that round trip through this session-scoped cache keyed by tab id — -// the same role tab.whereInput/tab.orderByInput play for SQL data tabs. The -// bound keeps state for long tab sessions from accumulating forever. +// every tab switch and remounted when the user comes back. Query conditions and +// the rows they produced survive that round trip through this session-scoped +// cache keyed by tab id — the same role tab.whereInput/tab.orderByInput and +// tab.result play for SQL data tabs, which have no equivalent here. The bounds +// keep state for long tab sessions from accumulating forever. const MAX_ENTRIES = 32; +/** Payloads larger than this are dropped; the tab reloads instead. */ +export const MAX_RESTORED_DOCUMENT_ROWS = 2_000; +/** Only the most recently used tabs keep rows; older ones keep conditions. */ +export const MAX_DATA_ENTRIES = 8; + const cache = new Map(); +const gridFsCache = new Map>(); +const closingKeys = new Set(); + +/** + * Clear a tab's cached state and block new writes for a short window, so the + * `onBeforeUnmount` capture cannot resurrect an entry after the tab is closed. + * Mirrors `beginClosingDataGridViewSnapshotsForTab`. + */ +export function beginClosingBrowserState(stateKey: string): void { + closingKeys.add(stateKey); + // Some test environments stub `window` without timers, so a bare + // `window.setTimeout` would throw while closing a tab. + const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout; + schedule(() => closingKeys.delete(stateKey), 5000); + cache.delete(stateKey); + gridFsCache.delete(stateKey); +} + +function trimDataPayloads(): void { + const keysNewestFirst = [...cache.keys()].reverse(); + let kept = 0; + for (const key of keysNewestFirst) { + const entry = cache.get(key); + if (!entry?.data) continue; + kept += 1; + if (kept > MAX_DATA_ENTRIES) cache.set(key, { ...entry, data: undefined }); + } +} export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserStateSnapshot | undefined { const snapshot = cache.get(stateKey); @@ -31,15 +93,67 @@ export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserSt } export function saveDocumentBrowserState(stateKey: string, snapshot: DocumentBrowserStateSnapshot): void { + if (closingKeys.has(stateKey)) return; + const bounded = snapshot.data && snapshot.data.documents.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, data: undefined } : snapshot; cache.delete(stateKey); - cache.set(stateKey, snapshot); + cache.set(stateKey, bounded); while (cache.size > MAX_ENTRIES) { const oldest = cache.keys().next().value; if (oldest === undefined) break; cache.delete(oldest); } + trimDataPayloads(); } export function clearDocumentBrowserState(stateKey: string): void { cache.delete(stateKey); } + +/** + * GridFS bucket and file listings reload on every remount for the same reason + * DocumentBrowser does, and never got the per-tab condition cache at all. Their + * payloads are small metadata rows, so one entry per tab carries both the query + * conditions and the listing. + */ +export interface GridFsBrowserStateSnapshot { + filterInput: string; + sortInput: string; + appliedStructuredFilter?: Record | null; + filterRules?: DocumentFilterRule[]; + page?: number; + selectedId?: string; + /** Identity + conditions the rows were listed under. */ + signature?: string; + rows?: TRow[]; +} + +export function restoreGridFsBrowserState(stateKey: string): GridFsBrowserStateSnapshot | undefined { + const snapshot = gridFsCache.get(stateKey); + if (!snapshot) return undefined; + gridFsCache.delete(stateKey); + gridFsCache.set(stateKey, snapshot); + return snapshot as GridFsBrowserStateSnapshot; +} + +export function saveGridFsBrowserState(stateKey: string, snapshot: GridFsBrowserStateSnapshot): void { + if (closingKeys.has(stateKey)) return; + const bounded = snapshot.rows && snapshot.rows.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, rows: undefined, signature: undefined } : snapshot; + gridFsCache.delete(stateKey); + gridFsCache.set(stateKey, bounded as GridFsBrowserStateSnapshot); + while (gridFsCache.size > MAX_ENTRIES) { + const oldest = gridFsCache.keys().next().value; + if (oldest === undefined) break; + gridFsCache.delete(oldest); + } +} + +export function clearGridFsBrowserState(stateKey: string): void { + gridFsCache.delete(stateKey); +} + +/** Test seam: drop everything. */ +export function resetBrowserStateCaches(): void { + cache.clear(); + gridFsCache.clear(); + closingKeys.clear(); +} diff --git a/apps/desktop/src/stores/queryStore.ts b/apps/desktop/src/stores/queryStore.ts index 547718e84c..a81976ba66 100644 --- a/apps/desktop/src/stores/queryStore.ts +++ b/apps/desktop/src/stores/queryStore.ts @@ -62,6 +62,7 @@ import { classifySqlRisk } from "@/lib/sql/sqlRisk"; import { externalSqlFileDisplayTitles, normalizeExternalSqlPath } from "@/lib/sql/sqlFileOpen"; import { clearDataGridPendingSnapshot, clearDataGridPendingSnapshotsForTab } from "@/composables/useDataGridEditor"; import { beginClosingDataGridViewSnapshotsForTab, clearDataGridViewSnapshot, clearDataGridViewSnapshotsForTab } from "@/lib/dataGrid/dataGridViewStateCache"; +import { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache"; import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence"; import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence"; import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache"; @@ -3428,6 +3429,7 @@ export const useQueryStore = defineStore("query", () => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(id); beginClosingDataGridViewSnapshotsForTab(id); + beginClosingBrowserState(id); clearDataGridStructuredFilterStatesForTab(id); clearDataGridSearchStatesForTab(id); if (tabs.value[idx].txnSessionId) void rollbackTransaction(id); @@ -3790,6 +3792,7 @@ export const useQueryStore = defineStore("query", () => { if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id); clearDataGridPendingSnapshotsForTab(tab.id); beginClosingDataGridViewSnapshotsForTab(tab.id); + beginClosingBrowserState(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id); if (tab.txnSessionId) void rollbackTransaction(tab.id); @@ -4009,6 +4012,7 @@ export const useQueryStore = defineStore("query", () => { rollbackTabTransaction(tab, { resetAutoCommit: true }); clearDataGridPendingSnapshotsForTab(tab.id); beginClosingDataGridViewSnapshotsForTab(tab.id); + beginClosingBrowserState(tab.id); clearDataGridStructuredFilterStatesForTab(tab.id); clearDataGridSearchStatesForTab(tab.id); if (tab.isExecuting) void cancelTabExecution(tab.id);