fix(grid): hydrate large values across result changes

This commit is contained in:
zipg authored and GitHub committed 2026-08-14 11:28:10 +08:00
1 parent 56cacd0441
commit 348e3a0c70
4 files changed
+99 -22

No files matched your search

+21 -20
View File
@@ -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<string, Promise<boolean>>();
const pendingLargeValueHydrations = createResultScopedPendingRequests<boolean>();
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<boolean> {
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) {
@@ -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\)/);
});
});
@@ -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<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((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<boolean>();
const result = {};
const load = deferred<boolean>();
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<boolean>();
const previousLoad = deferred<boolean>();
const currentLoad = deferred<boolean>();
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,
@@ -31,6 +31,27 @@ export function largeValueCellMap(result: Pick<QueryResult, "large_value_cells">
return new Map((result.large_value_cells ?? []).map((cell) => [largeValueCellKey(cell.row_index, cell.column_index), cell]));
}
export function createResultScopedPendingRequests<T>() {
const pending = new Map<string, { result: object; promise: Promise<T> }>();
return {
run(key: string, result: object, request: () => Promise<T>): Promise<T> {
const current = pending.get(key);
if (current?.result === result) return current.promise;
let entry: { result: object; promise: Promise<T> };
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];