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);