fix(mongodb): keep collection results across tab switches

This commit is contained in:
onenewcode
2026-09-10 12:46:18 +08:00
committed by GitHub
parent 1e9a3c0f67
commit 087351f73f
11 changed files with 826 additions and 47 deletions
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, ref, shallowRef, nextTick, watch, onMounted, onBeforeUnmount } from "vue";
import { computed, ref, shallowRef, nextTick, watch, onMounted, onBeforeUnmount, toRaw } from "vue";
import { uuid } from "@/lib/common/utils";
import { useI18n } from "vue-i18n";
import { RefreshCw, Trash2, Plus, Save, ChevronDown, ChevronLeft, ChevronRight, Table2, Braces, X, Search, Wrench, Filter, Columns3Cog, SquareDashed, Minus, Rows3, AlignLeft, AlignRight, EyeOff, Palette, Copy } from "@lucide/vue";
@@ -69,7 +69,7 @@ import {
documentStoreValueForGrid,
} from "@/lib/app/documentJsonValues";
import { applyDocumentStoreIdentityPlan, formatMeilisearchDocumentOperationPreview, insertDocumentStoreDocument as insertDocumentStoreDocumentCore } from "@/lib/app/documentStoreSave";
import { restoreDocumentBrowserState, saveDocumentBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserDataSnapshot } from "@/lib/tabs/documentBrowserStateCache";
import RedisJsonEditor from "@/components/redis/RedisJsonEditor.vue";
import { isLosslessJsonNumber, parseJsonPreservingLargeNumbers } from "@/lib/common/safeJsonFormat";
import {
@@ -122,12 +122,13 @@ const DYNAMODB_DEFAULT_EXPORT_ROW_LIMIT = 10_000;
// This component is keyed by tab in ContentArea and unmounted on every tab
// switch; without restoring from the per-tab cache, coming back to the tab
// would silently drop the user's filter/sort conditions. Page position only
// survives for skip-based paging: cursor stores (DynamoDB/Elasticsearch)
// cannot resume a page without their cursor stacks, and infinite scroll
// always restarts from the first segment.
// would silently drop the user's filter/sort conditions and re-query the
// server. Page position and rows only survive for skip-based paging: cursor
// stores (DynamoDB/Elasticsearch) cannot resume a page without their cursor
// stacks, and infinite scroll always restarts from the first segment.
const restoredDocumentBrowserState = props.stateKey ? restoreDocumentBrowserState(props.stateKey) : undefined;
const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && (documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch");
const skipBasedDocumentStore = documentStoreProviderFor(props.databaseType).kind === "mongodb" || documentStoreProviderFor(props.databaseType).kind === "meilisearch";
const restoresSkipBasedPage = !!restoredDocumentBrowserState && !settingsStore.editorSettings.infiniteScroll && skipBasedDocumentStore;
const documents = ref<JsonRecord[]>([]);
const copyDocuments = ref<JsonRecord[]>([]);
@@ -142,6 +143,11 @@ const mongoCopyDocumentsAvailable = ref(false);
const lastGridColumns = ref<string[]>([]);
const lastGridColumnTypes = ref<string[]>([]);
const total = ref<number | undefined>(undefined);
// Logical-result identity for DataGrid's tab-switch view snapshot. A data tab
// gets this from queryStore.publishResultGeneration; document tabs have no
// store-side result, so the browser mints one per completed load and inherits
// it on an infinite-scroll append and on a cache restore.
const documentViewGeneration = ref<string | undefined>(undefined);
const totalIsExact = ref(true);
const paginationTotal = ref<number | undefined>(undefined);
const loading = ref(false);
@@ -300,7 +306,46 @@ const documentFilterFieldSearch = ref<Record<string, string>>({});
const documentFilterRules = ref<DocumentFilterRule[]>(restoredDocumentBrowserState?.documentFilterRules ?? []);
const appliedDocumentFilter = ref<Record<string, unknown> | null>(restoredDocumentBrowserState?.appliedDocumentFilter ?? null);
function persistDocumentBrowserState() {
// Identity + conditions the currently held rows were loaded under. Rows are
// only worth replaying while this still matches the live inputs; a filter edit
// with a load still in flight would otherwise pair new conditions with stale
// rows on the next remount.
function documentDataSignature(): string | undefined {
try {
return JSON.stringify([documentStoreProvider.value.kind, props.connectionId, props.database, props.collection, currentDocumentFilter() ?? null, currentDocumentSortJson(sortInput.value) ?? null, page.value, pageSize.value, settingsStore.editorSettings.infiniteScroll === true]);
} catch {
// Malformed filter/sort JSON: nothing stable to key rows against.
return undefined;
}
}
let loadedDocumentDataSignature: string | undefined;
function captureDocumentBrowserData(): DocumentBrowserDataSnapshot | undefined {
// Cursor stores (DynamoDB/Elasticsearch) drop their cursor stacks on unmount
// and cannot resume a page without them, so they keep restarting at page 0.
if (!skipBasedDocumentStore) return undefined;
// Never completed a load, mid-flight, or errored — let the remount retry.
if (lastGridColumns.value.length === 0 || loading.value || error.value) return undefined;
const signature = documentDataSignature();
if (!signature || signature !== loadedDocumentDataSignature) return undefined;
return {
signature,
viewGeneration: documentViewGeneration.value,
// Unwrap the reactive proxies: this snapshot outlives the component.
documents: toRaw(documents.value),
copyDocuments: toRaw(copyDocuments.value),
copyDocumentsAvailable: mongoCopyDocumentsAvailable.value,
gridColumns: toRaw(lastGridColumns.value),
gridColumnTypes: toRaw(lastGridColumnTypes.value),
total: total.value,
totalIsExact: totalIsExact.value,
paginationTotal: paginationTotal.value,
selectedIdx: selectedIdx.value,
};
}
function persistDocumentBrowserState(options: { includeData?: boolean } = {}) {
if (!props.stateKey) return;
saveDocumentBrowserState(props.stateKey, {
filterInput: filterInput.value,
@@ -308,10 +353,49 @@ function persistDocumentBrowserState() {
appliedDocumentFilter: appliedDocumentFilter.value,
documentFilterRules: documentFilterRules.value,
page: page.value,
// Any condition change drops the payload; only the unmount capture stores
// rows, so a cached page can never outlive the conditions that produced it.
data: options.includeData ? captureDocumentBrowserData() : undefined,
});
}
watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], persistDocumentBrowserState, { deep: true });
watch([filterInput, sortInput, appliedDocumentFilter, documentFilterRules, page], () => persistDocumentBrowserState(), { deep: true });
// Seed the grid from the cached page so a tab switch costs no round trip
// (#8679). The signature guard rejects a snapshot whose identity or conditions
// no longer match — a changed page-size setting, say — and falls through to a
// normal load.
const restoredDocumentData = skipBasedDocumentStore && restoredDocumentBrowserState?.data && restoredDocumentBrowserState.data.signature === documentDataSignature() ? restoredDocumentBrowserState.data : undefined;
if (restoredDocumentData) {
// Assign the columns before committing so a collection that loaded empty
// stays distinguishable from one that never loaded: commitLoadedDocuments
// reads a non-empty lastGridColumns as "a load has completed", which is what
// drives the refresh toolbar for an empty collection.
lastGridColumns.value = restoredDocumentData.gridColumns;
lastGridColumnTypes.value = restoredDocumentData.gridColumnTypes;
commitLoadedDocuments(restoredDocumentData.documents, restoredDocumentData.copyDocuments, restoredDocumentData.copyDocumentsAvailable, false, documentStoreProvider.value.kind);
total.value = restoredDocumentData.total;
totalIsExact.value = restoredDocumentData.totalIsExact;
paginationTotal.value = restoredDocumentData.paginationTotal;
loadedDocumentDataSignature = restoredDocumentData.signature;
// Same rows as before the switch, so the grid may replay its viewport.
documentViewGeneration.value = restoredDocumentData.viewGeneration;
const restoredSelectedIdx = restoredDocumentData.selectedIdx;
if (restoredSelectedIdx !== null && restoredSelectedIdx >= 0 && restoredSelectedIdx < documents.value.length) {
selectedIdx.value = restoredSelectedIdx;
editJson.value = stringifyDocumentStoreValue(documents.value[restoredSelectedIdx], documentStoreProvider.value.kind, 2);
}
// Keep the grid's "count total rows" action working without a preceding load.
loadedDocumentQueryTotalCountRequest = {
connectionId: props.connectionId,
database: props.database,
collection: props.collection,
filter: currentDocumentFilter(),
generation: documentRequestGeneration,
storeKind: documentStoreProvider.value.kind,
};
}
const elasticsearchMappingFields = ref<ColumnInfo[]>([]);
function elasticsearchGridColumnTypesFor(columns: readonly string[]): string[] {
const mappingTypes = elasticsearchFieldTypes.value;
@@ -1459,6 +1543,12 @@ async function load(options: { page?: number; append?: boolean; offset?: number;
// Commit page + rows together so stale rows never briefly show last-page indexes.
if (options.page !== undefined) page.value = options.page;
commitLoadedDocuments(nextDocuments, nextCopyDocuments, hasTypePreservingCopyDocuments, options.append === true, storeKind);
// Mark which conditions these rows belong to, so the unmount capture can
// tell a still-valid page from one the user has since edited away.
loadedDocumentDataSignature = documentDataSignature();
// A replacement dataset must not adopt the previous viewport; an
// infinite-scroll append keeps rendering the same logical result.
if (options.append !== true || !documentViewGeneration.value) documentViewGeneration.value = uuid();
loadedDocumentQueryTotalCountRequest = countRequest;
if (storeKind === "dynamodb") {
const nextCursors = dynamodbPageCursors.value.slice(0, requestPage + 1);
@@ -2139,7 +2229,9 @@ onMounted(async () => {
window.addEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
unsubscribeElasticsearchIndexCleared = subscribeElasticsearchIndexCleared(handleElasticsearchIndexCleared);
try {
await connectionStore.ensureConnected(props.connectionId);
// A restored tab issues no query, so a blocking health probe here would be
// the only round trip left on the switch.
await connectionStore.ensureConnected(props.connectionId, restoredDocumentData ? { verifyHealth: false } : {});
} catch (e) {
console.warn("[DBX] ensureConnected failed for", props.connectionId, e);
}
@@ -2147,11 +2239,14 @@ onMounted(async () => {
// Mapping metadata enriches the filter builder, but it must not delay the
// first page of documents when the mapping endpoint is slow.
void loadElasticsearchMappingFields();
void load();
// A restored snapshot already holds the rows the last load produced, so a tab
// switch must not re-issue the collection query (#8679). Refresh and every
// mutation path still force a real load.
if (!restoredDocumentData) void load();
void nextTick(resizeDocumentQueryInputs);
});
onBeforeUnmount(() => {
persistDocumentBrowserState();
persistDocumentBrowserState({ includeData: true });
window.removeEventListener("pointerdown", handleDocumentBrowserPointerDown, true);
unsubscribeElasticsearchIndexCleared?.();
unsubscribeElasticsearchIndexCleared = undefined;
@@ -2421,6 +2516,8 @@ defineExpose({ focusSearch });
:database="props.database"
:table-meta="props.tableMeta"
:column-layout-scope-key="documentColumnLayoutScopeKey"
:view-state-key="props.stateKey"
:view-generation="documentViewGeneration"
context="results"
page-size-preference="table-open"
:database-type="props.databaseType"
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight, Download, Filter, LoaderCircle, Plus, RefreshCcw, Trash2, Upload, X } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
@@ -20,13 +20,23 @@ import { clampGridFsPage, gridFsTotalPages, paginateGridFsItems } from "@/lib/do
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useSettingsStore } from "@/stores/settingsStore";
import { restoreGridFsBrowserState, saveGridFsBrowserState } from "@/lib/tabs/documentBrowserStateCache";
const props = defineProps<{
connectionId: string;
database: string;
bucket: string;
/** Tab id; query conditions and the file listing are cached per tab. */
stateKey?: string;
}>();
type GridFsFile = Awaited<ReturnType<typeof api.documentListGridFsFiles>>[number];
// ContentArea renders only the active tab, so this component is unmounted on
// every tab switch. Without the per-tab cache, coming back re-lists the whole
// bucket and drops the user's filter/sort (#8679).
const restoredGridFsState = props.stateKey ? restoreGridFsBrowserState<GridFsFile>(props.stateKey) : undefined;
const { t } = useI18n();
const { toast } = useToast();
const connectionStore = useConnectionStore();
@@ -37,16 +47,16 @@ const uploading = ref(false);
const downloading = ref(false);
const deleting = ref(false);
const error = ref("");
const files = ref<Awaited<ReturnType<typeof api.documentListGridFsFiles>>>([]);
const selectedFileId = ref("");
const files = ref<GridFsFile[]>([]);
const selectedFileId = ref(restoredGridFsState?.selectedId ?? "");
const checkedFileIds = ref<Set<string>>(new Set());
const showDeleteConfirm = ref(false);
const filterInput = ref("");
const sortInput = ref("");
const filterInput = ref(restoredGridFsState?.filterInput ?? "");
const sortInput = ref(restoredGridFsState?.sortInput ?? "");
const filterBuilderOpen = ref(false);
const filterRules = ref<DocumentFilterRule[]>([createGridFsFileFilterRule(uuid())]);
const appliedStructuredFilter = ref<Record<string, unknown> | null>(null);
const page = ref(0);
const filterRules = ref<DocumentFilterRule[]>(restoredGridFsState?.filterRules ?? [createGridFsFileFilterRule(uuid())]);
const appliedStructuredFilter = ref<Record<string, unknown> | null>(restoredGridFsState?.appliedStructuredFilter ?? null);
const page = ref(Math.max(0, Math.trunc(restoredGridFsState?.page ?? 0)));
const pageSize = ref(normalizeResultPageSize(settingsStore.editorSettings.pageSize));
const totalBytes = computed(() => files.value.reduce((sum, file) => sum + (file.length || 0), 0));
@@ -186,12 +196,48 @@ function updatePageSize(nextValue: unknown) {
settingsStore.updateEditorSettings({ pageSize: normalized });
}
function gridFsSignature(): string {
return JSON.stringify([props.connectionId, props.database, props.bucket, filterInput.value, sortInput.value, appliedStructuredFilter.value ?? null]);
}
// Rows are only replayed when the conditions that produced them still match, so
// an edit made while a listing was in flight can never pair new conditions with
// stale rows on the next remount.
let loadedGridFsSignature: string | undefined;
if (restoredGridFsState?.rows && restoredGridFsState.signature === gridFsSignature()) {
files.value = restoredGridFsState.rows;
loadedGridFsSignature = restoredGridFsState.signature;
// Paging is client-side, so the page size setting may have moved while the
// tab was inactive without invalidating the listing itself.
ensurePageInRange();
}
const restoredGridFsFiles = loadedGridFsSignature !== undefined;
function persistGridFsState(options: { includeRows?: boolean } = {}) {
if (!props.stateKey) return;
const signature = gridFsSignature();
const keepRows = options.includeRows === true && !loading.value && !error.value && loadedGridFsSignature === signature;
saveGridFsBrowserState(props.stateKey, {
filterInput: filterInput.value,
sortInput: sortInput.value,
appliedStructuredFilter: appliedStructuredFilter.value,
filterRules: toRaw(filterRules.value),
page: page.value,
selectedId: selectedFileId.value,
signature: keepRows ? signature : undefined,
rows: keepRows ? toRaw(files.value) : undefined,
});
}
watch([filterInput, sortInput, appliedStructuredFilter, filterRules, page, selectedFileId], () => persistGridFsState(), { deep: true });
async function loadFiles() {
loading.value = true;
error.value = "";
try {
const nextFiles = await api.documentListGridFsFiles(props.connectionId, props.database, props.bucket, currentFilesFilter(), currentDocumentSortJson(sortInput.value));
files.value = nextFiles;
loadedGridFsSignature = gridFsSignature();
checkedFileIds.value = new Set(nextFiles.filter((file) => checkedFileIds.value.has(file.id)).map((file) => file.id));
if (selectedFileId.value && !nextFiles.some((file) => file.id === selectedFileId.value)) {
selectedFileId.value = "";
@@ -372,7 +418,12 @@ async function deleteSelectedFile() {
}
onMounted(() => {
void loadFiles();
// A restored listing means the tab switch costs no round trip; every explicit
// refresh and mutation path still calls loadFiles().
if (!restoredGridFsFiles) void loadFiles();
});
onBeforeUnmount(() => {
persistGridFsState({ includeRows: true });
});
watch(
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { computed, onBeforeUnmount, onMounted, ref, toRaw, watch } from "vue";
import { useI18n } from "vue-i18n";
import { ArrowDown, ArrowUp, ArrowUpDown, Filter, FolderOpen, Plus, RefreshCcw, Trash2, X } from "@lucide/vue";
import DangerConfirmDialog from "@/components/editor/DangerConfirmDialog.vue";
@@ -10,6 +10,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { useToast } from "@/composables/useToast";
import * as api from "@/lib/backend/api";
import { currentGridFsBucketFilter, currentGridFsBucketSort, currentGridFsBucketSortDirection, gridFsBucketSortInputForColumn } from "@/lib/document/gridFsBrowser";
import { restoreGridFsBrowserState, saveGridFsBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { useConnectionStore } from "@/stores/connectionStore";
import { connectionIsEffectivelyReadOnly } from "@/lib/database/readOnlyWriteAccess";
import { useQueryStore } from "@/stores/queryStore";
@@ -17,8 +18,17 @@ import { useQueryStore } from "@/stores/queryStore";
const props = defineProps<{
connectionId: string;
database: string;
/** Tab id; query conditions and the listing are cached per tab. */
stateKey?: string;
}>();
type GridFsBucket = Awaited<ReturnType<typeof api.documentListGridFsBuckets>>[number];
// ContentArea renders only the active tab, so this component is unmounted on
// every tab switch. Without the per-tab cache, coming back re-lists every
// bucket and drops the user's filter/sort (#8679).
const restoredGridFsState = props.stateKey ? restoreGridFsBrowserState<GridFsBucket>(props.stateKey) : undefined;
const { t } = useI18n();
const { toast } = useToast();
const queryStore = useQueryStore();
@@ -28,13 +38,13 @@ const loading = ref(false);
const creating = ref(false);
const deleting = ref(false);
const error = ref("");
const buckets = ref<Awaited<ReturnType<typeof api.documentListGridFsBuckets>>>([]);
const selectedBucketName = ref("");
const buckets = ref<GridFsBucket[]>([]);
const selectedBucketName = ref(restoredGridFsState?.selectedId ?? "");
const showCreateDialog = ref(false);
const showDeleteConfirm = ref(false);
const newBucketName = ref("");
const filterInput = ref("");
const sortInput = ref("");
const filterInput = ref(restoredGridFsState?.filterInput ?? "");
const sortInput = ref(restoredGridFsState?.sortInput ?? "");
const filterBuilderOpen = ref(false);
const selectedBucket = computed(() => buckets.value.find((bucket) => bucket.name === selectedBucketName.value) || null);
@@ -49,12 +59,42 @@ function formatBytes(bytes: number): string {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function gridFsSignature(): string {
return JSON.stringify([props.connectionId, props.database, filterInput.value, sortInput.value]);
}
// Rows are only replayed when the conditions that produced them still match, so
// an edit made while a listing was in flight can never pair new conditions with
// stale rows on the next remount.
let loadedGridFsSignature: string | undefined;
if (restoredGridFsState?.rows && restoredGridFsState.signature === gridFsSignature()) {
buckets.value = restoredGridFsState.rows;
loadedGridFsSignature = restoredGridFsState.signature;
}
const restoredGridFsBuckets = loadedGridFsSignature !== undefined;
function persistGridFsState(options: { includeRows?: boolean } = {}) {
if (!props.stateKey) return;
const signature = gridFsSignature();
const keepRows = options.includeRows === true && !loading.value && !error.value && loadedGridFsSignature === signature;
saveGridFsBrowserState(props.stateKey, {
filterInput: filterInput.value,
sortInput: sortInput.value,
selectedId: selectedBucketName.value,
signature: keepRows ? signature : undefined,
rows: keepRows ? toRaw(buckets.value) : undefined,
});
}
watch([filterInput, sortInput, selectedBucketName], () => persistGridFsState());
async function loadBuckets() {
loading.value = true;
error.value = "";
try {
const nextBuckets = await api.documentListGridFsBuckets(props.connectionId, props.database, currentGridFsBucketFilter(filterInput.value), currentGridFsBucketSort(sortInput.value));
buckets.value = nextBuckets;
loadedGridFsSignature = gridFsSignature();
if (selectedBucketName.value && !nextBuckets.some((bucket) => bucket.name === selectedBucketName.value)) {
selectedBucketName.value = "";
}
@@ -148,7 +188,12 @@ async function deleteBucket() {
}
onMounted(() => {
void loadBuckets();
// A restored listing means the tab switch costs no round trip; every explicit
// refresh and mutation path still calls loadBuckets().
if (!restoredGridFsBuckets) void loadBuckets();
});
onBeforeUnmount(() => {
persistGridFsState({ includeRows: true });
});
</script>
@@ -17,7 +17,12 @@ const backend = vi.hoisted(() => ({
const dataGrid = vi.hoisted(() => ({
paginate: undefined as ((offset: number, limit: number) => Promise<void>) | undefined,
sort: undefined as ((column: string, columnIndex: number, direction: "asc" | "desc" | null) => void) | undefined,
reload: undefined as (() => Promise<void>) | undefined,
pageOffset: undefined as number | undefined,
rowCount: undefined as number | undefined,
columnCount: undefined as number | undefined,
viewStateKey: undefined as string | undefined,
viewGeneration: undefined as string | undefined,
}));
const settings = vi.hoisted(() => ({
@@ -76,10 +81,13 @@ vi.mock("@/components/grid/DataGrid.vue", () => {
pageOffset: { type: Number, required: false },
pageLimit: { type: Number, required: false },
pageSizePreference: { type: String, required: false },
viewStateKey: { type: String, required: false },
viewGeneration: { type: String, required: false },
},
setup(props, { attrs, expose, slots }) {
dataGrid.paginate = attrs.onPaginate as typeof dataGrid.paginate;
dataGrid.sort = attrs.onSort as typeof dataGrid.sort;
dataGrid.reload = attrs.onReload as typeof dataGrid.reload;
expose({
visibleColumnCount: 2,
displayableColumnCount: 2,
@@ -102,6 +110,10 @@ vi.mock("@/components/grid/DataGrid.vue", () => {
});
return () => {
dataGrid.pageOffset = props.pageOffset;
dataGrid.viewStateKey = props.viewStateKey;
dataGrid.viewGeneration = props.viewGeneration;
dataGrid.rowCount = (props.result as { rows: unknown[] }).rows.length;
dataGrid.columnCount = (props.result as { columns: unknown[] }).columns.length;
return h("div", [
// Rendering the real search-bar slot exposes the filter/sort inputs
// the same way the actual grid toolbar does.
@@ -151,6 +163,7 @@ vi.mock("@/components/ui/select", async () => {
});
import DocumentBrowser from "@/components/document/DocumentBrowser.vue";
import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
@@ -214,8 +227,15 @@ beforeEach(async () => {
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 2));
dataGrid.paginate = undefined;
dataGrid.sort = undefined;
dataGrid.reload = undefined;
dataGrid.pageOffset = undefined;
dataGrid.rowCount = undefined;
dataGrid.columnCount = undefined;
dataGrid.viewStateKey = undefined;
dataGrid.viewGeneration = undefined;
settings.editorSettings.infiniteScroll = false;
settings.editorSettings.tableOpenPageSize = 2;
resetBrowserStateCaches();
root = document.createElement("div");
document.body.appendChild(root);
@@ -253,14 +273,20 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
await flushUi();
await mountBrowser({ stateKey: "tab-filter-sort" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(4);
const restoredCall = backend.documentFindDocuments.mock.calls[3]!;
expect(restoredCall[5]).toBe(JSON.stringify({ name: "row_1" }));
expect(restoredCall[7]).toBe(JSON.stringify({ name: 1 }));
// The remount replays the cached page instead of re-querying (#8679).
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
const [restoredFilter, restoredSort] = queryTextareas();
expect(restoredFilter.value).toBe('{"name":"row_1"}');
expect(restoredSort.value).toBe(JSON.stringify({ name: 1 }));
// The restored conditions are live, not merely cosmetic: the next query
// built from them still carries the filter and the new sort direction.
dataGrid.sort!("name", 1, "desc");
await flushUi();
const nextCall = backend.documentFindDocuments.mock.calls[3]!;
expect(nextCall[5]).toBe(JSON.stringify({ name: "row_1" }));
expect(nextCall[7]).toBe(JSON.stringify({ name: -1 }));
});
it("restores the page position for skip-based stores", async () => {
@@ -280,9 +306,15 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
await flushUi();
await mountBrowser({ stateKey: "tab-page" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(2);
// Restored from cache: the page position survives with no extra query.
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
expect(dataGrid.pageOffset).toBe(2);
// Paging still works from the restored page.
await dataGrid.paginate!(0, 2);
await flushUi();
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(3);
expect(backend.documentFindDocuments.mock.calls[2]![3]).toBe(0);
});
it("restarts from the first page for cursor-based stores (elasticsearch)", async () => {
@@ -307,6 +339,127 @@ describe("DocumentBrowser tab state (tab switch persistence)", () => {
expect(restoredCall[10]).toBeUndefined();
});
it("does not re-query the collection when a tab switch remounts a mongodb browser", async () => {
await mountBrowser({ stateKey: "tab-no-reload" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
expect(dataGrid.rowCount).toBe(2);
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ stateKey: "tab-no-reload" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
expect(dataGrid.rowCount).toBe(2);
});
it("still forces a real reload from the refresh button after a restore", async () => {
await mountBrowser({ stateKey: "tab-refresh" });
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ stateKey: "tab-refresh" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
await dataGrid.reload!();
await flushUi();
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
});
it("re-queries when the page size changed while the tab was inactive", async () => {
await mountBrowser({ stateKey: "tab-page-size" });
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
settings.editorSettings.tableOpenPageSize = 10;
await mountBrowser({ stateKey: "tab-page-size" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
});
it("re-queries when the previous load failed", async () => {
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockRejectedValueOnce(new Error("boom")).mockResolvedValue(documentResult(1, 2, 2));
await mountBrowser({ stateKey: "tab-error" });
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ stateKey: "tab-error" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
});
it("keeps a loaded-but-empty collection distinguishable from a never-loaded one across a remount", async () => {
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValue({ documents: [], total: 0, total_is_exact: true });
await mountBrowser({ stateKey: "tab-empty" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
const loadedColumnCount = dataGrid.columnCount;
expect(loadedColumnCount).toBeGreaterThan(0);
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ stateKey: "tab-empty" });
// No reload, and the grid still reads as "a load has completed" so the
// refresh toolbar stays available (see the empty-collection behavior).
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
expect(dataGrid.columnCount).toBe(loadedColumnCount);
});
it("re-queries a cursor-based store on remount", async () => {
backend.documentFindDocuments.mockReset();
backend.documentFindDocuments.mockResolvedValue(documentResult(1, 2, 6));
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ databaseType: "elasticsearch", stateKey: "tab-es-reload" });
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(2);
});
it("hands the grid a stable view identity so scroll and selection survive the switch", async () => {
await mountBrowser({ stateKey: "tab-view-state" });
expect(dataGrid.viewStateKey).toBe("tab-view-state");
const generation = dataGrid.viewGeneration;
expect(generation).toBeTruthy();
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mountBrowser({ stateKey: "tab-view-state" });
// Same logical result, so DataGrid may replay its cached viewport.
expect(backend.documentFindDocuments).toHaveBeenCalledTimes(1);
expect(dataGrid.viewGeneration).toBe(generation);
});
it("mints a new view identity when the data is actually reloaded", async () => {
await mountBrowser({ stateKey: "tab-view-generation" });
const generation = dataGrid.viewGeneration;
await dataGrid.reload!();
await flushUi();
// A replacement dataset must not adopt the previous viewport.
expect(dataGrid.viewGeneration).not.toBe(generation);
});
it("keeps tab states isolated from each other", async () => {
await mountBrowser({ stateKey: "tab-a" });
dataGrid.sort!("name", 1, "asc");
@@ -0,0 +1,193 @@
// @vitest-environment happy-dom
import { createApp, defineComponent, h, nextTick, type App } from "vue";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const backend = vi.hoisted(() => ({
documentListGridFsBuckets: vi.fn(),
documentListGridFsFiles: vi.fn(),
}));
const settings = vi.hoisted(() => ({
editorSettings: {
pageSize: 50,
tableOpenPageSize: 50,
infiniteScroll: false,
tableFontFamily: "system-ui",
tableFontSize: 12,
},
updateEditorSettings: vi.fn(),
}));
vi.mock("vue-i18n", async (importOriginal) => ({
...(await importOriginal<typeof import("vue-i18n")>()),
useI18n: () => ({ t: (key: string) => key }),
}));
vi.mock("@/lib/backend/api", () => ({
documentListGridFsBuckets: backend.documentListGridFsBuckets,
documentListGridFsFiles: backend.documentListGridFsFiles,
documentCreateGridFsBucket: vi.fn(),
documentDeleteGridFsBucket: vi.fn(),
documentUploadGridFsFile: vi.fn(),
documentDownloadGridFsFile: vi.fn(),
documentDeleteGridFsFile: vi.fn(),
}));
vi.mock("@/stores/connectionStore", () => ({
useConnectionStore: () => ({ getConfig: () => ({}), ensureConnected: vi.fn() }),
}));
vi.mock("@/stores/queryStore", () => ({
useQueryStore: () => ({ openMongoBucket: vi.fn() }),
}));
vi.mock("@/stores/settingsStore", () => ({
TABLE_FONT_SIZE_MIN: 8,
TABLE_FONT_SIZE_MAX: 16,
useSettingsStore: () => settings,
}));
vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) }));
function passthroughStub(name: string) {
return defineComponent({
name,
inheritAttrs: false,
setup(_, { slots }) {
return () => h("div", slots.default?.());
},
});
}
vi.mock("@/components/ui/popover", () => ({
Popover: passthroughStub("PopoverStub"),
PopoverTrigger: passthroughStub("PopoverTriggerStub"),
PopoverContent: passthroughStub("PopoverContentStub"),
}));
vi.mock("@/components/ui/select", () => ({
Select: passthroughStub("SelectStub"),
SelectContent: passthroughStub("SelectContentStub"),
SelectItem: passthroughStub("SelectItemStub"),
SelectTrigger: passthroughStub("SelectTriggerStub"),
SelectValue: passthroughStub("SelectValueStub"),
}));
import MongoGridFsBrowser from "@/components/document/MongoGridFsBrowser.vue";
import MongoBucketBrowser from "@/components/document/MongoBucketBrowser.vue";
import { resetBrowserStateCaches } from "@/lib/tabs/documentBrowserStateCache";
let app: App<Element> | null = null;
let root: HTMLDivElement | null = null;
async function flushUi() {
for (let index = 0; index < 4; index++) {
await Promise.resolve();
await nextTick();
}
}
async function mount(component: unknown, props: Record<string, unknown>) {
app = createApp(component as Parameters<typeof createApp>[0], props);
app.mount(root!);
await flushUi();
}
async function remount(component: unknown, props: Record<string, unknown>) {
app!.unmount();
app = null;
root!.replaceChildren();
await flushUi();
await mount(component, props);
}
beforeEach(() => {
vi.stubGlobal("localStorage", { getItem: () => null, setItem: () => undefined, removeItem: () => undefined });
backend.documentListGridFsBuckets.mockReset();
backend.documentListGridFsFiles.mockReset();
backend.documentListGridFsBuckets.mockResolvedValue([{ name: "fs", fileCount: 2, totalBytes: 100 }]);
backend.documentListGridFsFiles.mockResolvedValue([
{ id: "1", filename: "a.txt", length: 10, chunkSize: 1, uploadDate: "2026-01-01T00:00:00Z", metadata: null },
{ id: "2", filename: "b.txt", length: 20, chunkSize: 1, uploadDate: "2026-01-02T00:00:00Z", metadata: null },
]);
resetBrowserStateCaches();
root = document.createElement("div");
document.body.appendChild(root);
});
afterEach(() => {
app?.unmount();
root?.remove();
app = null;
root = null;
vi.unstubAllGlobals();
});
describe("MongoGridFsBrowser tab state", () => {
const props = { connectionId: "conn-1", database: "test", stateKey: "gridfs-tab" };
it("does not re-list buckets when a tab switch remounts the browser", async () => {
await mount(MongoGridFsBrowser, props);
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1);
await remount(MongoGridFsBrowser, props);
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(1);
});
it("re-lists when no state key ties the tab to a snapshot", async () => {
await mount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" });
await remount(MongoGridFsBrowser, { connectionId: "conn-1", database: "test" });
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2);
});
it("re-lists when the previous load failed", async () => {
backend.documentListGridFsBuckets.mockReset();
backend.documentListGridFsBuckets.mockRejectedValueOnce(new Error("boom")).mockResolvedValue([]);
await mount(MongoGridFsBrowser, props);
await remount(MongoGridFsBrowser, props);
expect(backend.documentListGridFsBuckets).toHaveBeenCalledTimes(2);
});
});
describe("MongoBucketBrowser tab state", () => {
const props = { connectionId: "conn-1", database: "test", bucket: "fs", stateKey: "bucket-tab" };
it("does not re-list files when a tab switch remounts the browser", async () => {
await mount(MongoBucketBrowser, props);
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1);
await remount(MongoBucketBrowser, props);
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(1);
});
it("re-lists when the bucket changed", async () => {
await mount(MongoBucketBrowser, props);
await remount(MongoBucketBrowser, { ...props, bucket: "other" });
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
});
it("keeps the filter condition across a remount and re-lists under it", async () => {
await mount(MongoBucketBrowser, props);
const filterInput = root!.querySelector<HTMLInputElement>('input[placeholder="{}"]');
expect(filterInput).not.toBeNull();
filterInput!.value = '{"filename":"a.txt"}';
filterInput!.dispatchEvent(new Event("input", { bubbles: true }));
filterInput!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
await flushUi();
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
await remount(MongoBucketBrowser, props);
// The condition survives, and the cached listing means no third request.
const restoredFilter = root!.querySelector<HTMLInputElement>('input[placeholder="{}"]');
expect(restoredFilter?.value).toBe('{"filename":"a.txt"}');
expect(backend.documentListGridFsFiles).toHaveBeenCalledTimes(2);
});
});
+14 -3
View File
@@ -488,6 +488,13 @@ interface DataGridProps {
cacheKey?: string;
columnWidthCacheKey?: string;
pendingStateKey?: string;
/**
* Owner key for the tab-switch view snapshot. Defaults to `cacheKey`, which
* is what query and data tabs use. Document tabs (MongoDB collections) set it
* on its own because `cacheKey` also scopes structured filters, column widths
* and pending edits, and they must keep those scopes as they are (#8679).
*/
viewStateKey?: string;
/**
* Logical-result identity (`QueryTab.resultViewGeneration`) the grid is
* currently rendering. The tab-switch view snapshot is captured with this
@@ -4970,6 +4977,9 @@ function cancelViewSnapshotRestoreFrame() {
}
/** Bounded integrity probe of the result the grid is currently rendering. */
/** Snapshot owner; falls back to `cacheKey` for query and data tabs. */
const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);
function currentViewProbe(): string {
const rows = props.result.rows;
return buildDataGridViewProbe({
@@ -5096,13 +5106,14 @@ function expandRowRange(range: readonly number[], displayCount: number): number[
*/
function captureTabSwitchViewSnapshot() {
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
if (!props.cacheKey || !props.viewGeneration) return;
const ownerKey = viewSnapshotOwnerKey.value;
if (!ownerKey || !props.viewGeneration) return;
if (showTranspose.value) return;
const scroller = useCanvasGridRows.value ? canvasScrollerElement() : gridScrollerElement();
if (!scroller) return;
const { selection, droppedSelection } = captureViewSelection();
saveDataGridViewSnapshot({
ownerKey: props.cacheKey,
ownerKey,
viewGeneration: props.viewGeneration,
probe: currentViewProbe(),
renderer: useCanvasGridRows.value ? "canvas" : "dom",
@@ -5122,7 +5133,7 @@ function captureTabSwitchViewSnapshot() {
function restoreTabSwitchViewSnapshot() {
if (!DATA_GRID_VIEW_SNAPSHOT_RESTORE) return;
if (!structuredFilterHydrationReady.value) return;
const ownerKey = props.cacheKey;
const ownerKey = viewSnapshotOwnerKey.value;
if (!ownerKey || !props.viewGeneration) return;
if (showTranspose.value) return;
const snapshot = peekDataGridViewSnapshot(ownerKey);
@@ -17,13 +17,21 @@ describe("DataGrid tab-switch view snapshots", () => {
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 (!ownerKey || !props.viewGeneration) return;");
expect(captureFnSource).toContain("if (showTranspose.value) return;");
expect(captureFnSource).toContain("ownerKey: props.cacheKey");
expect(captureFnSource).toContain("ownerKey,");
expect(captureFnSource).toContain("viewGeneration: props.viewGeneration");
expect(captureFnSource).toContain("probe: currentViewProbe()");
});
it("takes the snapshot owner from viewStateKey, falling back to cacheKey", () => {
// Document tabs need their own owner key: cacheKey also scopes structured
// filters, column widths and pending edits, which they leave untouched.
expect(dataGridSource).toContain("const viewSnapshotOwnerKey = computed(() => props.viewStateKey?.trim() || props.cacheKey?.trim() || undefined);");
expect(captureFnSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
expect(restoreSource).toContain("const ownerKey = viewSnapshotOwnerKey.value;");
});
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).
@@ -2427,13 +2427,13 @@ defineExpose({
<template v-else-if="activeTab.mode === 'mongo-gridfs'">
<div class="flex-1 min-h-0">
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" />
<MongoGridFsBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :state-key="activeTab.id" />
</div>
</template>
<template v-else-if="activeTab.mode === 'mongo-bucket'">
<div class="flex-1 min-h-0">
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" />
<MongoBucketBrowser :key="activeTab.id" :connection-id="activeTab.connectionId" :database="activeTab.database" :bucket="activeTab.mongoBucket?.bucketName || activeTab.sql" :state-key="activeTab.id" />
</div>
</template>
@@ -1,5 +1,18 @@
import { describe, expect, it } from "vitest";
import { clearDocumentBrowserState, restoreDocumentBrowserState, saveDocumentBrowserState, type DocumentBrowserStateSnapshot } from "@/lib/tabs/documentBrowserStateCache";
import { beforeEach, describe, expect, it } from "vitest";
import {
beginClosingBrowserState,
clearDocumentBrowserState,
clearGridFsBrowserState,
MAX_DATA_ENTRIES,
MAX_RESTORED_DOCUMENT_ROWS,
resetBrowserStateCaches,
restoreDocumentBrowserState,
restoreGridFsBrowserState,
saveDocumentBrowserState,
saveGridFsBrowserState,
type DocumentBrowserDataSnapshot,
type DocumentBrowserStateSnapshot,
} from "@/lib/tabs/documentBrowserStateCache";
function snapshot(page: number): DocumentBrowserStateSnapshot {
return {
@@ -11,6 +24,25 @@ function snapshot(page: number): DocumentBrowserStateSnapshot {
};
}
function data(rowCount: number, signature = "sig"): DocumentBrowserDataSnapshot {
return {
signature,
documents: Array.from({ length: rowCount }, (_, index) => ({ _id: String(index) })),
copyDocuments: [],
copyDocumentsAvailable: false,
gridColumns: ["_id"],
gridColumnTypes: ["objectId"],
total: rowCount,
totalIsExact: true,
paginationTotal: rowCount,
selectedIdx: null,
};
}
beforeEach(() => {
resetBrowserStateCaches();
});
describe("documentBrowserStateCache", () => {
it("round-trips a snapshot for the same key", () => {
saveDocumentBrowserState("round-trip", snapshot(3));
@@ -56,4 +88,75 @@ describe("documentBrowserStateCache", () => {
expect(restoreDocumentBrowserState("clear-me")).toBeUndefined();
});
it("round-trips a row payload alongside the conditions", () => {
saveDocumentBrowserState("with-data", { ...snapshot(1), data: data(2) });
expect(restoreDocumentBrowserState("with-data")?.data?.documents).toHaveLength(2);
});
it("drops a stored payload on a conditions-only save", () => {
saveDocumentBrowserState("drop-data", { ...snapshot(1), data: data(2) });
saveDocumentBrowserState("drop-data", snapshot(1));
expect(restoreDocumentBrowserState("drop-data")?.data).toBeUndefined();
});
it("drops a payload larger than the row bound but keeps the conditions", () => {
saveDocumentBrowserState("too-big", { ...snapshot(2), data: data(MAX_RESTORED_DOCUMENT_ROWS + 1) });
const restored = restoreDocumentBrowserState("too-big");
expect(restored?.data).toBeUndefined();
expect(restored?.filterInput).toBe(snapshot(2).filterInput);
});
it("keeps payloads only for the most recently used tabs", () => {
for (let index = 0; index < MAX_DATA_ENTRIES + 2; index += 1) {
saveDocumentBrowserState(`payload-${index}`, { ...snapshot(index), data: data(1) });
}
expect(restoreDocumentBrowserState("payload-0")?.data).toBeUndefined();
expect(restoreDocumentBrowserState("payload-0")?.filterInput).toBe(snapshot(0).filterInput);
expect(restoreDocumentBrowserState(`payload-${MAX_DATA_ENTRIES + 1}`)?.data).toBeDefined();
});
it("blocks the unmount capture from resurrecting a closed tab", () => {
saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) });
beginClosingBrowserState("closing");
// DocumentBrowser unmounts after the store removed the tab and writes again.
saveDocumentBrowserState("closing", { ...snapshot(1), data: data(1) });
expect(restoreDocumentBrowserState("closing")).toBeUndefined();
});
});
describe("gridFsBrowserStateCache", () => {
it("round-trips conditions and rows", () => {
saveGridFsBrowserState("gridfs-a", { filterInput: "{}", sortInput: "", signature: "sig", rows: [{ name: "fs" }] });
const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-a");
expect(restored?.rows).toEqual([{ name: "fs" }]);
expect(restored?.signature).toBe("sig");
});
it("drops an oversized listing but keeps the conditions", () => {
const rows = Array.from({ length: MAX_RESTORED_DOCUMENT_ROWS + 1 }, (_, index) => ({ name: String(index) }));
saveGridFsBrowserState("gridfs-big", { filterInput: "{}", sortInput: "", signature: "sig", rows });
const restored = restoreGridFsBrowserState<{ name: string }>("gridfs-big");
expect(restored?.rows).toBeUndefined();
expect(restored?.signature).toBeUndefined();
expect(restored?.filterInput).toBe("{}");
});
it("clears a key on demand and on close", () => {
saveGridFsBrowserState("gridfs-clear", { filterInput: "", sortInput: "" });
clearGridFsBrowserState("gridfs-clear");
expect(restoreGridFsBrowserState("gridfs-clear")).toBeUndefined();
saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" });
beginClosingBrowserState("gridfs-closing");
saveGridFsBrowserState("gridfs-closing", { filterInput: "", sortInput: "" });
expect(restoreGridFsBrowserState("gridfs-closing")).toBeUndefined();
});
});
@@ -1,5 +1,27 @@
import type { DocumentFilterRule } from "@/lib/app/documentStoreProvider";
/**
* Rows and totals a completed load produced, replayed on remount instead of
* re-querying. `signature` pins the identity and conditions they were loaded
* under (store kind, connection/database/collection, filter, sort, page, page
* size, infinite-scroll mode); a restore that does not match it falls through
* to a normal load.
*/
export interface DocumentBrowserDataSnapshot {
signature: string;
/** DataGrid's logical-result identity, so it may replay scroll and selection. */
viewGeneration?: string;
documents: Record<string, unknown>[];
copyDocuments: Record<string, unknown>[];
copyDocumentsAvailable: boolean;
gridColumns: string[];
gridColumnTypes: string[];
total?: number;
totalIsExact: boolean;
paginationTotal?: number;
selectedIdx: number | null;
}
export interface DocumentBrowserStateSnapshot {
/** Manual JSON filter text from the collection filter input. */
filterInput: string;
@@ -11,15 +33,55 @@ export interface DocumentBrowserStateSnapshot {
documentFilterRules: DocumentFilterRule[];
/** Zero-based page position; only meaningful for skip-based stores. */
page: number;
/**
* Rows from the last completed load. Written only by the unmount capture; a
* conditions-only save (the keystroke path) drops it, which is the whole
* invalidation story — a dropped payload degrades to today's reload.
*/
data?: DocumentBrowserDataSnapshot;
}
// ContentArea renders only the active tab, so DocumentBrowser is unmounted on
// every tab switch and remounted when the user comes back. Query conditions
// survive that round trip through this session-scoped cache keyed by tab id —
// the same role tab.whereInput/tab.orderByInput play for SQL data tabs. The
// bound keeps state for long tab sessions from accumulating forever.
// every tab switch and remounted when the user comes back. Query conditions and
// the rows they produced survive that round trip through this session-scoped
// cache keyed by tab id — the same role tab.whereInput/tab.orderByInput and
// tab.result play for SQL data tabs, which have no equivalent here. The bounds
// keep state for long tab sessions from accumulating forever.
const MAX_ENTRIES = 32;
/** Payloads larger than this are dropped; the tab reloads instead. */
export const MAX_RESTORED_DOCUMENT_ROWS = 2_000;
/** Only the most recently used tabs keep rows; older ones keep conditions. */
export const MAX_DATA_ENTRIES = 8;
const cache = new Map<string, DocumentBrowserStateSnapshot>();
const gridFsCache = new Map<string, GridFsBrowserStateSnapshot<unknown>>();
const closingKeys = new Set<string>();
/**
* Clear a tab's cached state and block new writes for a short window, so the
* `onBeforeUnmount` capture cannot resurrect an entry after the tab is closed.
* Mirrors `beginClosingDataGridViewSnapshotsForTab`.
*/
export function beginClosingBrowserState(stateKey: string): void {
closingKeys.add(stateKey);
// Some test environments stub `window` without timers, so a bare
// `window.setTimeout` would throw while closing a tab.
const schedule = typeof window !== "undefined" && typeof window.setTimeout === "function" ? window.setTimeout.bind(window) : setTimeout;
schedule(() => closingKeys.delete(stateKey), 5000);
cache.delete(stateKey);
gridFsCache.delete(stateKey);
}
function trimDataPayloads(): void {
const keysNewestFirst = [...cache.keys()].reverse();
let kept = 0;
for (const key of keysNewestFirst) {
const entry = cache.get(key);
if (!entry?.data) continue;
kept += 1;
if (kept > MAX_DATA_ENTRIES) cache.set(key, { ...entry, data: undefined });
}
}
export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserStateSnapshot | undefined {
const snapshot = cache.get(stateKey);
@@ -31,15 +93,67 @@ export function restoreDocumentBrowserState(stateKey: string): DocumentBrowserSt
}
export function saveDocumentBrowserState(stateKey: string, snapshot: DocumentBrowserStateSnapshot): void {
if (closingKeys.has(stateKey)) return;
const bounded = snapshot.data && snapshot.data.documents.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, data: undefined } : snapshot;
cache.delete(stateKey);
cache.set(stateKey, snapshot);
cache.set(stateKey, bounded);
while (cache.size > MAX_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest === undefined) break;
cache.delete(oldest);
}
trimDataPayloads();
}
export function clearDocumentBrowserState(stateKey: string): void {
cache.delete(stateKey);
}
/**
* GridFS bucket and file listings reload on every remount for the same reason
* DocumentBrowser does, and never got the per-tab condition cache at all. Their
* payloads are small metadata rows, so one entry per tab carries both the query
* conditions and the listing.
*/
export interface GridFsBrowserStateSnapshot<TRow> {
filterInput: string;
sortInput: string;
appliedStructuredFilter?: Record<string, unknown> | null;
filterRules?: DocumentFilterRule[];
page?: number;
selectedId?: string;
/** Identity + conditions the rows were listed under. */
signature?: string;
rows?: TRow[];
}
export function restoreGridFsBrowserState<TRow>(stateKey: string): GridFsBrowserStateSnapshot<TRow> | undefined {
const snapshot = gridFsCache.get(stateKey);
if (!snapshot) return undefined;
gridFsCache.delete(stateKey);
gridFsCache.set(stateKey, snapshot);
return snapshot as GridFsBrowserStateSnapshot<TRow>;
}
export function saveGridFsBrowserState<TRow>(stateKey: string, snapshot: GridFsBrowserStateSnapshot<TRow>): void {
if (closingKeys.has(stateKey)) return;
const bounded = snapshot.rows && snapshot.rows.length > MAX_RESTORED_DOCUMENT_ROWS ? { ...snapshot, rows: undefined, signature: undefined } : snapshot;
gridFsCache.delete(stateKey);
gridFsCache.set(stateKey, bounded as GridFsBrowserStateSnapshot<unknown>);
while (gridFsCache.size > MAX_ENTRIES) {
const oldest = gridFsCache.keys().next().value;
if (oldest === undefined) break;
gridFsCache.delete(oldest);
}
}
export function clearGridFsBrowserState(stateKey: string): void {
gridFsCache.delete(stateKey);
}
/** Test seam: drop everything. */
export function resetBrowserStateCaches(): void {
cache.clear();
gridFsCache.clear();
closingKeys.clear();
}
+4
View File
@@ -62,6 +62,7 @@ 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 { beginClosingBrowserState } from "@/lib/tabs/documentBrowserStateCache";
import { clearDataGridStructuredFilterStatesForTab } from "@/lib/dataGrid/dataGridFilterBuilderPersistence";
import { clearDataGridSearchStatesForTab } from "@/lib/dataGrid/dataGridSearchStatePersistence";
import { buildTabResultSnapshot, deleteTabResultSnapshot, pruneTabResultSnapshots, readTabResultSnapshot, tabResultCacheKey, writeTabResultSnapshot } from "@/lib/tabs/tabResultCache";
@@ -3428,6 +3429,7 @@ export const useQueryStore = defineStore("query", () => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(id);
beginClosingDataGridViewSnapshotsForTab(id);
beginClosingBrowserState(id);
clearDataGridStructuredFilterStatesForTab(id);
clearDataGridSearchStatesForTab(id);
if (tabs.value[idx].txnSessionId) void rollbackTransaction(id);
@@ -3790,6 +3792,7 @@ export const useQueryStore = defineStore("query", () => {
if (tab.mode === "sqlserver-trace") void disposeSqlServerActivityTrace(tab.id);
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.txnSessionId) void rollbackTransaction(tab.id);
@@ -4009,6 +4012,7 @@ export const useQueryStore = defineStore("query", () => {
rollbackTabTransaction(tab, { resetAutoCommit: true });
clearDataGridPendingSnapshotsForTab(tab.id);
beginClosingDataGridViewSnapshotsForTab(tab.id);
beginClosingBrowserState(tab.id);
clearDataGridStructuredFilterStatesForTab(tab.id);
clearDataGridSearchStatesForTab(tab.id);
if (tab.isExecuting) void cancelTabExecution(tab.id);