From 348e3a0c70ab8009e35ee4f7a293d1cbef78108d Mon Sep 17 00:00:00 2001 From: zipg Date: Fri, 14 Aug 2026 11:28:10 +0800 Subject: [PATCH] fix(grid): hydrate large values across result changes --- apps/desktop/src/components/grid/DataGrid.vue | 41 ++++++++------- .../DataGridCellDetailSelection.spec.ts | 7 +++ .../dataGrid/dataGridLargeValues.spec.ts | 52 ++++++++++++++++++- .../src/lib/dataGrid/dataGridLargeValues.ts | 21 ++++++++ 4 files changed, 99 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index cc06a70a3b..9e9fb11c55 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -152,7 +152,7 @@ import { isCancelSearchShortcut, isCopyCurrentRowShortcut, isDeleteCurrentRowSho import { dataGridHeaderContentWidth, scrollbarGutterWidth } from "@/lib/dataGrid/dataGridScrollGutter"; import { canFetchNextDataGridSegment, canGoNextDataGridPage, dataGridTotalRowCountLabelKey, dataGridTruncationHintKey, hasCompleteLocalDataGridResult, resolveDataGridPaginationTotal, type DataGridInexactTotalRowCountMode } from "@/lib/dataGrid/dataGridPagination"; import { dataGridCountQueryOptions } from "@/lib/dataGrid/dataGridQueryOptions"; -import { largeValueCellKey, largeValueCellMap, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; +import { createResultScopedPendingRequests, largeValueCellKey, largeValueCellMap, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { dataGridBottomScrollTop, dataGridScrollPosition, isDataGridAtScrollBottom, isDataGridNearScrollBottom, isDataGridPrefixAppend, shouldCheckInfiniteScrollAfterScroll, type DataGridScrollPosition } from "@/lib/dataGrid/dataGridInfiniteScroll"; import { CANVAS_DATA_GRID_ROW_HEIGHT, MAX_CANVAS_DATA_GRID_PIXEL_RATIO, canvasDataGridActionOverlayWidth, canvasDataGridActionReservedWidth, dataGridSearchMatchKey, drawCanvasDataGrid, type CanvasDevicePixelSize } from "@/lib/dataGrid/canvasDataGridRenderer"; import { DATA_GRID_DARK_STRIPED_ROW_BG, DATA_GRID_LIGHT_STRIPED_ROW_BG, dataGridActiveRowBackground } from "@/lib/dataGrid/dataGridPaintTheme"; @@ -4055,7 +4055,7 @@ type LargeValueCellRequest = { const LARGE_VALUE_FETCH_MAX_ROWS = 200; const LARGE_VALUE_FETCH_TARGET_BYTES = 64 * 1024 * 1024; -const pendingLargeValueHydrations = new Map>(); +const pendingLargeValueHydrations = createResultScopedPendingRequests(); const largeValueCellsByKey = computed(() => largeValueCellMap(props.result)); function isLargeValuePreview(item: RowItem | undefined, columnIndex: number): boolean { @@ -4224,33 +4224,30 @@ async function cloneRows(rowIds: number[]) { async function hydrateLargeValueCell(rowId: number, columnIndex: number): Promise { const item = getRowItem(rowId); if (!isLargeValuePreview(item, columnIndex) || item?.sourceIndex === undefined) return true; + const sourceResult = props.result; const hydrationKey = largeValueCellKey(item.sourceIndex, columnIndex); - const pending = pendingLargeValueHydrations.get(hydrationKey); - if (pending) return pending; - const hydration = (async () => { + const operation = dataGridResultLifecycle.beginOperation(); + return pendingLargeValueHydrations.run(hydrationKey, sourceResult, async () => { try { const resolved = await resolveLargeValueCells([rowId], [columnIndex]); + if (!dataGridResultLifecycle.isCurrent(operation) || props.result !== sourceResult) return false; const value = resolved.get(rowId)?.get(columnIndex); if (value === undefined && !resolved.get(rowId)?.has(columnIndex)) return false; - const row = [...(props.result.rows[item.sourceIndex!] ?? [])]; + const row = [...(sourceResult.rows[item.sourceIndex!] ?? [])]; row[columnIndex] = value ?? null; - const rows = props.result.rows.slice(); + const rows = sourceResult.rows.slice(); rows[item.sourceIndex!] = row; - props.result.rows = rows; - props.result.large_value_cells = props.result.large_value_cells?.filter((cell) => cell.row_index !== item.sourceIndex || cell.column_index !== columnIndex); + sourceResult.rows = rows; + sourceResult.large_value_cells = sourceResult.large_value_cells?.filter((cell) => cell.row_index !== item.sourceIndex || cell.column_index !== columnIndex); largeValueResolutionVersion.value += 1; clearCellFormatCache(); - queryStore.invalidateResultEstimateForPayload(props.result); + queryStore.invalidateResultEstimateForPayload(sourceResult); return true; } catch (error) { - reportLargeValueLoadError(error); + if (dataGridResultLifecycle.isCurrent(operation) && props.result === sourceResult) reportLargeValueLoadError(error); return false; - } finally { - pendingLargeValueHydrations.delete(hydrationKey); } - })(); - pendingLargeValueHydrations.set(hydrationKey, hydration); - return hydration; + }); } const exportContextCell = computed(() => { @@ -6650,14 +6647,18 @@ function selectExportMenuItem(value: string) { } // --- Cell selection and detail --- +function hydrateCellDetailTarget(target: { rowIndex: number; col: number }) { + const item = displayItemAt(target.rowIndex); + if (item && isLargeValuePreview(item, target.col)) void hydrateLargeValueCell(item.id, target.col); +} + function showCellDetails(rowIndex: number, colIndex: number) { closeMongoJsonPreview(); resetDetailEdit(); detailCell.value = { rowIndex, col: colIndex }; activeCellDetailTab.value = defaultCellDetailTab(); showCellDetail.value = true; - const item = displayItemAt(rowIndex); - if (item && isLargeValuePreview(item, colIndex)) void hydrateLargeValueCell(item.id, colIndex); + hydrateCellDetailTarget(detailCell.value); } function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, actualColIdx: number) { @@ -6670,8 +6671,7 @@ function showCellDetailsForVisibleCell(rowIndex: number, visibleColIdx: number, function openCellDetailDialog(rowIndex: number, columnIndex: number) { cellDetailDialogTarget.value = { rowIndex, col: columnIndex }; cellDetailDialogOpen.value = true; - const item = displayItemAt(rowIndex); - if (item && isLargeValuePreview(item, columnIndex)) void hydrateLargeValueCell(item.id, columnIndex); + hydrateCellDetailTarget(cellDetailDialogTarget.value); } function openColumnDetailDialog(columnIndex: number) { @@ -6808,6 +6808,7 @@ watch([selectedRange, showCellDetail, isEditingDetail, isSelectingCells], () => }); if (!target) return; detailCell.value = target; + hydrateCellDetailTarget(target); }); function openImagePreview(src: string, title: string) { diff --git a/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts index 4ba105b1a6..794cc325b7 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridCellDetailSelection.spec.ts @@ -7,5 +7,12 @@ describe("DataGrid cell detail selection", () => { it("resynchronizes the open detail after a mouse selection gesture finishes", () => { expect(dataGridSource).toContain("watch([selectedRange, showCellDetail, isEditingDetail, isSelectingCells]"); expect(dataGridSource).toContain("if (isSelectingCells.value) return;"); + expect(dataGridSource).toMatch(/detailCell\.value = target;\s+hydrateCellDetailTarget\(target\);/); + }); + + it("hydrates bounded large-value previews for every cell detail target", () => { + expect(dataGridSource).toMatch(/function hydrateCellDetailTarget[\s\S]*?isLargeValuePreview[\s\S]*?hydrateLargeValueCell/); + expect(dataGridSource).toMatch(/showCellDetails[\s\S]*?hydrateCellDetailTarget\(detailCell\.value\)/); + expect(dataGridSource).toMatch(/openCellDetailDialog[\s\S]*?hydrateCellDetailTarget\(cellDetailDialogTarget\.value\)/); }); }); diff --git a/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts b/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts index cee5320b00..299014a79c 100644 --- a/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts +++ b/apps/desktop/src/lib/__tests__/dataGrid/dataGridLargeValues.spec.ts @@ -1,5 +1,5 @@ -import { describe, expect, it } from "vitest"; -import { appendLargeValueCells, canUseTableDataLargeValuePreview, largeValueCellMap, remapLargeValueCells, TABLE_DATA_CELL_PREVIEW_SIZE, TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; +import { describe, expect, it, vi } from "vitest"; +import { appendLargeValueCells, canUseTableDataLargeValuePreview, createResultScopedPendingRequests, largeValueCellMap, remapLargeValueCells, TABLE_DATA_CELL_PREVIEW_SIZE, TABLE_DATA_PREVIEW_CONTENT_MAX_BYTES, tableDataLargeValuePreviewOptions } from "@/lib/dataGrid/dataGridLargeValues"; import { buildDataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; import type { ColumnInfo } from "@/types/database"; @@ -14,6 +14,14 @@ function column(name: string, dataType: string, isPrimaryKey = false): ColumnInf }; } +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + describe("data grid large-value metadata", () => { it("enables bounded table previews only for supported databases with stable keys", () => { const columns = [column("id", "bigint", true), column("payload", "longtext")]; @@ -97,6 +105,46 @@ describe("data grid large-value metadata", () => { expect(largeValueCellMap(result).get("4:2")).toEqual(result.large_value_cells[0]); }); + it("deduplicates pending requests only within the same result", async () => { + const requests = createResultScopedPendingRequests(); + const result = {}; + const load = deferred(); + const request = vi.fn(() => load.promise); + + const first = requests.run("0:1", result, request); + const second = requests.run("0:1", result, request); + await Promise.resolve(); + + expect(second).toBe(first); + expect(request).toHaveBeenCalledOnce(); + + load.resolve(true); + await expect(first).resolves.toBe(true); + }); + + it("keeps the newer result request after the previous result settles", async () => { + const requests = createResultScopedPendingRequests(); + const previousLoad = deferred(); + const currentLoad = deferred(); + const previousResult = {}; + const currentResult = {}; + const currentRequest = vi.fn(() => currentLoad.promise); + + const previous = requests.run("0:1", previousResult, () => previousLoad.promise); + const current = requests.run("0:1", currentResult, currentRequest); + await Promise.resolve(); + + previousLoad.resolve(false); + await expect(previous).resolves.toBe(false); + + const deduplicatedCurrent = requests.run("0:1", currentResult, currentRequest); + expect(deduplicatedCurrent).toBe(current); + expect(currentRequest).toHaveBeenCalledOnce(); + + currentLoad.resolve(true); + await expect(current).resolves.toBe(true); + }); + it("marks a backend-bounded value as truncated even when the preview is shorter than the UI limit", () => { const detail = buildDataGridCellDetail({ rowIndex: 0, diff --git a/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts b/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts index 3a9a39b6c9..d48af57e58 100644 --- a/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts +++ b/apps/desktop/src/lib/dataGrid/dataGridLargeValues.ts @@ -31,6 +31,27 @@ export function largeValueCellMap(result: Pick return new Map((result.large_value_cells ?? []).map((cell) => [largeValueCellKey(cell.row_index, cell.column_index), cell])); } +export function createResultScopedPendingRequests() { + const pending = new Map }>(); + + return { + run(key: string, result: object, request: () => Promise): Promise { + const current = pending.get(key); + if (current?.result === result) return current.promise; + + let entry: { result: object; promise: Promise }; + const promise = Promise.resolve() + .then(request) + .finally(() => { + if (pending.get(key) === entry) pending.delete(key); + }); + entry = { result, promise }; + pending.set(key, entry); + return promise; + }, + }; +} + export function appendLargeValueCells(previous: QueryResult["large_value_cells"], segment: QueryResult["large_value_cells"], rowOffset: number, appendedRowCount: number): QueryResult["large_value_cells"] { const appended = (segment ?? []).filter((cell) => cell.row_index < appendedRowCount).map((cell) => ({ ...cell, row_index: cell.row_index + rowOffset })); const combined = [...(previous ?? []), ...appended];