feat(grid): preserve data-grid viewport and selection across tab switches
This commit is contained in:
1 parent
2b26bd6a1a
commit
41b5e51dcc
21 files changed
+1231
-8
No files matched your search
@@ -0,0 +1,206 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
DATA_GRID_VIEW_SNAPSHOT_RESTORE,
|
||||
MAX_CACHE_BYTES,
|
||||
MAX_SNAPSHOT_BYTES,
|
||||
MAX_SPARSE_CELL_ENTRIES,
|
||||
beginClosingDataGridViewSnapshotsForTab,
|
||||
buildDataGridViewProbe,
|
||||
clampDataGridViewSelection,
|
||||
clearDataGridViewSnapshot,
|
||||
clearDataGridViewSnapshotsForTab,
|
||||
dataGridViewSnapshotCacheBytes,
|
||||
dataGridViewSnapshotCacheSize,
|
||||
peekDataGridViewSnapshot,
|
||||
resetDataGridViewSnapshots,
|
||||
saveDataGridViewSnapshot,
|
||||
shouldNotifyOverBudgetSelection,
|
||||
type DataGridViewSelectionSnapshot,
|
||||
type DataGridViewSnapshot,
|
||||
} from "@/lib/dataGrid/dataGridViewStateCache";
|
||||
|
||||
function snapshot(ownerKey: string, patch: Partial<DataGridViewSnapshot> = {}): DataGridViewSnapshot {
|
||||
return {
|
||||
ownerKey,
|
||||
viewGeneration: "gen-1",
|
||||
probe: "probe",
|
||||
renderer: "dom",
|
||||
rowCount: 10,
|
||||
columnCount: 2,
|
||||
viewport: { top: 100, left: 0 },
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
function sparseCellSelection(count: number): DataGridViewSelectionSnapshot {
|
||||
return { kind: "cells", cellKeys: Array.from({ length: count }, (_, index) => `${index}:${index}`) };
|
||||
}
|
||||
|
||||
describe("dataGridViewStateCache", () => {
|
||||
beforeEach(() => {
|
||||
resetDataGridViewSnapshots();
|
||||
});
|
||||
|
||||
it("exposes an enabled rollback switch by default", () => {
|
||||
expect(DATA_GRID_VIEW_SNAPSHOT_RESTORE).toBe(true);
|
||||
});
|
||||
|
||||
it("round-trips a snapshot and refreshes LRU recency on peek", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-1-0"));
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-1-0")?.viewport.top).toBe(100);
|
||||
expect(peekDataGridViewSnapshot("missing")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("caps the cache at 32 owners and evicts the least recently used", () => {
|
||||
for (let index = 0; index < 33; index += 1) saveDataGridViewSnapshot(snapshot(`tab-${index}`));
|
||||
|
||||
expect(dataGridViewSnapshotCacheSize()).toBe(32);
|
||||
expect(peekDataGridViewSnapshot("tab-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-32")).toBeDefined();
|
||||
});
|
||||
|
||||
it("measures size in bytes, not UTF-16 code units", () => {
|
||||
// A selection of multi-byte characters: byte length exceeds code-unit count.
|
||||
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 300 }, () => "格:式") };
|
||||
const stored = snapshot("tab-1", { selection });
|
||||
|
||||
saveDataGridViewSnapshot(stored);
|
||||
|
||||
// ~300 CJK keys cost well above 600 UTF-16 units but above 1800 bytes; a
|
||||
// code-unit measure would keep this under a tiny budget while bytes exceed it.
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(JSON.stringify(kept ?? {}).length).toBeGreaterThan(600);
|
||||
});
|
||||
|
||||
it("drops the cell keys when they exceed the per-snapshot byte budget and marks the snapshot", () => {
|
||||
// Long keys push the serialized selection past the 64 KB budget.
|
||||
const selection: DataGridViewSelectionSnapshot = { kind: "cells", cellKeys: Array.from({ length: 1200 }, (_, index) => `${index}:${"cell".repeat(20)}`) };
|
||||
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection }));
|
||||
|
||||
expect(result.droppedSelection).toBe(true);
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toEqual([]);
|
||||
expect(kept?.selectionDropped).toBe(true);
|
||||
expect(kept?.viewport.top).toBe(100);
|
||||
expect(dataGridViewSnapshotCacheBytes()).toBeLessThan(MAX_SNAPSHOT_BYTES);
|
||||
});
|
||||
|
||||
it("keeps a selection that fits the budget without a dropped flag", () => {
|
||||
const result = saveDataGridViewSnapshot(snapshot("tab-1", { selection: sparseCellSelection(3) }));
|
||||
|
||||
expect(result.droppedSelection).toBe(false);
|
||||
const kept = peekDataGridViewSnapshot("tab-1");
|
||||
expect(kept?.selection?.kind === "cells" && kept.selection.cellKeys).toHaveLength(3);
|
||||
expect(kept?.selectionDropped).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clamps cell keys beyond the sparse cardinality cap", () => {
|
||||
const over = sparseCellSelection(MAX_SPARSE_CELL_ENTRIES + 1);
|
||||
const result = clampDataGridViewSelection(over);
|
||||
|
||||
expect(result.droppedSelection).toBe(true);
|
||||
expect(result.selection.kind === "cells" && result.selection.cellKeys).toEqual([]);
|
||||
expect(clampDataGridViewSelection(sparseCellSelection(MAX_SPARSE_CELL_ENTRIES)).droppedSelection).toBe(false);
|
||||
});
|
||||
|
||||
it("clears a single owner and its notice marker", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
|
||||
clearDataGridViewSnapshot("tab-1");
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("clears every snapshot belonging to a tab", () => {
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-a-0"));
|
||||
saveDataGridViewSnapshot(snapshot("tab-1-run-b-0"));
|
||||
saveDataGridViewSnapshot(snapshot("tab-2-run-a-0"));
|
||||
|
||||
clearDataGridViewSnapshotsForTab("tab-1");
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-a-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-1-run-b-0")).toBeUndefined();
|
||||
expect(peekDataGridViewSnapshot("tab-2-run-a-0")).toBeDefined();
|
||||
});
|
||||
|
||||
it("lets a tab capture again right after a plain clear (no tombstone)", () => {
|
||||
clearDataGridViewSnapshotsForTab("tab-1");
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("notifies at most once per owner and generation", () => {
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(true);
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-1")).toBe(false);
|
||||
// A new logical result is a new chance to report.
|
||||
expect(shouldNotifyOverBudgetSelection("tab-1", "gen-2")).toBe(true);
|
||||
expect(shouldNotifyOverBudgetSelection("tab-2", "gen-1")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the whole cache within the global byte budget", () => {
|
||||
for (let index = 0; index < 40; index += 1) {
|
||||
saveDataGridViewSnapshot(snapshot(`tab-${index}`, { selection: sparseCellSelection(2000) }));
|
||||
}
|
||||
|
||||
expect(dataGridViewSnapshotCacheBytes()).toBeLessThanOrEqual(MAX_CACHE_BYTES);
|
||||
expect(dataGridViewSnapshotCacheSize()).toBeLessThanOrEqual(32);
|
||||
});
|
||||
|
||||
it("blocks writes for a closing tab without requiring window timers", () => {
|
||||
const originalWindow = globalThis.window;
|
||||
// Test hosts may stub `window` without timer functions.
|
||||
(globalThis as { window?: unknown }).window = {};
|
||||
try {
|
||||
expect(() => beginClosingDataGridViewSnapshotsForTab("tab-1")).not.toThrow();
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = originalWindow;
|
||||
}
|
||||
|
||||
saveDataGridViewSnapshot(snapshot("tab-1"));
|
||||
|
||||
expect(peekDataGridViewSnapshot("tab-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildDataGridViewProbe", () => {
|
||||
it("is deterministic and bounded", () => {
|
||||
const long = "x".repeat(500);
|
||||
const input = {
|
||||
columns: Array.from({ length: 40 }, (_, index) => `column-${index}`),
|
||||
columnTypes: Array.from({ length: 40 }, (_, index) => `type-${index}`),
|
||||
rowCount: 3,
|
||||
firstRow: [long, 1, 2, 3, 4],
|
||||
lastRow: ["z", 1],
|
||||
};
|
||||
|
||||
const probe = buildDataGridViewProbe(input);
|
||||
|
||||
expect(probe).toBe(buildDataGridViewProbe(input));
|
||||
expect(probe).not.toContain(long);
|
||||
expect(probe).toContain("x".repeat(64));
|
||||
// Bounded to 16 columns / 4 probed row columns.
|
||||
expect(probe).not.toContain("column-20");
|
||||
});
|
||||
|
||||
it("changes when the dataset changes", () => {
|
||||
const base = { columns: ["id", "name"], rowCount: 1, firstRow: [1, "a"] };
|
||||
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, firstRow: [2, "a"] }));
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, rowCount: 2 }));
|
||||
expect(buildDataGridViewProbe(base)).not.toBe(buildDataGridViewProbe({ ...base, columns: ["id", "other"] }));
|
||||
});
|
||||
|
||||
it("skips large-value cells instead of reading them", () => {
|
||||
const input = {
|
||||
columns: ["id", "blob"],
|
||||
rowCount: 1,
|
||||
firstRow: [1, "huge-value"],
|
||||
largeValueCells: [{ row_index: 0, column_index: 1 }],
|
||||
};
|
||||
|
||||
expect(buildDataGridViewProbe(input)).not.toContain("huge-value");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { decodeTabResultSnapshot, encodeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function sampleResult(): QueryResult {
|
||||
return {
|
||||
columns: ["id", "name"],
|
||||
rows: [[1, "a"]],
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("tab result cache view generation", () => {
|
||||
it("round-trips the tab-level view generation for a data tab with no runs", () => {
|
||||
const restored = decodeTabResultSnapshot(
|
||||
encodeTabResultSnapshot({
|
||||
result: sampleResult(),
|
||||
resultViewGeneration: "gen-data-tab",
|
||||
cachedAt: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restored?.resultViewGeneration).toBe("gen-data-tab");
|
||||
});
|
||||
|
||||
it("round-trips the per-run view generation", () => {
|
||||
const restored = decodeTabResultSnapshot(
|
||||
encodeTabResultSnapshot({
|
||||
resultRuns: [{ id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(), resultViewGeneration: "gen-run" }],
|
||||
activeResultRunId: "run-1",
|
||||
cachedAt: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(restored?.resultRuns?.[0]?.resultViewGeneration).toBe("gen-run");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* Session-scoped cache for the view state of a data grid across tab switches.
|
||||
*
|
||||
* `ContentArea` renders only the active tab, so every `DataGrid` is unmounted on
|
||||
* a tab switch and remounted when the user returns. Scroll position and
|
||||
* selection live inside the component instance and would otherwise be lost.
|
||||
*
|
||||
* This cache deliberately holds primitives only — never a `QueryResult`, a rows
|
||||
* array, a Vue reactive object, or a callback — so it cannot defeat
|
||||
* `queryStore.releaseResultObjectPayload()` or inactive-result eviction.
|
||||
*
|
||||
* A snapshot is replayed only when the owning grid's `resultViewGeneration`
|
||||
* matches the value captured with the snapshot. That token is assigned by
|
||||
* `publishResultGeneration` in `queryStore` (new for every dataset replacement,
|
||||
* inherited only for disk restore and infinite-scroll append), so a fresh
|
||||
* result can never adopt a stale viewport (#7341).
|
||||
*/
|
||||
|
||||
export type DataGridRendererMode = "dom" | "canvas";
|
||||
|
||||
/** Cell position in display-row / visible-column coordinates. */
|
||||
export interface DataGridViewCellPosition {
|
||||
rowIndex: number;
|
||||
colIndex: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection captured in grid display coordinates. A generation match already
|
||||
* guarantees the same dataset, so plain indexes are used instead of the
|
||||
* identity-token machinery from `dataGridSelectionPersistence.ts` — that path
|
||||
* builds a token for every row, which is O(row count) and would violate the
|
||||
* capture budget on large results.
|
||||
*/
|
||||
export type DataGridViewSelectionSnapshot =
|
||||
| { kind: "all"; anchorRowIndex?: number | null }
|
||||
| { kind: "rows"; rowIndexes: number[]; contiguous?: boolean; anchorRowIndex?: number | null }
|
||||
| { kind: "columns"; columnIndexes: number[] }
|
||||
| { kind: "cells"; cellKeys: string[]; anchor?: DataGridViewCellPosition; focus?: DataGridViewCellPosition; selectingAll?: boolean }
|
||||
| { kind: "range"; anchor: DataGridViewCellPosition; focus: DataGridViewCellPosition; selectingAll?: boolean; lastClickedRowIndex?: number | null };
|
||||
|
||||
export interface DataGridViewSnapshot {
|
||||
/** Grid cache key: `resultGridCacheKey(tab)` for results, tab id for data tabs. */
|
||||
ownerKey: string;
|
||||
/** Logical-result identity the grid was rendering when this was captured. */
|
||||
viewGeneration: string;
|
||||
/** Bounded deterministic probe; integrity check only, never the authority. */
|
||||
probe: string;
|
||||
renderer: DataGridRendererMode;
|
||||
rowCount: number;
|
||||
columnCount: number;
|
||||
viewport: { top: number; left: number };
|
||||
/** Index-based selection captured in O(selection size). */
|
||||
selection?: DataGridViewSelectionSnapshot;
|
||||
/** Set when a sparse selection had to be dropped; surfaces the restore-time notice. */
|
||||
selectionDropped?: boolean;
|
||||
}
|
||||
|
||||
export interface DataGridViewProbeInput {
|
||||
columns: readonly string[];
|
||||
columnTypes?: readonly (string | undefined)[];
|
||||
rowCount: number;
|
||||
firstRow?: readonly unknown[];
|
||||
lastRow?: readonly unknown[];
|
||||
/** Large-value cells are skipped so the probe never reads a multi-MB value. */
|
||||
largeValueCells?: ReadonlyArray<{ row_index: number; column_index: number }>;
|
||||
navigation?: {
|
||||
whereInput?: string;
|
||||
orderByInput?: string;
|
||||
pageOffset?: number;
|
||||
pageLimit?: number;
|
||||
sortColumn?: string;
|
||||
sortDirection?: string;
|
||||
sortMode?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const MAX_ENTRIES = 32;
|
||||
/**
|
||||
* Rollback switch (default on). Set `VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT=true`
|
||||
* to skip capture and restore entirely, which restores the pre-feature
|
||||
* behavior without a code change.
|
||||
*/
|
||||
export const DATA_GRID_VIEW_SNAPSHOT_RESTORE = import.meta.env?.VITE_DBX_DISABLE_GRID_VIEW_SNAPSHOT !== "true";
|
||||
/** Per-snapshot serialized budget. */
|
||||
export const MAX_SNAPSHOT_BYTES = 64 * 1024;
|
||||
/** Whole-cache serialized budget. */
|
||||
export const MAX_CACHE_BYTES = 2 * 1024 * 1024;
|
||||
/** Hard cap on sparse cell-selection entries. */
|
||||
export const MAX_SPARSE_CELL_ENTRIES = 4096;
|
||||
const PROBE_COLUMNS = 16;
|
||||
const PROBE_ROW_COLUMNS = 4;
|
||||
const PROBE_VALUE_CHARS = 64;
|
||||
|
||||
const cache = new Map<string, DataGridViewSnapshot>();
|
||||
/**
|
||||
* Tabs whose snapshots must not be written again. Closing a tab unmounts its
|
||||
* grid, whose teardown capture would otherwise re-create the entry the store
|
||||
* just deleted. Mirrors `closingPendingSnapshotTabs` in `useDataGridEditor`.
|
||||
*/
|
||||
const closingTabs = new Set<string>();
|
||||
/**
|
||||
* Owners already told that an oversized sparse selection was dropped. Cleared
|
||||
* only when the owner's snapshot is cleared or its generation changes, so
|
||||
* repeated tab switching cannot re-notify.
|
||||
*/
|
||||
const overBudgetNotified = new Set<string>();
|
||||
|
||||
function ownerBelongsToTab(ownerKey: string, tabId: string): boolean {
|
||||
return ownerKey === tabId || ownerKey.startsWith(`${tabId}-`);
|
||||
}
|
||||
|
||||
function isClosingOwner(ownerKey: string): boolean {
|
||||
for (const tabId of closingTabs) {
|
||||
if (ownerBelongsToTab(ownerKey, tabId)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const byteEncoder = new TextEncoder();
|
||||
|
||||
/**
|
||||
* Serialized size in bytes (UTF-8), so the budgets hold for non-ASCII column
|
||||
* and cell content too. Only ever called on the small snapshot object, never
|
||||
* on result data.
|
||||
*/
|
||||
function snapshotBytes(snapshot: DataGridViewSnapshot): number {
|
||||
try {
|
||||
return byteEncoder.encode(JSON.stringify(snapshot)).byteLength;
|
||||
} catch {
|
||||
return Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
}
|
||||
|
||||
/** True when the selection exceeds the sparse-cell budget. */
|
||||
export function selectionExceedsBudget(selection: DataGridViewSelectionSnapshot | undefined): boolean {
|
||||
return !!selection && (selection.kind === "cells" ? selection.cellKeys.length > MAX_SPARSE_CELL_ENTRIES : selection.kind === "rows" && !selection.contiguous && selection.rowIndexes.length > MAX_SPARSE_CELL_ENTRIES);
|
||||
}
|
||||
|
||||
/** Trim an over-budget sparse selection, reporting what was dropped. */
|
||||
export function clampDataGridViewSelection(selection: DataGridViewSelectionSnapshot): { selection: DataGridViewSelectionSnapshot; droppedSelection: boolean } {
|
||||
if (selection.kind === "cells") {
|
||||
if (selection.cellKeys.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
|
||||
return { selection: { kind: "cells", cellKeys: [], anchor: selection.anchor, focus: selection.focus }, droppedSelection: true };
|
||||
}
|
||||
if (selection.kind === "rows" && !selection.contiguous) {
|
||||
if (selection.rowIndexes.length <= MAX_SPARSE_CELL_ENTRIES) return { selection, droppedSelection: false };
|
||||
return { selection: { kind: "rows", rowIndexes: [], anchorRowIndex: selection.anchorRowIndex }, droppedSelection: true };
|
||||
}
|
||||
return { selection, droppedSelection: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Shrink a snapshot until it fits the per-snapshot budget: a sparse selection
|
||||
* first, then the whole selection. The viewport is always retained.
|
||||
*/
|
||||
function degradeToBudget(snapshot: DataGridViewSnapshot): { snapshot: DataGridViewSnapshot; droppedSelection: boolean } {
|
||||
if (snapshotBytes(snapshot) <= MAX_SNAPSHOT_BYTES) {
|
||||
return { snapshot, droppedSelection: false };
|
||||
}
|
||||
if (snapshot.selection?.kind === "cells" && snapshot.selection.cellKeys.length) {
|
||||
const withoutCells: DataGridViewSnapshot = { ...snapshot, selection: { ...snapshot.selection, cellKeys: [] }, selectionDropped: true };
|
||||
if (snapshotBytes(withoutCells) <= MAX_SNAPSHOT_BYTES) {
|
||||
return { snapshot: withoutCells, droppedSelection: true };
|
||||
}
|
||||
}
|
||||
return { snapshot: { ...snapshot, selection: undefined, selectionDropped: true }, droppedSelection: !!snapshot.selection };
|
||||
}
|
||||
|
||||
function totalCacheBytes(): number {
|
||||
let total = 0;
|
||||
for (const snapshot of cache.values()) total += snapshotBytes(snapshot);
|
||||
return total;
|
||||
}
|
||||
|
||||
function evictOldest(): boolean {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) return false;
|
||||
cache.delete(oldest);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a snapshot for `ownerKey`, degrading it to fit the budgets. Returns
|
||||
* whether a selection had to be dropped, so the caller can surface the one-shot
|
||||
* notice.
|
||||
*/
|
||||
export function saveDataGridViewSnapshot(snapshot: DataGridViewSnapshot): { droppedSelection: boolean } {
|
||||
if (isClosingOwner(snapshot.ownerKey)) return { droppedSelection: false };
|
||||
const { snapshot: fitted, droppedSelection } = degradeToBudget(snapshot);
|
||||
cache.delete(snapshot.ownerKey);
|
||||
cache.set(snapshot.ownerKey, fitted);
|
||||
while (cache.size > MAX_ENTRIES) {
|
||||
if (!evictOldest()) break;
|
||||
}
|
||||
while (cache.size > 1 && totalCacheBytes() > MAX_CACHE_BYTES) {
|
||||
if (!evictOldest()) break;
|
||||
}
|
||||
return { droppedSelection };
|
||||
}
|
||||
|
||||
/** Read a snapshot without consuming it, refreshing its LRU recency. */
|
||||
export function peekDataGridViewSnapshot(ownerKey: string): DataGridViewSnapshot | undefined {
|
||||
const snapshot = cache.get(ownerKey);
|
||||
if (!snapshot) return undefined;
|
||||
cache.delete(ownerKey);
|
||||
cache.set(ownerKey, snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop a replayed snapshot after a successful restore, keeping the over-budget
|
||||
* notice marker so switching back and forth cannot re-notify.
|
||||
*/
|
||||
export function consumeDataGridViewSnapshot(ownerKey: string): void {
|
||||
cache.delete(ownerKey);
|
||||
}
|
||||
|
||||
/** Drop a single owner's snapshot and its notice marker. */
|
||||
export function clearDataGridViewSnapshot(ownerKey: string): void {
|
||||
cache.delete(ownerKey);
|
||||
// Deleting while iterating a Set/Map is safe: iteration skips removed entries.
|
||||
for (const key of overBudgetNotified) {
|
||||
if (key.startsWith(`${ownerKey} `)) overBudgetNotified.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop every snapshot belonging to a tab (owner keys are `${tabId}-...`). */
|
||||
export function clearDataGridViewSnapshotsForTab(tabId: string): void {
|
||||
for (const key of cache.keys()) {
|
||||
if (ownerBelongsToTab(key, tabId)) cache.delete(key);
|
||||
}
|
||||
for (const key of overBudgetNotified) {
|
||||
if (ownerBelongsToTab(key, tabId) || key.startsWith(`${tabId} `)) overBudgetNotified.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a tab's snapshots and block new writes for a short window, so the grid
|
||||
* teardown capture cannot resurrect an entry after the tab is closed or its
|
||||
* result cleared.
|
||||
*/
|
||||
export function beginClosingDataGridViewSnapshotsForTab(tabId: string): void {
|
||||
closingTabs.add(tabId);
|
||||
// Some test environments stub `window` without timers, so a bare
|
||||
// `window.setTimeout` would throw while clearing a result.
|
||||
const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout;
|
||||
schedule(() => closingTabs.delete(tabId), 5000);
|
||||
clearDataGridViewSnapshotsForTab(tabId);
|
||||
}
|
||||
|
||||
/** Test seam: drop everything. */
|
||||
export function resetDataGridViewSnapshots(): void {
|
||||
cache.clear();
|
||||
overBudgetNotified.clear();
|
||||
closingTabs.clear();
|
||||
}
|
||||
|
||||
export function dataGridViewSnapshotCacheSize(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
export function dataGridViewSnapshotCacheBytes(): number {
|
||||
return totalCacheBytes();
|
||||
}
|
||||
|
||||
/**
|
||||
* One-shot notice bookkeeping for a dropped selection. Returns true the first
|
||||
* time this owner+generation combination reports a drop, and false on every
|
||||
* later call until the snapshot is cleared or the generation changes.
|
||||
*/
|
||||
export function shouldNotifyOverBudgetSelection(ownerKey: string, viewGeneration: string): boolean {
|
||||
const marker = `${ownerKey} ${viewGeneration}`;
|
||||
if (overBudgetNotified.has(marker)) return false;
|
||||
overBudgetNotified.add(marker);
|
||||
return true;
|
||||
}
|
||||
|
||||
function probeValue(value: unknown, skip: boolean): string {
|
||||
if (skip) return "";
|
||||
if (value === null) return " ";
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
return text.length > PROBE_VALUE_CHARS ? text.slice(0, PROBE_VALUE_CHARS) : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic bounded fingerprint of a result. Used only to detect cache
|
||||
* corruption or wiring mistakes: it must never authorize a restore by itself.
|
||||
*/
|
||||
export function buildDataGridViewProbe(input: DataGridViewProbeInput): string {
|
||||
const skip = new Set((input.largeValueCells ?? []).map((cell) => `${cell.row_index}:${cell.column_index}`));
|
||||
const rowProbe = (row: readonly unknown[] | undefined, rowIndex: number): string => {
|
||||
if (!row) return "";
|
||||
const cells: string[] = [];
|
||||
const limit = Math.min(PROBE_ROW_COLUMNS, row.length);
|
||||
for (let columnIndex = 0; columnIndex < limit; columnIndex += 1) {
|
||||
cells.push(probeValue(row[columnIndex], skip.has(`${rowIndex}:${columnIndex}`)));
|
||||
}
|
||||
return cells.join("");
|
||||
};
|
||||
const navigation = input.navigation;
|
||||
return JSON.stringify({
|
||||
columns: input.columns.slice(0, PROBE_COLUMNS),
|
||||
columnTypes: input.columnTypes?.slice(0, PROBE_COLUMNS),
|
||||
rowCount: input.rowCount,
|
||||
first: rowProbe(input.firstRow, 0),
|
||||
last: rowProbe(input.lastRow, Math.max(0, input.rowCount - 1)),
|
||||
nav: navigation ? [navigation.whereInput ?? "", navigation.orderByInput ?? "", navigation.pageOffset ?? 0, navigation.pageLimit ?? 0, navigation.sortColumn ?? "", navigation.sortDirection ?? "", navigation.sortMode ?? ""] : undefined,
|
||||
});
|
||||
}
|
||||
@@ -32,6 +32,12 @@ export interface TabResultSnapshot {
|
||||
resultLocalSortOriginalMongoCopyDocuments?: QueryResult["mongo_copy_documents"];
|
||||
resultRuns?: QueryTab["resultRuns"];
|
||||
activeResultRunId?: string;
|
||||
/**
|
||||
* Logical-result identity for the tab-switch view snapshot cache. Required at
|
||||
* the tab level because a data tab has no result run to carry it; query tabs
|
||||
* additionally carry it per run through `resultRuns`.
|
||||
*/
|
||||
resultViewGeneration?: string;
|
||||
queryAnalysis?: QueryTab["queryAnalysis"];
|
||||
querySourceColumns?: QueryTab["querySourceColumns"];
|
||||
queryWriteTargets?: QueryTab["queryWriteTargets"];
|
||||
@@ -747,6 +753,7 @@ export function buildTabResultSnapshot(tab: QueryTab): TabResultSnapshot | undef
|
||||
resultLocalSortOriginalMongoCopyDocuments: tab.resultLocalSortOriginalMongoCopyDocuments ? clonePlain(tab.resultLocalSortOriginalMongoCopyDocuments) : undefined,
|
||||
resultRuns: stripResultRunSessionIds(tab.resultRuns),
|
||||
activeResultRunId: tab.activeResultRunId,
|
||||
resultViewGeneration: tab.resultViewGeneration,
|
||||
queryAnalysis: tab.queryAnalysis ? clonePlain(tab.queryAnalysis) : undefined,
|
||||
querySourceColumns: tab.querySourceColumns ? [...tab.querySourceColumns] : undefined,
|
||||
queryWriteTargets: tab.queryWriteTargets?.map((target) => ({ ...target, sourceColumns: [...target.sourceColumns] })),
|
||||
|
||||
Reference in new issue
Block a user