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
@@ -250,6 +250,7 @@ import { uniqueDataGridColumnOrderKeys } from "@/lib/dataGrid/dataGridColumnOrde
|
||||
import { dataGridColumnLayoutScopeKey, TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, tableDataGridColumnOrderScopeKey } from "@/lib/dataGrid/dataGridColumnLayoutStorage";
|
||||
import { createPendingSelectionSummary, formatSelectionAggregate, formatSelectionAverage, summarizeSelection } from "@/lib/dataGrid/gridSelection";
|
||||
import { captureDataGridSelection, restoreDataGridSelection, type CaptureDataGridSelectionOptions, type PersistedDataGridSelection } from "@/lib/dataGrid/dataGridSelectionPersistence";
|
||||
import { buildDataGridViewProbe, clampDataGridViewSelection, DATA_GRID_VIEW_SNAPSHOT_RESTORE, consumeDataGridViewSnapshot, peekDataGridViewSnapshot, saveDataGridViewSnapshot, shouldNotifyOverBudgetSelection, type DataGridViewSelectionSnapshot } from "@/lib/dataGrid/dataGridViewStateCache";
|
||||
import { dataGridFrameCoversRow, dataGridSelectionEdgeMask, dataGridSelectionFrameKindAtCell, dataGridSelectionUsesOuterFrame, resolveDataGridSelectionFrames } from "@/lib/dataGrid/dataGridSelectionFrames";
|
||||
import {
|
||||
createDataGridCellContextMenuItems,
|
||||
@@ -485,6 +486,13 @@ interface DataGridProps {
|
||||
cacheKey?: string;
|
||||
columnWidthCacheKey?: string;
|
||||
pendingStateKey?: string;
|
||||
/**
|
||||
* Logical-result identity (`QueryTab.resultViewGeneration`) the grid is
|
||||
* currently rendering. The tab-switch view snapshot is captured with this
|
||||
* value and replayed only when it still matches, so a replaced dataset never
|
||||
* adopts a stale viewport or selection (#7341).
|
||||
*/
|
||||
viewGeneration?: string;
|
||||
exportSql?: string;
|
||||
onExecuteSql?: (sql: string) => Promise<void>;
|
||||
fullExportResult?: (onProgress?: (info: { rowsExported: number; totalRows: number | null }) => void) => Promise<QueryResult | undefined>;
|
||||
@@ -1197,6 +1205,11 @@ const filterBuilderOpen = filterBuilder.open;
|
||||
const filterBuilderColumnSearch = filterBuilder.columnSearch;
|
||||
const filteredFilterBuilderColumnOptions = filterBuilder.filteredColumns;
|
||||
const appliedStructuredWhereInput = filterBuilder.appliedWhereInput;
|
||||
// Structured filter rules are restored asynchronously. A tab-switch snapshot's
|
||||
// probe includes the applied condition, so restoring before this hydration
|
||||
// settles would reject an otherwise valid snapshot and never retry it.
|
||||
const structuredFilterHydrationReady = ref(true);
|
||||
let structuredFilterHydrationRequestId = 0;
|
||||
const draftStructuredWhereInput = ref("");
|
||||
const filterEditorView = computed(() => settingsStore.editorSettings.dataGridFilterEditorView);
|
||||
const isPersistentFilterView = computed(() => filterEditorView.value === "conditions" || filterEditorView.value === "text");
|
||||
@@ -1464,6 +1477,8 @@ function persistStructuredFilterState() {
|
||||
}
|
||||
|
||||
function loadStructuredFilterStateForScope() {
|
||||
const requestId = ++structuredFilterHydrationRequestId;
|
||||
structuredFilterHydrationReady.value = false;
|
||||
const cached = cachedStructuredFilterState();
|
||||
if (cached) {
|
||||
const cacheKey = structuredFilterCacheKey.value;
|
||||
@@ -1472,14 +1487,23 @@ function loadStructuredFilterStateForScope() {
|
||||
whereFilterInput.value = cached.manualWhereInput;
|
||||
serverColumnFilters.value = structuredClone(cached.serverColumnFilters ?? {});
|
||||
appliedStructuredWhereInput.value = "";
|
||||
void buildStructuredWhereFromRules(structuredFilterRules.value).then((whereInput) => {
|
||||
if (structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
|
||||
appliedStructuredWhereInput.value = whereInput;
|
||||
nextTick(() => {
|
||||
emit("update:whereInput", currentWhereInput() ?? "");
|
||||
markConditionInputsApplied();
|
||||
void buildStructuredWhereFromRules(structuredFilterRules.value)
|
||||
.then((whereInput) => {
|
||||
if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
|
||||
appliedStructuredWhereInput.value = whereInput;
|
||||
nextTick(() => {
|
||||
emit("update:whereInput", currentWhereInput() ?? "");
|
||||
markConditionInputsApplied();
|
||||
});
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (requestId !== structuredFilterHydrationRequestId || structuredFilterCacheKey.value !== cacheKey || structuredFilterScopeKey.value !== scopeKey) return;
|
||||
structuredFilterHydrationReady.value = true;
|
||||
// The initial mounted restore may have run while the applied condition
|
||||
// was empty; retry exactly after the async condition is settled.
|
||||
nextTick(restoreTabSwitchViewSnapshot);
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
appliedStructuredWhereInput.value = "";
|
||||
@@ -1487,6 +1511,7 @@ function loadStructuredFilterStateForScope() {
|
||||
structuredFilterRules.value = filterBuilderColumnOptions.value.length > 0 ? [defaultStructuredFilterRule()] : [];
|
||||
persistStructuredFilterState();
|
||||
markConditionInputsApplied();
|
||||
structuredFilterHydrationReady.value = true;
|
||||
}
|
||||
|
||||
function ensureStructuredFilterRule() {
|
||||
@@ -4925,6 +4950,251 @@ function restoreSelectionAfterRefresh(snapshot: PersistedDataGridSelection) {
|
||||
});
|
||||
}
|
||||
|
||||
/** Bounded settling envelope for a replayed tab-switch viewport. */
|
||||
const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;
|
||||
let viewSnapshotRestoreFrame = 0;
|
||||
|
||||
function cancelViewSnapshotRestoreFrame() {
|
||||
if (viewSnapshotRestoreFrame) cancelAnimationFrame(viewSnapshotRestoreFrame);
|
||||
viewSnapshotRestoreFrame = 0;
|
||||
}
|
||||
|
||||
/** Bounded integrity probe of the result the grid is currently rendering. */
|
||||
function currentViewProbe(): string {
|
||||
const rows = props.result.rows;
|
||||
return buildDataGridViewProbe({
|
||||
columns: props.result.columns,
|
||||
columnTypes: props.result.column_types,
|
||||
rowCount: rows.length,
|
||||
firstRow: rows[0],
|
||||
lastRow: rows[rows.length - 1],
|
||||
largeValueCells: props.result.large_value_cells,
|
||||
navigation: {
|
||||
whereInput: currentWhereInput(),
|
||||
orderByInput: orderByInput.value,
|
||||
pageOffset: props.pageOffset,
|
||||
pageLimit: pageSize.value,
|
||||
sortColumn: props.sortColumn,
|
||||
sortDirection: props.sortDirection,
|
||||
sortMode: props.sortMode,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy the current selection into display coordinates. O(selection size): a
|
||||
* generation match already guarantees the same dataset on return, so the
|
||||
* identity-token machinery of the refresh path (which scans every row) is not
|
||||
* needed here.
|
||||
*
|
||||
* Large row sets are represented compactly: a contiguous range (or "all" when
|
||||
* every row is selected) costs a single range pair instead of one id per row.
|
||||
*/
|
||||
function captureViewSelection(): { selection?: DataGridViewSelectionSnapshot; droppedSelection: boolean } {
|
||||
const selectedRowCount = selectedRowIds.value.size;
|
||||
const hasRows = selectedRowCount > 0;
|
||||
const hasCells = selectedCellKeys.value.size > 0;
|
||||
const hasColumns = selectedColumnIndexes.value.size > 0;
|
||||
const hasRange = !!selectionAnchor.value && !!selectionFocus.value;
|
||||
if (!hasRows && !hasCells && !hasColumns && !hasRange) return { selection: undefined, droppedSelection: false };
|
||||
|
||||
if (hasRows) {
|
||||
const displayCount = displayItems.value.length;
|
||||
const contiguous = selectedRowCount === displayCount || selectedRowsContiguous(selectedRowIds.value);
|
||||
if (contiguous) {
|
||||
const anchorRowIndex = selection.lastClickedRowIndex.value;
|
||||
if (selectedRowCount === displayCount) {
|
||||
return { selection: { kind: "all", anchorRowIndex }, droppedSelection: false };
|
||||
}
|
||||
return { selection: { kind: "rows", rowIndexes: minimalRowRange(selectedRowIds.value), contiguous: true, anchorRowIndex }, droppedSelection: false };
|
||||
}
|
||||
const { selection: capturedRows, droppedSelection } = clampDataGridViewSelection({ kind: "rows", rowIndexes: selectedRowIdsAsDisplayIndexes(selectedRowIds.value), anchorRowIndex: selection.lastClickedRowIndex.value });
|
||||
return { selection: capturedRows.kind === "rows" && capturedRows.rowIndexes.length ? capturedRows : undefined, droppedSelection };
|
||||
}
|
||||
|
||||
if (hasColumns) {
|
||||
return { selection: { kind: "columns", columnIndexes: [...selectedColumnIndexes.value] }, droppedSelection: false };
|
||||
}
|
||||
|
||||
if (hasRange) {
|
||||
return {
|
||||
selection: { kind: "range", anchor: { ...selectionAnchor.value! }, focus: { ...selectionFocus.value! }, selectingAll: isSelectingAll.value || undefined, lastClickedRowIndex: selection.lastClickedRowIndex.value },
|
||||
droppedSelection: false,
|
||||
};
|
||||
}
|
||||
|
||||
const { selection: capturedCells, droppedSelection } = clampDataGridViewSelection({ kind: "cells", cellKeys: [...selectedCellKeys.value], anchor: selectionAnchor.value ? { ...selectionAnchor.value } : undefined, focus: selectionFocus.value ? { ...selectionFocus.value } : undefined });
|
||||
return { selection: capturedCells.kind === "cells" && capturedCells.cellKeys.length ? capturedCells : undefined, droppedSelection };
|
||||
}
|
||||
|
||||
/** Display indexes of the selected row ids, bounded to the displayed rows. */
|
||||
function selectedRowIdsAsDisplayIndexes(selectedIds: ReadonlySet<number>): number[] {
|
||||
const indexes: number[] = [];
|
||||
for (const rowId of selectedIds) {
|
||||
const index = displayRowIndexById(rowId);
|
||||
if (index >= 0) indexes.push(index);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the selected rows form a contiguous block in display order.
|
||||
* Iterates only the selected ids via the O(1) index lookup, so a single-row
|
||||
* selection on a 100k-row result stays O(1) instead of scanning every row.
|
||||
*/
|
||||
function selectedRowsContiguous(selectedIds: ReadonlySet<number>): boolean {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
let count = 0;
|
||||
for (const rowId of selectedIds) {
|
||||
const index = displayRowIndexById(rowId);
|
||||
if (index < 0) continue;
|
||||
if (index < min) min = index;
|
||||
if (index > max) max = index;
|
||||
count += 1;
|
||||
}
|
||||
return count > 0 && max - min + 1 === count;
|
||||
}
|
||||
|
||||
/** The [first, last] display indexes of the selected rows, when contiguous. */
|
||||
function minimalRowRange(selectedIds: ReadonlySet<number>): number[] {
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
for (const rowId of selectedIds) {
|
||||
const index = displayRowIndexById(rowId);
|
||||
if (index < 0) continue;
|
||||
if (index < min) min = index;
|
||||
if (index > max) max = index;
|
||||
}
|
||||
return Number.isFinite(min) ? [min, max] : [];
|
||||
}
|
||||
|
||||
/** Expand a [first, last] row range into every display index in between. */
|
||||
function expandRowRange(range: readonly number[], displayCount: number): number[] {
|
||||
const [first, last] = range;
|
||||
if (!Number.isInteger(first) || !Number.isInteger(last) || first < 0 || last < first) return [];
|
||||
const indexes: number[] = [];
|
||||
const end = Math.min(last, displayCount - 1);
|
||||
for (let index = first; index <= end; index += 1) indexes.push(index);
|
||||
return indexes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Capture scroll/selection before this instance is unmounted by a tab switch.
|
||||
* Skips transpose (explicitly out of scope) and grids without an owner key or
|
||||
* generation, so a snapshot can never be replayed without an identity check.
|
||||
*/
|
||||
function captureTabSwitchViewSnapshot() {
|
||||
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
|
||||
if (!props.cacheKey || !props.viewGeneration) return;
|
||||
if (showTranspose.value) return;
|
||||
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
|
||||
if (!scroller) return;
|
||||
const { selection, droppedSelection } = captureViewSelection();
|
||||
saveDataGridViewSnapshot({
|
||||
ownerKey: props.cacheKey,
|
||||
viewGeneration: props.viewGeneration,
|
||||
probe: currentViewProbe(),
|
||||
renderer: useCanvasGridRows.value ? "canvas" : "dom",
|
||||
rowCount: props.result.rows.length,
|
||||
columnCount: props.result.columns.length,
|
||||
viewport: { top: Math.max(0, scroller.scrollTop), left: Math.max(0, scroller.scrollLeft) },
|
||||
selection,
|
||||
selectionDropped: droppedSelection || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a tab-switch snapshot captured from a previous instance of the same
|
||||
* logical result. Every gate must pass; any mismatch degrades to a clean start
|
||||
* rather than a partial restore.
|
||||
*/
|
||||
function restoreTabSwitchViewSnapshot() {
|
||||
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
|
||||
if (!structuredFilterHydrationReady.value) return;
|
||||
const ownerKey = props.cacheKey;
|
||||
if (!ownerKey || !props.viewGeneration) return;
|
||||
if (showTranspose.value) return;
|
||||
const snapshot = peekDataGridViewSnapshot(ownerKey);
|
||||
if (!snapshot) return;
|
||||
if (snapshot.viewGeneration !== props.viewGeneration) return;
|
||||
if (snapshot.renderer !== (useCanvasGridRows.value ? "canvas" : "dom")) return;
|
||||
if (snapshot.probe !== currentViewProbe()) return;
|
||||
|
||||
// The selection rides along for free: indexes stay valid under a generation
|
||||
// match, and out-of-range entries simply never match any display row.
|
||||
const capturedSelection = snapshot.selection;
|
||||
if (capturedSelection) {
|
||||
if (capturedSelection.kind === "cells") {
|
||||
if (capturedSelection.cellKeys.length) restoreCellSelectionState({ cellKeys: new Set(capturedSelection.cellKeys) });
|
||||
} else if (capturedSelection.kind === "range") {
|
||||
restoreCellSelectionState({ anchor: capturedSelection.anchor, focus: capturedSelection.focus, selectingAll: capturedSelection.selectingAll === true });
|
||||
if (capturedSelection.lastClickedRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.lastClickedRowIndex;
|
||||
} else if (capturedSelection.kind === "columns") {
|
||||
selectedColumnIndexes.value = new Set(capturedSelection.columnIndexes);
|
||||
} else if (capturedSelection.kind === "rows") {
|
||||
const indexes = capturedSelection.contiguous ? expandRowRange(capturedSelection.rowIndexes, displayItems.value.length) : capturedSelection.rowIndexes;
|
||||
selectedRowIds.value = new Set(indexes.map((index) => displayItems.value[index]?.id).filter((id): id is number => typeof id === "number"));
|
||||
if (capturedSelection.anchorRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;
|
||||
} else if (capturedSelection.kind === "all") {
|
||||
selectedRowIds.value = new Set(displayItems.value.map((item) => item.id));
|
||||
if (capturedSelection.anchorRowIndex != null) selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;
|
||||
}
|
||||
}
|
||||
|
||||
// DOM virtualization and canvas layout can report an incomplete scroll height
|
||||
// for more than a frame, which would clamp the saved position to the top.
|
||||
// Mirror `restoreScrollAcrossFrames`: immediate, then nextTick, then a
|
||||
// bounded rAF settling loop that stops once the position sticks.
|
||||
let settled = false;
|
||||
let previousMaxTop = -1;
|
||||
const applyView = (): boolean => {
|
||||
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
|
||||
if (!scroller) return false;
|
||||
const maxTop = Math.max(0, scroller.scrollHeight - scroller.clientHeight);
|
||||
const maxLeft = Math.max(0, scroller.scrollWidth - scroller.clientWidth);
|
||||
const targetTop = Math.min(Math.max(0, snapshot.viewport.top), maxTop);
|
||||
const targetLeft = Math.min(Math.max(0, snapshot.viewport.left), maxLeft);
|
||||
scroller.scrollTop = targetTop;
|
||||
scroller.scrollLeft = targetLeft;
|
||||
if (useCanvasGridRows.value) syncCanvasViewport();
|
||||
const accepted = Math.abs(scroller.scrollTop - targetTop) < 0.5 && Math.abs(scroller.scrollLeft - targetLeft) < 0.5;
|
||||
// A saved offset of 0 is settled as soon as it is accepted. Otherwise the
|
||||
// scroller must be tall enough to hold the saved position AND its measured
|
||||
// geometry must hold still — an unmeasured virtual scroller clamps the
|
||||
// target to 0 and would otherwise be mistaken for a finished restore.
|
||||
const roomForSavedTop = maxTop >= snapshot.viewport.top;
|
||||
const stable = maxTop === previousMaxTop;
|
||||
previousMaxTop = maxTop;
|
||||
return accepted && (snapshot.viewport.top === 0 || (roomForSavedTop && stable));
|
||||
};
|
||||
const attempt = (): boolean => {
|
||||
if (settled || !applyView()) return settled;
|
||||
settled = true;
|
||||
cancelViewSnapshotRestoreFrame();
|
||||
consumeDataGridViewSnapshot(ownerKey);
|
||||
// Report a dropped selection only once the restore it belongs to actually
|
||||
// happened — never while merely leaving the tab.
|
||||
if (snapshot.selectionDropped && shouldNotifyOverBudgetSelection(ownerKey, props.viewGeneration!)) {
|
||||
toast(t("grid.viewSnapshotSelectionNotRestored"), 4000);
|
||||
}
|
||||
return true;
|
||||
};
|
||||
if (attempt()) return;
|
||||
nextTick(() => {
|
||||
if (attempt()) return;
|
||||
if (typeof requestAnimationFrame !== "function") return;
|
||||
let frames = 0;
|
||||
const onFrame = () => {
|
||||
if (attempt()) return;
|
||||
frames += 1;
|
||||
if (frames >= MAX_VIEW_SNAPSHOT_RESTORE_FRAMES) return;
|
||||
viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);
|
||||
};
|
||||
viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);
|
||||
});
|
||||
}
|
||||
|
||||
const multiRowCount = computed(() => {
|
||||
if (hasRowSelection.value) return selectedRowCount.value;
|
||||
const range = selectedRange.value;
|
||||
@@ -6848,11 +7118,20 @@ onMounted(() => {
|
||||
window.visualViewport?.addEventListener("resize", refreshDataGridViewportMetrics);
|
||||
window.addEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics);
|
||||
window.addEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);
|
||||
window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot);
|
||||
window.addEventListener("blur", clearInternalClipboardCopy);
|
||||
document.addEventListener("visibilitychange", clearInternalClipboardCopy);
|
||||
nextTick(restoreTabSwitchViewSnapshot);
|
||||
});
|
||||
onDeactivated(pauseCanvasGridWork);
|
||||
onUnmounted(() => {
|
||||
// Capture before teardown: a tab switch unmounts this instance, and the
|
||||
// snapshot is the only carrier of its viewport/selection. This runs after the
|
||||
// `dbx:before-tab-switch` capture and wins, because it observes the final
|
||||
// rendered viewport. Closing a tab blocks both writes via the cache's
|
||||
// closing-tab tombstone.
|
||||
captureTabSwitchViewSnapshot();
|
||||
cancelViewSnapshotRestoreFrame();
|
||||
onLocalFilterResizeEnd();
|
||||
dataGridRuntimeScope.dispose();
|
||||
foreignKeyDisplayRequests.dispose();
|
||||
@@ -6874,6 +7153,7 @@ onUnmounted(() => {
|
||||
window.visualViewport?.removeEventListener("resize", refreshDataGridViewportMetrics);
|
||||
window.removeEventListener("dbx:ui-scale-applied", refreshDataGridViewportMetrics);
|
||||
window.removeEventListener(TABLE_DATA_GRID_COLUMN_ORDER_CHANGED_EVENT, onSynchronizedTableDataGridColumnOrderChanged);
|
||||
window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot);
|
||||
window.removeEventListener("blur", clearInternalClipboardCopy);
|
||||
document.removeEventListener("visibilitychange", clearInternalClipboardCopy);
|
||||
});
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const dataGridSource = readFileSync(new URL("../DataGrid.vue", import.meta.url), "utf8");
|
||||
|
||||
function functionSource(name: string, endMarker: string): string {
|
||||
const start = dataGridSource.indexOf(`function ${name}(`);
|
||||
const end = dataGridSource.indexOf(endMarker, start + 1);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return dataGridSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("DataGrid tab-switch view snapshots", () => {
|
||||
const captureSource = functionSource("captureViewSelection", "function captureTabSwitchViewSnapshot(");
|
||||
const captureFnSource = functionSource("captureTabSwitchViewSnapshot", "function restoreTabSwitchViewSnapshot(");
|
||||
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 (showTranspose.value) return;");
|
||||
expect(captureFnSource).toContain("ownerKey: props.cacheKey");
|
||||
expect(captureFnSource).toContain("viewGeneration: props.viewGeneration");
|
||||
expect(captureFnSource).toContain("probe: currentViewProbe()");
|
||||
});
|
||||
|
||||
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).
|
||||
expect(captureFnSource).not.toContain("captureCurrentSelectionForRefresh");
|
||||
// Contiguous/all row selections serialize as a compact range instead of one
|
||||
// id per row, and the range helpers iterate only the selected ids via the
|
||||
// O(1) display index lookup — never the full displayItems.
|
||||
expect(captureSource).toContain("selectedRowCount === displayCount");
|
||||
expect(captureSource).toContain("selectedRowsContiguous(selectedRowIds.value)");
|
||||
expect(captureSource).toContain('kind: "all"');
|
||||
expect(captureSource).toContain("clampDataGridViewSelection(");
|
||||
const contiguousHelper = dataGridSource.slice(dataGridSource.indexOf("function selectedRowsContiguous("), dataGridSource.indexOf("function minimalRowRange("));
|
||||
expect(contiguousHelper).toContain("displayRowIndexById(rowId)");
|
||||
expect(contiguousHelper).toContain("max - min + 1 === count");
|
||||
expect(contiguousHelper).not.toContain("displayItems");
|
||||
});
|
||||
|
||||
it("gates every restore on identity, renderer, and probe", () => {
|
||||
expect(restoreSource).toContain("if (!structuredFilterHydrationReady.value) return;");
|
||||
expect(restoreSource).toContain("if (snapshot.viewGeneration !== props.viewGeneration) return;");
|
||||
expect(restoreSource).toContain('if (snapshot.renderer !== (useCanvasGridRows.value ? "canvas" : "dom")) return;');
|
||||
expect(restoreSource).toContain("if (snapshot.probe !== currentViewProbe()) return;");
|
||||
expect(restoreSource).toContain("if (showTranspose.value) return;");
|
||||
});
|
||||
|
||||
it("applies the index-based selection directly without scrolling it into view", () => {
|
||||
expect(restoreSource).toContain("restoreCellSelectionState({ cellKeys: new Set(capturedSelection.cellKeys) })");
|
||||
expect(restoreSource).toContain('selectedRowIds.value = new Set(indexes.map((index) => displayItems.value[index]?.id).filter((id): id is number => typeof id === "number"));');
|
||||
expect(restoreSource).toContain("selectedColumnIndexes.value = new Set(capturedSelection.columnIndexes);");
|
||||
expect(restoreSource).toContain("selectedRowIds.value = new Set(displayItems.value.map((item) => item.id));");
|
||||
expect(restoreSource).toContain("selection.lastClickedRowIndex.value = capturedSelection.anchorRowIndex;");
|
||||
expect(restoreSource).not.toContain("restoreSelectionAfterRefresh(snapshot");
|
||||
});
|
||||
|
||||
it("clamps the restored viewport and consumes the snapshot after success", () => {
|
||||
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.top), maxTop)");
|
||||
expect(restoreSource).toContain("Math.min(Math.max(0, snapshot.viewport.left), maxLeft)");
|
||||
// A replayed snapshot is consumed so a later remount cannot re-apply it.
|
||||
expect(restoreSource).toContain("consumeDataGridViewSnapshot(ownerKey);");
|
||||
});
|
||||
|
||||
it("settles the viewport over a bounded retry envelope instead of a fixed two frames", () => {
|
||||
// Immediate attempt, then nextTick, then at most 8 rAFs — mirroring
|
||||
// restoreScrollAcrossFrames. A fixed two-frame assumption intermittently
|
||||
// clamps the saved position to the top before virtualization measures.
|
||||
expect(restoreSource).toContain("if (attempt()) return;");
|
||||
expect(restoreSource).toContain("nextTick(() => {");
|
||||
expect(restoreSource).toContain("frames >= MAX_VIEW_SNAPSHOT_RESTORE_FRAMES");
|
||||
expect(restoreSource).toContain("viewSnapshotRestoreFrame = requestAnimationFrame(onFrame);");
|
||||
// An unmeasured scroller clamps the target to 0; that must not count as a
|
||||
// finished restore, or the saved position is lost at the top.
|
||||
expect(restoreSource).toContain("const roomForSavedTop = maxTop >= snapshot.viewport.top;");
|
||||
expect(restoreSource).toContain("return accepted && (snapshot.viewport.top === 0 || (roomForSavedTop && stable));");
|
||||
expect(dataGridSource).toContain("const MAX_VIEW_SNAPSHOT_RESTORE_FRAMES = 8;");
|
||||
});
|
||||
|
||||
it("honours the rollback switch in both directions", () => {
|
||||
expect(captureFnSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
|
||||
expect(restoreSource).toContain("if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;");
|
||||
expect(dataGridSource).toContain("cancelViewSnapshotRestoreFrame();");
|
||||
});
|
||||
|
||||
it("wires capture to unmount and to the pre-tab-switch event", () => {
|
||||
expect(dataGridSource).toContain('window.addEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
|
||||
expect(dataGridSource).toContain('window.removeEventListener("dbx:before-tab-switch", captureTabSwitchViewSnapshot)');
|
||||
expect(dataGridSource).toContain("onUnmounted(() => {\n // Capture before teardown");
|
||||
expect(dataGridSource).toContain("captureTabSwitchViewSnapshot();");
|
||||
});
|
||||
|
||||
it("reports a dropped selection only after a successful restore", () => {
|
||||
// The capture must not toast while the user is leaving the tab.
|
||||
expect(captureFnSource).not.toContain("toast(");
|
||||
expect(restoreSource).toContain("snapshot.selectionDropped && shouldNotifyOverBudgetSelection(ownerKey");
|
||||
expect(restoreSource).toContain('toast(t("grid.viewSnapshotSelectionNotRestored")');
|
||||
// The notice stays one-shot per owner+generation.
|
||||
expect(dataGridSource).toContain("shouldNotifyOverBudgetSelection(ownerKey, props.viewGeneration!)");
|
||||
});
|
||||
|
||||
it("retries after asynchronous structured-filter hydration", () => {
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = false;");
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationReady.value = true;");
|
||||
expect(dataGridSource).toContain("nextTick(restoreTabSwitchViewSnapshot);");
|
||||
expect(dataGridSource).toContain("structuredFilterHydrationRequestId");
|
||||
});
|
||||
});
|
||||
@@ -1842,6 +1842,7 @@ defineExpose({
|
||||
:cache-key="activeResultGridCacheKey"
|
||||
:column-width-cache-key="activeResultGridColumnWidthCacheKey"
|
||||
:pending-state-key="activeResultGridInstanceKey"
|
||||
:view-generation="activeTab.resultViewGeneration"
|
||||
class="flex-1 min-h-0"
|
||||
:result="activeTab.result"
|
||||
:sort-column="activeTab.resultSortColumn"
|
||||
@@ -2270,6 +2271,7 @@ defineExpose({
|
||||
class="flex-1 min-h-0"
|
||||
:key="activeTab.id"
|
||||
:cache-key="activeTab.id"
|
||||
:view-generation="activeTab.resultViewGeneration"
|
||||
:result="activeTab.result"
|
||||
:sort-column="activeTab.resultSortColumn"
|
||||
:sort-column-index="activeTab.resultSortColumnIndex"
|
||||
|
||||
@@ -2201,6 +2201,7 @@ export default withEnglishFallback({
|
||||
queryError: "Sorğu xətası",
|
||||
saveErrorTitle: "Dəyişiklikləri yadda saxlamaq alınmadı",
|
||||
dataUnavailable: "Cədvəl məlumatları yenidən yüklənməlidir.",
|
||||
viewSnapshotSelectionNotRestored: "Əvvəlki görünüş bərpa edildi, lakin seçim çox böyük olduğu üçün saxlanılmadı.",
|
||||
cachedResultUnavailable: "Keşlənmiş nəticə yoxdur və ya uyğun deyil.",
|
||||
reexecuteQuery: "Sorğunu yenidən işlət",
|
||||
dataUnavailableHintPrefix: "Basın: ",
|
||||
|
||||
@@ -2202,6 +2202,7 @@ export default {
|
||||
queryError: "Query Error",
|
||||
saveErrorTitle: "Failed to Save Changes",
|
||||
dataUnavailable: "Table data needs to be reloaded.",
|
||||
viewSnapshotSelectionNotRestored: "The previous view was restored, but its selection was too large to keep.",
|
||||
cachedResultUnavailable: "The cached result is missing or incompatible.",
|
||||
reexecuteQuery: "Run query again",
|
||||
dataUnavailableHintPrefix: "Press ",
|
||||
|
||||
@@ -2051,6 +2051,7 @@ export default withEnglishFallback({
|
||||
queryError: "Error de consulta",
|
||||
saveErrorTitle: "Error al guardar los cambios",
|
||||
dataUnavailable: "Los datos de la tabla deben volver a cargarse.",
|
||||
viewSnapshotSelectionNotRestored: "Se restauró la vista anterior, pero la selección era demasiado grande para conservarla.",
|
||||
dataUnavailableHintPrefix: "Presiona ",
|
||||
dataUnavailableHintSuffix: " o haz clic en Actualizar abajo para recargar.",
|
||||
refresh: "Actualizar",
|
||||
|
||||
@@ -2049,6 +2049,7 @@ export default withEnglishFallback({
|
||||
queryError: "Errore Query",
|
||||
saveErrorTitle: "Impossibile salvare le modifiche",
|
||||
dataUnavailable: "I dati della tabella devono essere ricaricati.",
|
||||
viewSnapshotSelectionNotRestored: "La vista precedente è stata ripristinata, ma la selezione era troppo grande per essere mantenuta.",
|
||||
dataUnavailableHintPrefix: "Premi ",
|
||||
dataUnavailableHintSuffix: " o fai clic su Aggiorna qui sotto per ricaricare.",
|
||||
refresh: "Aggiorna",
|
||||
|
||||
@@ -2061,6 +2061,7 @@ export default withEnglishFallback({
|
||||
queryError: "クエリエラー",
|
||||
saveErrorTitle: "変更の保存に失敗しました",
|
||||
dataUnavailable: "テーブルデータを再読み込みする必要があります。",
|
||||
viewSnapshotSelectionNotRestored: "前回の表示位置は復元しましたが、選択範囲が大きすぎるため復元できませんでした。",
|
||||
dataUnavailableHintPrefix: "",
|
||||
dataUnavailableHintSuffix: " を押すか、下の更新ボタンをクリックして再読み込みしてください。",
|
||||
refresh: "更新",
|
||||
|
||||
@@ -2025,6 +2025,7 @@ export default withEnglishFallback({
|
||||
queryError: "쿼리 오류",
|
||||
saveErrorTitle: "변경사항 저장 실패",
|
||||
dataUnavailable: "테이블 데이터를 다시 불러와야 합니다.",
|
||||
viewSnapshotSelectionNotRestored: "이전 보기 위치는 복원했지만 선택 범위가 너무 커서 복원하지 못했습니다.",
|
||||
cachedResultUnavailable: "캐시된 결과가 없거나 호환되지 않습니다.",
|
||||
reexecuteQuery: "쿼리 다시 실행",
|
||||
dataUnavailableHintPrefix: "",
|
||||
|
||||
@@ -2051,6 +2051,7 @@ export default withEnglishFallback({
|
||||
queryError: "Erro na Consulta",
|
||||
saveErrorTitle: "Falha ao salvar alterações",
|
||||
dataUnavailable: "Os dados da tabela precisam ser recarregados.",
|
||||
viewSnapshotSelectionNotRestored: "A visualização anterior foi restaurada, mas a seleção era grande demais para ser mantida.",
|
||||
dataUnavailableHintPrefix: "Pressione ",
|
||||
dataUnavailableHintSuffix: " ou clique em Atualizar abaixo para recarregar.",
|
||||
refresh: "Atualizar",
|
||||
|
||||
@@ -2196,6 +2196,7 @@ export default withEnglishFallback({
|
||||
queryError: "Sorgu Hatası",
|
||||
saveErrorTitle: "Değişiklikler Kaydedilemedi",
|
||||
dataUnavailable: "Tablo verisinin yeniden yüklenmesi gerekiyor.",
|
||||
viewSnapshotSelectionNotRestored: "Önceki görünüm geri yüklendi, ancak seçim korunamayacak kadar büyüktü.",
|
||||
cachedResultUnavailable: "Önbelleğe alınmış sonuç eksik veya uyumsuz.",
|
||||
reexecuteQuery: "Sorguyu yeniden çalıştır",
|
||||
dataUnavailableHintPrefix: "Yeniden yüklemek için ",
|
||||
|
||||
@@ -2126,6 +2126,7 @@ export default withEnglishFallback({
|
||||
queryError: "查询出错",
|
||||
saveErrorTitle: "数据修改保存失败",
|
||||
dataUnavailable: "表数据需要重新加载。",
|
||||
viewSnapshotSelectionNotRestored: "已恢复上次的视图位置,但过大的选择范围未能保留。",
|
||||
dataUnavailableHintPrefix: "按 ",
|
||||
dataUnavailableHintSuffix: " 或点击下方刷新按钮重新加载。",
|
||||
cachedResultUnavailable: "缓存结果缺失或不兼容。",
|
||||
|
||||
@@ -2052,6 +2052,7 @@ export default withEnglishFallback({
|
||||
searchColumn: "搜尋欄/註解...",
|
||||
noColumnsFound: "未找到欄",
|
||||
dataUnavailable: "資料表資料需要重新載入。",
|
||||
viewSnapshotSelectionNotRestored: "已還原上次的檢視位置,但過大的選取範圍未能保留。",
|
||||
dataUnavailableHintPrefix: "按 ",
|
||||
dataUnavailableHintSuffix: " 或點擊下方重新整理來重新載入。",
|
||||
queryError: "查詢出錯",
|
||||
|
||||
@@ -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] })),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { createPinia, setActivePinia } from "pinia";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { QueryResult } from "@/types/database";
|
||||
|
||||
function sampleResult(rows: number): QueryResult {
|
||||
return {
|
||||
columns: ["id", "name"],
|
||||
rows: Array.from({ length: rows }, (_, index) => [index, `row-${index}`]),
|
||||
affected_rows: 0,
|
||||
execution_time_ms: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// The first test pays the cold transform of the store module, which can exceed
|
||||
// the default 10s budget on Windows.
|
||||
describe("queryStore resultViewGeneration", { timeout: 30_000 }, () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.unstubAllGlobals();
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it("assigns a new generation when an error result replaces the dataset", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "error", "query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
tab.result = sampleResult(2);
|
||||
tab.resultViewGeneration = "gen-old";
|
||||
|
||||
store.setErrorResult(tabId, new Error("boom"));
|
||||
|
||||
expect(tab.resultViewGeneration).toBeTruthy();
|
||||
expect(tab.resultViewGeneration).not.toBe("gen-old");
|
||||
});
|
||||
|
||||
it("assigns a new generation for a local sort, because row order changes", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "local-sort", "query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
tab.result = {
|
||||
...sampleResult(3),
|
||||
rows: [
|
||||
[3, "c"],
|
||||
[1, "a"],
|
||||
[2, "b"],
|
||||
],
|
||||
};
|
||||
tab.resultViewGeneration = "gen-old";
|
||||
|
||||
store.sortTabResultLocally(tabId, "id", 0, "asc");
|
||||
|
||||
expect(tab.result.rows.map((row) => row[0])).toEqual([1, 2, 3]);
|
||||
expect(tab.resultViewGeneration).toBeTruthy();
|
||||
expect(tab.resultViewGeneration).not.toBe("gen-old");
|
||||
});
|
||||
|
||||
it("inherits the generation when a result is restored from disk", async () => {
|
||||
const cached = sampleResult(2);
|
||||
vi.doMock("@/lib/tabs/tabResultCache", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/tabs/tabResultCache")>();
|
||||
return {
|
||||
...actual,
|
||||
readTabResultSnapshot: vi.fn(async () => ({ result: cached, resultViewGeneration: "gen-disk" }) as any),
|
||||
};
|
||||
});
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "evicted", "query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
tab.resultEvicted = true;
|
||||
tab.resultCacheKey = "tab:cache:key";
|
||||
tab.resultViewGeneration = "gen-disk";
|
||||
|
||||
await store.reloadEvictedTab(tabId);
|
||||
|
||||
expect(tab.result).toBeDefined();
|
||||
expect(tab.resultViewGeneration).toBe("gen-disk");
|
||||
vi.doUnmock("@/lib/tabs/tabResultCache");
|
||||
});
|
||||
|
||||
it("projects the run's generation onto the tab when switching result runs", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "runs", "query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
const first = { id: "run-1", title: "Run 1", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1), resultGridRevision: "rev-1", resultViewGeneration: "gen-run-1" };
|
||||
const second = { id: "run-2", title: "Run 2", sequence: 2, sql: "select 2", createdAt: 2, result: sampleResult(1), resultGridRevision: "rev-2", resultViewGeneration: "gen-run-2" };
|
||||
tab.resultRuns = [first, second];
|
||||
tab.activeResultRunId = "run-1";
|
||||
tab.resultViewGeneration = "gen-run-1";
|
||||
|
||||
await store.setActiveResultRun(tabId, "run-2", { evictInactive: false });
|
||||
|
||||
expect(tab.resultViewGeneration).toBe("gen-run-2");
|
||||
});
|
||||
|
||||
it("does not let a legacy run without a generation inherit the tab's value", async () => {
|
||||
const { useQueryStore } = await import("@/stores/queryStore");
|
||||
const store = useQueryStore();
|
||||
const tabId = store.createTab("pg-1", "app", "legacy-run", "query");
|
||||
const tab = store.tabs.find((item) => item.id === tabId)!;
|
||||
const legacy = { id: "run-legacy", title: "Legacy", sequence: 1, sql: "select 1", createdAt: 1, result: sampleResult(1) };
|
||||
tab.resultRuns = [legacy];
|
||||
tab.activeResultRunId = "run-legacy";
|
||||
tab.resultViewGeneration = "gen-stale";
|
||||
|
||||
await store.setActiveResultRun(tabId, "run-legacy", { evictInactive: false });
|
||||
|
||||
expect(tab.resultViewGeneration).toBeTruthy();
|
||||
expect(tab.resultViewGeneration).not.toBe("gen-stale");
|
||||
});
|
||||
});
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const queryStoreSource = readFileSync(new URL("../queryStore.ts", import.meta.url), "utf8");
|
||||
|
||||
/** Every direct assignment to the token, with its line number. */
|
||||
function directAssignments(): string[] {
|
||||
return queryStoreSource
|
||||
.split("\n")
|
||||
.map((line, index) => ({ line: line.trim(), number: index + 1 }))
|
||||
.filter((entry) => /^(current|tab|run)\.resultViewGeneration\s*=/.test(entry.line))
|
||||
.map((entry) => `${entry.number}: ${entry.line}`);
|
||||
}
|
||||
|
||||
/** The body of executeTabSql, where every result publication must live. */
|
||||
function executeTabSqlBody(): string {
|
||||
const start = queryStoreSource.indexOf("async function executeTabSql(");
|
||||
const end = queryStoreSource.indexOf("\n }\n", start);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
expect(end).toBeGreaterThan(start);
|
||||
return queryStoreSource.slice(start, end);
|
||||
}
|
||||
|
||||
describe("queryStore resultViewGeneration writer", () => {
|
||||
it("assigns the token in exactly the allowed places", () => {
|
||||
const assignments = directAssignments();
|
||||
|
||||
// publishResultGeneration (the writer) plus the two identity-preserving
|
||||
// readers: disk restore inherits, run projection copies the run's value.
|
||||
expect(assignments).toHaveLength(3);
|
||||
expect(assignments.join("\n")).toContain("snapshot.resultViewGeneration");
|
||||
expect(assignments.join("\n")).toContain("run.resultViewGeneration");
|
||||
});
|
||||
|
||||
it("defaults an unclassified publication to a new generation", () => {
|
||||
const start = queryStoreSource.indexOf("function publishResultGeneration(");
|
||||
const end = queryStoreSource.indexOf("function ", start + 10);
|
||||
const body = queryStoreSource.slice(start, end);
|
||||
|
||||
expect(body).toContain('if (origin === "disk-restore") return;');
|
||||
expect(body).toContain('if (origin === "append") {');
|
||||
expect(body).toContain("tab.resultViewGeneration = uuid();");
|
||||
});
|
||||
|
||||
it("publishes a generation for every replacement branch of executeTabSql", () => {
|
||||
const body = executeTabSqlBody();
|
||||
|
||||
// Generic branch: append inherits, everything else replaces.
|
||||
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));');
|
||||
// Redis, MongoDB, and Elasticsearch branches replace the visible result too;
|
||||
// Mongo's append variant must keep the current generation.
|
||||
expect(body).toContain('publishResultGeneration(current, shouldAppendResult ? "append" : "execute");');
|
||||
const publicationCount = body.match(/publishResultGeneration\(current/g)?.length ?? 0;
|
||||
expect(publicationCount).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
|
||||
it("publishes outside executeTabSql where results are also replaced", () => {
|
||||
expect(queryStoreSource).toContain('publishResultGeneration(tab, "local-sort");');
|
||||
expect(queryStoreSource).toContain('publishResultGeneration(tab, "execute");');
|
||||
expect(queryStoreSource).toContain('publishResultGeneration(current, "execute");');
|
||||
});
|
||||
|
||||
it("reserves the closing tombstone for tab closure, not ordinary execution", () => {
|
||||
// Ordinary execution clears the payload before running; the tombstone there
|
||||
// would block capturing the fresh result's view for five seconds.
|
||||
const clearResultPayloadStart = queryStoreSource.indexOf("function clearResultPayload(");
|
||||
const clearResultPayloadEnd = queryStoreSource.indexOf("\n }\n", clearResultPayloadStart);
|
||||
const clearResultPayloadBody = queryStoreSource.slice(clearResultPayloadStart, clearResultPayloadEnd);
|
||||
expect(clearResultPayloadBody).not.toContain("beginClosingDataGridViewSnapshotsForTab");
|
||||
expect(clearResultPayloadBody).toContain("clearDataGridViewSnapshotsForTab(tab.id);");
|
||||
// Tab closure keeps the tombstone so the teardown capture cannot resurrect.
|
||||
expect(queryStoreSource).toContain("beginClosingDataGridViewSnapshotsForTab(id);");
|
||||
expect(queryStoreSource.match(/beginClosingDataGridViewSnapshotsForTab\(tab\.id\)/g)?.length).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,7 @@ import { replaceSqlServerLeadingUseQuery, sqlServerLeadingUseScript, sqlServerUs
|
||||
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 { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
|
||||
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
|
||||
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
|
||||
@@ -1475,6 +1476,12 @@ export const useQueryStore = defineStore("query", () => {
|
||||
if (!options.evicted) {
|
||||
if (tab.resultCacheKey && !options.preserveCacheSnapshot) void deleteTabResultSnapshot(tab.resultCacheKey);
|
||||
tab.resultCacheKey = undefined;
|
||||
// Drop the stale view snapshot but do NOT tombstone: ordinary execution
|
||||
// clears the payload before running, and the replacement result must stay
|
||||
// free to capture a fresh snapshot when the user switches away. Tab
|
||||
// closure (closeTab/closeTabsWhere/releaseTabsWhere) uses the tombstone.
|
||||
// An evicted result keeps its snapshot so returning to the tab can replay it.
|
||||
clearDataGridViewSnapshotsForTab(tab.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1554,6 +1561,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.results = run.results;
|
||||
tab.activeResultIndex = run.activeResultIndex;
|
||||
tab.resultGridRevision = run.resultGridRevision;
|
||||
// A legacy run without the token must not inherit a stale tab value: fail
|
||||
// safe by starting a fresh logical result.
|
||||
tab.resultViewGeneration = run.resultViewGeneration ?? uuid();
|
||||
tab.batchSqlExecution = cloneBatchSqlExecution(run.batchSqlExecution);
|
||||
tab.resultBaseSql = run.resultBaseSql;
|
||||
tab.resultEditorFingerprint = run.resultEditorFingerprint;
|
||||
@@ -1877,6 +1887,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
results: tab.results,
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultGridRevision: tab.resultGridRevision,
|
||||
resultViewGeneration: tab.resultViewGeneration,
|
||||
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
|
||||
resultBaseSql: tab.resultBaseSql,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
@@ -1978,6 +1989,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
results: tab.results,
|
||||
activeResultIndex: tab.activeResultIndex,
|
||||
resultGridRevision: tab.resultGridRevision,
|
||||
resultViewGeneration: tab.resultViewGeneration,
|
||||
batchSqlExecution: cloneBatchSqlExecution(tab.batchSqlExecution),
|
||||
resultBaseSql: tab.resultBaseSql,
|
||||
resultEditorFingerprint: tab.resultEditorFingerprint,
|
||||
@@ -2038,6 +2050,27 @@ export const useQueryStore = defineStore("query", () => {
|
||||
}
|
||||
}
|
||||
|
||||
type ResultPublicationOrigin = "execute" | "refresh" | "page" | "sort" | "local-sort" | "append" | "disk-restore";
|
||||
|
||||
/**
|
||||
* Single writer of `resultViewGeneration`, the logical-result identity used by
|
||||
* the tab-switch view snapshot cache (`dataGridViewStateCache.ts`).
|
||||
*
|
||||
* New value by default, so a path that forgets to classify itself still fails
|
||||
* safe (the old view snapshot stops matching) instead of replaying a stale
|
||||
* viewport. `append` extends the current dataset; `disk-restore` inherits, so
|
||||
* an evicted-then-restored payload keeps its captured view. No other code path
|
||||
* may assign the token directly.
|
||||
*/
|
||||
function publishResultGeneration(tab: QueryTab, origin: ResultPublicationOrigin) {
|
||||
if (origin === "disk-restore") return;
|
||||
if (origin === "append") {
|
||||
tab.resultViewGeneration ??= uuid();
|
||||
return;
|
||||
}
|
||||
tab.resultViewGeneration = uuid();
|
||||
}
|
||||
|
||||
function sortTabResultLocally(id: string, column: string, columnIndex: number, direction: DataGridSortDirection | null) {
|
||||
const tab = tabs.value.find((t) => t.id === id);
|
||||
if (!tab?.result) return;
|
||||
@@ -2059,6 +2092,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
const mongo_copy_documents = originalMongoCopyDocuments ? rowIndexes.map((index) => originalMongoCopyDocuments[index]) : undefined;
|
||||
const large_value_cells = remapLargeValueCells(tab.resultLocalSortOriginalLargeValueCells, rowIndexes);
|
||||
assignDisplayedResult(tab, { ...tab.result, rows, large_value_cells, mongo_documents, mongo_copy_documents });
|
||||
// Reordering rows invalidates source-index view snapshots.
|
||||
publishResultGeneration(tab, "local-sort");
|
||||
|
||||
tab.resultSortColumn = direction ? column : undefined;
|
||||
tab.resultSortColumnIndex = direction ? columnIndex : undefined;
|
||||
@@ -2532,6 +2567,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.executionId = undefined;
|
||||
tab.executingResultRunId = undefined;
|
||||
tab.queryExecutionStartedAt = undefined;
|
||||
// An externally-supplied result is a brand-new dataset, not the previous
|
||||
// one: publish a fresh generation so the tab can capture a view snapshot.
|
||||
publishResultGeneration(tab, "execute");
|
||||
if (tab.result) touchResult(tab);
|
||||
return id;
|
||||
}
|
||||
@@ -3385,6 +3423,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
persistSavedSqlEditorPosition(tabs.value[idx]);
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(id);
|
||||
beginClosingDataGridViewSnapshotsForTab(id);
|
||||
clearDataGridStructuredFilterStatesForTab(id);
|
||||
clearDataGridSearchStatesForTab(id);
|
||||
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
|
||||
@@ -3746,6 +3785,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
.forEach((tab) => {
|
||||
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
clearDataGridSearchStatesForTab(tab.id);
|
||||
if (tab.txnSessionId) void rollbackTransaction(tab.id);
|
||||
@@ -3930,6 +3970,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
await executeTabSql(tab.id, sql, {
|
||||
pagination: { limit, offset },
|
||||
preserveResultDuringExecution: true,
|
||||
publicationOrigin: "refresh",
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -3963,6 +4004,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
.forEach((tab) => {
|
||||
rollbackTabTransaction(tab, { resetAutoCommit: true });
|
||||
clearDataGridPendingSnapshotsForTab(tab.id);
|
||||
beginClosingDataGridViewSnapshotsForTab(tab.id);
|
||||
clearDataGridStructuredFilterStatesForTab(tab.id);
|
||||
clearDataGridSearchStatesForTab(tab.id);
|
||||
if (tab.isExecuting) void cancelTabExecution(tab.id);
|
||||
@@ -4521,6 +4563,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
tab.result = toErrorResult(e);
|
||||
tab.results = undefined;
|
||||
tab.activeResultIndex = undefined;
|
||||
// An error result is a replacement, not the previous dataset.
|
||||
publishResultGeneration(tab, "execute");
|
||||
tab.resultSessionId = undefined;
|
||||
tab.resultClientSessionId = undefined;
|
||||
tab.isExecuting = false;
|
||||
@@ -4550,6 +4594,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.activeResultIndex = undefined;
|
||||
current.resultSessionId = undefined;
|
||||
current.resultClientSessionId = undefined;
|
||||
publishResultGeneration(current, "execute");
|
||||
touchResult(current);
|
||||
}
|
||||
clearLiveBatchSqlExecution(current, executionId);
|
||||
@@ -4584,7 +4629,10 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.resultGridRevision = uuid();
|
||||
if (current.activeResultRunId) syncActiveResultRunFromDisplayed(current);
|
||||
await nextTick();
|
||||
if (previousGridKey && options?.openInNewResultTab !== true) clearDataGridPendingSnapshot(previousGridKey);
|
||||
if (previousGridKey && options?.openInNewResultTab !== true) {
|
||||
clearDataGridPendingSnapshot(previousGridKey);
|
||||
clearDataGridViewSnapshot(previousGridKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
return producedResult;
|
||||
@@ -5458,6 +5506,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
};
|
||||
pagination?: { limit: number; offset: number; sessionId?: string; clientSessionId?: string };
|
||||
appendResult?: { maxRows: number };
|
||||
/** Logical-result publication origin for the view-snapshot cache. */
|
||||
publicationOrigin?: ResultPublicationOrigin;
|
||||
mongoSafety?: MongoAggregateSafetyOptions;
|
||||
preserveResultDuringExecution?: boolean;
|
||||
preserveTotalRowCountDuringExecution?: boolean;
|
||||
@@ -5681,6 +5731,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.activeResultIndex = undefined;
|
||||
current.result = allResults[0];
|
||||
}
|
||||
// Redis command batches always replace the visible result.
|
||||
publishResultGeneration(current, "execute");
|
||||
producedResult = current.result !== undefined;
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
@@ -6066,6 +6118,7 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.activeResultIndex = undefined;
|
||||
current.result = allResults[0];
|
||||
}
|
||||
publishResultGeneration(current, shouldAppendResult ? "append" : "execute");
|
||||
producedResult = current.result !== undefined;
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
@@ -6134,6 +6187,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.results = allResults.length > 1 ? allResults : undefined;
|
||||
current.activeResultIndex = allResults.length > 1 ? resultIndex : undefined;
|
||||
current.result = allResults[resultIndex];
|
||||
// Elasticsearch batch requests replace the visible result.
|
||||
publishResultGeneration(current, "execute");
|
||||
producedResult = current.result !== undefined;
|
||||
touchResult(current);
|
||||
current.queryAnalysis = undefined;
|
||||
@@ -6461,6 +6516,9 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.activeResultIndex = undefined;
|
||||
current.result = results[0];
|
||||
}
|
||||
// Logical-result identity for the view-snapshot cache. An append extends
|
||||
// the same dataset; every other branch above replaces it.
|
||||
publishResultGeneration(current, shouldAppendResult ? "append" : (options?.publicationOrigin ?? "execute"));
|
||||
producedResult = current.result !== undefined;
|
||||
current.resultBaseSql = batchResume ? batchResume.batch.submittedSql : shouldReplaceActiveResultInGroup ? (current.resultBaseSql ?? queryBaseSql) : queryBaseSql;
|
||||
current.resultEditorFingerprint = batchResume ? batchResume.batch.editorFingerprint : shouldReplaceActiveResultInGroup ? (current.resultEditorFingerprint ?? executionEditorFingerprint) : executionEditorFingerprint;
|
||||
@@ -6651,6 +6709,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
current.resultTotalRowCountLoading = false;
|
||||
touchResult(current);
|
||||
producedResult = true;
|
||||
// An error result replaces the dataset the view snapshot was taken on.
|
||||
publishResultGeneration(current, "execute");
|
||||
// When a pinned result requires a new run, errors must use that same
|
||||
// run instead of being replaced by the retained pinned result below.
|
||||
syncDisplayedResultRun(current, queryBaseSql, captureResultRun);
|
||||
@@ -7274,6 +7334,8 @@ export const useQueryStore = defineStore("query", () => {
|
||||
// 置空让 projectResultRun 按需重算
|
||||
tab.resultRuns = snapshot.resultRuns ? markQueryResultRunsRowsRaw(snapshot.resultRuns).map((run) => ({ ...run, resultEstimatedBytes: undefined })) : tab.resultRuns;
|
||||
tab.activeResultRunId = snapshot.activeResultRunId ?? tab.activeResultRunId;
|
||||
// Disk restore is the same logical result: keep the captured view identity.
|
||||
tab.resultViewGeneration = snapshot.resultViewGeneration ?? tab.resultViewGeneration;
|
||||
if (!tab.result && !tab.results && !tab.resultRuns) return false;
|
||||
|
||||
tab.queryAnalysis = snapshot.queryAnalysis;
|
||||
|
||||
@@ -800,6 +800,13 @@ export interface QueryResultRun {
|
||||
pinned?: boolean;
|
||||
/** Distinguishes successive result payloads that reuse the same run slot. */
|
||||
resultGridRevision?: string;
|
||||
/**
|
||||
* Logical-result identity for the tab-switch view snapshot cache. Distinct
|
||||
* from `resultGridRevision` (the grid remount key): this one changes on every
|
||||
* dataset replacement, including in-place refresh, and is preserved across
|
||||
* disk eviction/restore. See `dataGridViewStateCache.ts`.
|
||||
*/
|
||||
resultViewGeneration?: string;
|
||||
result?: QueryResult;
|
||||
results?: QueryResult[];
|
||||
activeResultIndex?: number;
|
||||
@@ -1200,6 +1207,8 @@ export interface QueryTab {
|
||||
activeResultIndex?: number;
|
||||
/** Distinguishes successive result payloads that reuse the current result slot. */
|
||||
resultGridRevision?: string;
|
||||
/** Logical-result identity for the tab-switch view snapshot cache; see QueryResultRun. */
|
||||
resultViewGeneration?: string;
|
||||
resultRuns?: QueryResultRun[];
|
||||
activeResultRunId?: string;
|
||||
resultAutoSave?: boolean;
|
||||
|
||||
Reference in new issue
Block a user