From a216e0938e57460ae2f4ed2e194bf1d31352e5bf Mon Sep 17 00:00:00 2001 From: jearhe <0verme7753@gmail.com> Date: Fri, 11 Sep 2026 10:52:19 +0800 Subject: [PATCH] fix(grid): restore long cell values in the detail editor --- apps/desktop/src/components/grid/DataGrid.vue | 13 ++- .../grid/__tests__/DataGridSurfaces.spec.ts | 8 ++ .../__tests__/useDataGridCellDetail.spec.ts | 47 +++++++++ .../useDataGridCellDetailEdit.spec.ts | 95 ++++++++++++++++++- .../src/composables/useDataGridCellDetail.ts | 12 ++- .../composables/useDataGridCellDetailEdit.ts | 9 +- 6 files changed, 177 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/components/grid/DataGrid.vue b/apps/desktop/src/components/grid/DataGrid.vue index 257cb9b1b8..94d8573b1a 100644 --- a/apps/desktop/src/components/grid/DataGrid.vue +++ b/apps/desktop/src/components/grid/DataGrid.vue @@ -5786,7 +5786,16 @@ watch(valueEditorContainer, async (el) => { fontSize: editorFontSize, fontFamily: detailEditorFontFamily, }); - await valueDetailEditor.create(el, detailEditValue.value, activeCellDetail.value?.type); + const editor = valueDetailEditor; + await editor.create(el, detailEditValue.value, activeCellDetail.value?.type); + // The editor initializes asynchronously (theme loading can yield here), so + // detailEditValue may have changed before CodeMirror owns the document. + // Reconcile the latest value after create and ignore an editor replaced by + // a fast tab unmount/remount. + if (valueDetailEditor !== editor) return; + if (editor.getValue() !== detailEditValue.value) { + editor.setValue(detailEditValue.value, activeCellDetail.value?.type); + } } else if (!el && valueDetailEditor) { valueDetailEditor.destroy(); valueDetailEditor = null; @@ -5809,7 +5818,7 @@ const detailEdit = useDataGridCellDetailEdit({ restoreCellValue, syncEditor: (value, columnType) => { const editor = getDetailEditor(); - if (editor) editor.setValue(value, columnType); + if (editor && editor.getValue() !== value) editor.setValue(value, columnType); }, refreshDetail: () => { detailCell.value = detailCell.value ? { ...detailCell.value } : null; diff --git a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts index af603c023f..680c5168f0 100644 --- a/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts +++ b/apps/desktop/src/components/grid/__tests__/DataGridSurfaces.spec.ts @@ -1392,6 +1392,14 @@ describe("cell detail surfaces", () => { } expect(dataGridSource).not.toContain("activeCellDetailTabsGridClass"); + const valueEditorLifecycleStart = dataGridSource.indexOf("watch(valueEditorContainer"); + const valueEditorLifecycleEnd = dataGridSource.indexOf("const detailEdit = useDataGridCellDetailEdit", valueEditorLifecycleStart); + const valueEditorLifecycle = dataGridSource.slice(valueEditorLifecycleStart, valueEditorLifecycleEnd); + expect(valueEditorLifecycle).toContain("const editor = valueDetailEditor;"); + expect(valueEditorLifecycle).toContain("if (valueDetailEditor !== editor) return;"); + expect(valueEditorLifecycle).toContain("if (editor.getValue() !== detailEditValue.value)"); + expect(valueEditorLifecycle).toContain("editor.setValue(detailEditValue.value, activeCellDetail.value?.type);"); + const valueEditorStart = dataGridSource.indexOf("", valueEditorStart); const valueEditor = dataGridSource.slice(valueEditorStart, valueEditorEnd); diff --git a/apps/desktop/src/composables/__tests__/useDataGridCellDetail.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridCellDetail.spec.ts index e93bea5cba..83f20ab914 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridCellDetail.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridCellDetail.spec.ts @@ -106,4 +106,51 @@ describe("useDataGridCellDetail", () => { await nextTick(); scope.stop(); }); + + it("reconciles the latest long value after asynchronous editor creation", async () => { + const scope = effectScope(); + const editValue = ref(""); + let finishCreate!: () => void; + mocks.create.mockImplementationOnce(() => new Promise((resolve) => (finishCreate = resolve))); + const composable = scope.run(() => useDataGridCellDetail({ detail: ref(detail()), editValue, onCancel: vi.fn() }))!; + + composable.detailsEditorContainer.value = document.createElement("div"); + await nextTick(); + expect(finishCreate).toBeTypeOf("function"); + + const longValue = "x".repeat(15000); + editValue.value = longValue; + await nextTick(); + // The real editor ignores setValue until create has installed its view. + mocks.setValue.mockClear(); + + finishCreate(); + await Promise.resolve(); + await nextTick(); + + expect(mocks.setValue).toHaveBeenCalledWith(longValue, "VARCHAR"); + + composable.detailsEditorContainer.value = undefined; + await nextTick(); + scope.stop(); + }); + + it("does not reconcile an editor that was destroyed during creation", async () => { + const scope = effectScope(); + let finishCreate!: () => void; + mocks.create.mockImplementationOnce(() => new Promise((resolve) => (finishCreate = resolve))); + const composable = scope.run(() => useDataGridCellDetail({ detail: ref(detail()), editValue: ref(""), onCancel: vi.fn() }))!; + + composable.detailsEditorContainer.value = document.createElement("div"); + await nextTick(); + mocks.setValue.mockClear(); + composable.detailsEditorContainer.value = undefined; + await nextTick(); + expect(mocks.destroy).toHaveBeenCalledOnce(); + + finishCreate(); + await Promise.resolve(); + expect(mocks.setValue).not.toHaveBeenCalled(); + scope.stop(); + }); }); diff --git a/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts b/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts index 21dfc1ab96..d27b400e2f 100644 --- a/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts +++ b/apps/desktop/src/composables/__tests__/useDataGridCellDetailEdit.spec.ts @@ -6,7 +6,7 @@ import { useDataGridCellDetailEdit } from "@/composables/useDataGridCellDetailEd import { MONGO_DOCUMENT_GRID_NULL } from "@/lib/mongo/mongoDocumentValues"; import type { DataGridCellDetail } from "@/lib/dataGrid/dataGridDetail"; -function detail(value: string): DataGridCellDetail { +function detail(value: string, patch: Partial = {}): DataGridCellDetail { return { rowNumber: 1, rowId: 3, @@ -24,6 +24,7 @@ function detail(value: string): DataGridCellDetail { length: value.length, formattedJson: "", isEditable: true, + ...patch, }; } @@ -59,4 +60,96 @@ describe("useDataGridCellDetailEdit", () => { editor.setDetailNull(); expect(applyCellValue).toHaveBeenLastCalledWith(3, 1, MONGO_DOCUMENT_GRID_NULL); }); + + it("synchronizes the latest long value after asynchronous hydration", async () => { + const previewValue = "preview"; + const longValue = "x".repeat(15000); + const activeDetail = ref(detail(previewValue)); + let finishHydration!: (result: boolean) => void; + const syncEditor = vi.fn(); + const editor = useDataGridCellDetailEdit({ + activeDetail: computed(() => activeDetail.value), + activeTab: ref("valueEditor"), + jsonFormatted: computed(() => false), + databaseType: computed(() => "mongodb"), + resultRows: computed(() => [["id", previewValue]]), + getColumnInfo: () => undefined, + getRowItem: () => ({ sourceIndex: 0, isNew: false, isDeleted: false }), + hydrateLargeValueCell: () => new Promise((resolve) => (finishHydration = resolve)), + applyCellValue: vi.fn(), + restoreCellValue: vi.fn(), + syncEditor, + refreshDetail: vi.fn(), + warnFormattedJsonEdit: vi.fn(), + }); + + const start = editor.startDetailEdit(); + activeDetail.value = detail(longValue); + finishHydration(true); + await start; + + expect(editor.detailEditValue.value).toBe(longValue); + expect(syncEditor).toHaveBeenLastCalledWith(longValue, ""); + }); + + it("updates an already mounted editor when the selected cell changes", async () => { + const activeDetail = ref(detail("short")); + const activeTab = ref<"details" | "valueEditor">("details"); + const syncEditor = vi.fn(); + const editor = useDataGridCellDetailEdit({ + activeDetail: computed(() => activeDetail.value), + activeTab, + jsonFormatted: computed(() => false), + databaseType: computed(() => "mongodb"), + resultRows: computed(() => [["id", "short"]]), + getColumnInfo: () => undefined, + getRowItem: () => ({ sourceIndex: 0, isNew: false, isDeleted: false }), + hydrateLargeValueCell: async () => true, + applyCellValue: vi.fn(), + restoreCellValue: vi.fn(), + syncEditor, + refreshDetail: vi.fn(), + warnFormattedJsonEdit: vi.fn(), + }); + + activeTab.value = "valueEditor"; + await Promise.resolve(); + await Promise.resolve(); + syncEditor.mockClear(); + + const longValue = "x".repeat(15000); + activeDetail.value = detail(longValue, { rowId: 4 }); + await Promise.resolve(); + + expect(editor.detailEditValue.value).toBe(longValue); + expect(syncEditor).toHaveBeenLastCalledWith(longValue, ""); + }); + + it("ignores a late async start after the detail editor is reset", async () => { + const activeDetail = ref(detail("preview")); + let finishHydration!: (result: boolean) => void; + const editor = useDataGridCellDetailEdit({ + activeDetail: computed(() => activeDetail.value), + activeTab: ref("valueEditor"), + jsonFormatted: computed(() => false), + databaseType: computed(() => "mongodb"), + resultRows: computed(() => [["id", "preview"]]), + getColumnInfo: () => undefined, + getRowItem: () => ({ sourceIndex: 0, isNew: false, isDeleted: false }), + hydrateLargeValueCell: () => new Promise((resolve) => (finishHydration = resolve)), + applyCellValue: vi.fn(), + restoreCellValue: vi.fn(), + syncEditor: vi.fn(), + refreshDetail: vi.fn(), + warnFormattedJsonEdit: vi.fn(), + }); + + const start = editor.startDetailEdit(); + editor.resetDetailEdit(); + finishHydration(true); + await start; + + expect(editor.isEditingDetail.value).toBe(false); + expect(editor.detailEditValue.value).toBe(""); + }); }); diff --git a/apps/desktop/src/composables/useDataGridCellDetail.ts b/apps/desktop/src/composables/useDataGridCellDetail.ts index dd02978b86..22af4d1bd8 100644 --- a/apps/desktop/src/composables/useDataGridCellDetail.ts +++ b/apps/desktop/src/composables/useDataGridCellDetail.ts @@ -36,6 +36,9 @@ export function useDataGridCellDetail(options: { detail: Ref const editor = useCellDetailEditor({ onChange: (value) => (options.editValue.value = value), onEscape: options.onCancel, ...editorOptions() }); detailsEditor = editor; await editor.create(element, options.editValue.value, options.detail.value.type); + if (detailsEditor === editor && editor.getValue() !== options.editValue.value) { + editor.setValue(options.editValue.value, options.detail.value.type); + } if (detailsEditor === editor) editor.view.value?.focus(); } else if (!element && detailsEditor) { detailsEditor.destroy(); @@ -45,8 +48,13 @@ export function useDataGridCellDetail(options: { detail: Ref watch(sideJsonPreviewContainer, async (element) => { if (element && !sideJsonEditor) { - sideJsonEditor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() }); - await sideJsonEditor.create(element, options.detail.value.formattedJson ?? "", "json"); + const editor = useCellDetailEditor({ language: "json", readOnly: true, ...editorOptions() }); + sideJsonEditor = editor; + await editor.create(element, options.detail.value.formattedJson ?? "", "json"); + if (sideJsonEditor === editor) { + const value = options.detail.value.formattedJson ?? ""; + if (editor.getValue() !== value) editor.setValue(value, "json"); + } } else if (!element && sideJsonEditor) { sideJsonEditor.destroy(); sideJsonEditor = null; diff --git a/apps/desktop/src/composables/useDataGridCellDetailEdit.ts b/apps/desktop/src/composables/useDataGridCellDetailEdit.ts index b22d12b91e..52dbed06d4 100644 --- a/apps/desktop/src/composables/useDataGridCellDetailEdit.ts +++ b/apps/desktop/src/composables/useDataGridCellDetailEdit.ts @@ -39,6 +39,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti const detailValueDiffSnapshot = ref | null>(null); let allowActiveCellDetailResync = false; let lastSyncedDetailCellKey: string | null = null; + let detailEditGeneration = 0; const hasPendingDetailEditorDraft = computed(() => isEditingDetail.value && detailEditValue.value !== detailEditOriginalValue.value); const detailJsonDiffContext = computed(() => { @@ -80,6 +81,7 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti } function resetDetailEdit() { + detailEditGeneration += 1; isEditingDetail.value = false; detailEditValue.value = ""; detailEditOriginalValue.value = ""; @@ -116,16 +118,19 @@ export function useDataGridCellDetailEdit(options: UseDataGridCellDetailEditOpti isEditingDetail.value = true; }); - async function startDetailEdit() { + async function startDetailEdit(generation = detailEditGeneration) { const initialDetail = options.activeDetail.value; if (!initialDetail || !initialDetail.isEditable) return; + const initialCellKey = `${initialDetail.rowId}:${initialDetail.colIndex}`; if (!(await options.hydrateLargeValueCell(initialDetail.rowId, initialDetail.colIndex))) return; + if (generation !== detailEditGeneration) return; const detail = options.activeDetail.value; - if (!detail || !detail.isEditable) return; + if (!detail || !detail.isEditable || `${detail.rowId}:${detail.colIndex}` !== initialCellKey) return; options.warnFormattedJsonEdit(detail); const value = activeDetailEditorText(detail); detailEditValue.value = value; detailEditOriginalValue.value = value; + syncEditorFromDetailEdit(); isEditingDetail.value = true; }