fix(mongodb): keep collection results across tab switches

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-10 12:46:18 +08:00
1 parent 1e9a3c0f67
commit 087351f73f
11 files changed
+826 -47

No files matched your search

@@ -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();
});
});
@@ -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<string, unknown>[];
copyDocuments: Record<string, unknown>[];
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<string, DocumentBrowserStateSnapshot>();
const gridFsCache = new Map<string, GridFsBrowserStateSnapshot<unknown>>();
const closingKeys = new Set<string>();
/**
* 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<TRow> {
filterInput: string;
sortInput: string;
appliedStructuredFilter?: Record<string, unknown> | null;
filterRules?: DocumentFilterRule[];
page?: number;
selectedId?: string;
/** Identity + conditions the rows were listed under. */
signature?: string;
rows?: TRow[];
}
export function restoreGridFsBrowserState<TRow>(stateKey: string): GridFsBrowserStateSnapshot<TRow> | undefined {
const snapshot = gridFsCache.get(stateKey);
if (!snapshot) return undefined;
gridFsCache.delete(stateKey);
gridFsCache.set(stateKey, snapshot);
return snapshot as GridFsBrowserStateSnapshot<TRow>;
}
export function saveGridFsBrowserState<TRow>(stateKey: string, snapshot: GridFsBrowserStateSnapshot<TRow>): 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<unknown>);
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();
}