fix(mongodb): keep collection results across tab switches

This commit is contained in:
onenewcode authored and GitHub committed 2026-09-10 12:46:18 +08:00
1 parent 1e9a3c0f67
commit 087351f73f
11 files changed
+826 -47

No files matched your search

@@ -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);
});
});