fix(grid): hydrate large values across result changes
This commit is contained in:
4 files changed
+99
-22
No files matched your search
@@ -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
@@ -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];
|
||||
|
||||
Reference in new issue
Block a user