fix(mongodb): keep collection results across tab switches
This commit is contained in:
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();
|
||||
}
|
||||
Reference in new issue
Block a user