feat(grid): preserve data-grid viewport and selection across tab switches

This commit is contained in:
Abeautifulsnow authored and GitHub committed 2026-09-09 22:36:20 +08:00
1 parent 2b26bd6a1a
commit 41b5e51dcc
21 files changed
+1231 -8

No files matched your search

+287 -7
View File
@@ -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);
});
@@ -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"