From c7dbaf59655209f552fe8b9c9a60d91bdd920457 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Tue, 8 Sep 2026 15:10:48 +0800 Subject: [PATCH] fix(structure): stop replaying consumed initial tab on remount Closes #8419 --- .../structure/TableStructureEditor.vue | 22 +- .../TableStructureEditor.initialTab.spec.ts | 402 ++++++++++++++++++ apps/desktop/src/types/database.ts | 2 + 3 files changed, 424 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/components/structure/__tests__/TableStructureEditor.initialTab.spec.ts diff --git a/apps/desktop/src/components/structure/TableStructureEditor.vue b/apps/desktop/src/components/structure/TableStructureEditor.vue index aaa3db3e74..9d79146405 100644 --- a/apps/desktop/src/components/structure/TableStructureEditor.vue +++ b/apps/desktop/src/components/structure/TableStructureEditor.vue @@ -1160,6 +1160,7 @@ let skipNextRefreshVersion = false; let restoringDraft = false; let syncingDraft = false; let draftHydrated = false; +let lastAppliedInitialTabRequestId: number | undefined; let hydratingRestoredDraft = false; let structureScrollFrame = 0; let structureHorizontalScrollbarThumbLeftPercent = 0; @@ -1361,6 +1362,7 @@ function createCurrentDraft(initialized = true): TableStructureEditorDraft { triggersLoaded: triggersLoaded.value, loadedMetadataFacets: [...loadedMetadataFacets], scrollPositions: cloneDraftValue(structureScrollPositions.value), + appliedInitialTabRequestId: lastAppliedInitialTabRequestId, initialized, }; } @@ -1376,6 +1378,7 @@ function syncDraftToParent() { function restoreDraft(draft: TableStructureEditorDraft) { restoringDraft = true; draftHydrated = false; + lastAppliedInitialTabRequestId = draft.appliedInitialTabRequestId; activeTab.value = draft.activeTab || "columns"; // Restore the DDL baseline alongside the edit, otherwise the restored script // would read as dirty (or clean) against the wrong reference text. @@ -3750,14 +3753,17 @@ function unregisterStructureEditorShortcuts() { onMounted(() => { resetState(); - applyInitialStructureTab(); + // With an initialized draft the restore below owns the tab (plus any + // unconsumed initial tab); applying the stale initial tab first would only + // flash the wrong facet and kick its metadata load (#8419). + if (!props.draft?.initialized) applyInitialStructureTab(); applyInitialStructureTarget(); registerStructureEditorShortcuts(); void loadDynamicDataTypeOptions(); if (props.draft?.initialized) { restoreDraft(props.draft); // A restored draft owns its saved tab unless navigation explicitly requested another one. - applyInitialStructureTab(false); + applyPendingInitialStructureTab(); applyInitialStructureTarget(); } structureEditorReady = true; @@ -3840,11 +3846,23 @@ function resolveStructureMetadataTab(tab: TableInfoTab | undefined, capabilities function applyInitialStructureTab(useDefault = true) { if (props.initialTab) { activeTab.value = resolveStructureMetadataTab(props.initialTab); + lastAppliedInitialTabRequestId = props.initialTabRequestId; } else if (useDefault) { activeTab.value = resolveStructureMetadataTab(undefined); } } +// The tab's structureInitialTab stays populated after it was consumed once +// (e.g. the side panel opened the editor on its foreign-keys facet). Replaying +// it on every remount would override the draft-restored tab the user last +// selected, so an initial tab only applies again when navigation bumped the +// request id (#8419). +function applyPendingInitialStructureTab() { + if (!props.initialTab) return; + if (props.initialTabRequestId !== undefined && props.initialTabRequestId === lastAppliedInitialTabRequestId) return; + applyInitialStructureTab(false); +} + function initialTargetKey(target: TableStructureEditorTarget): string { return `${props.initialTabRequestId ?? 0}:${target.kind}:${target.name}`; } diff --git a/apps/desktop/src/components/structure/__tests__/TableStructureEditor.initialTab.spec.ts b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.initialTab.spec.ts new file mode 100644 index 0000000000..a6d8e74528 --- /dev/null +++ b/apps/desktop/src/components/structure/__tests__/TableStructureEditor.initialTab.spec.ts @@ -0,0 +1,402 @@ +// @vitest-environment happy-dom + +import { createApp, nextTick, type App } from "vue"; +import { EditorView } from "@codemirror/view"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const TABLE_DDL = "CREATE TABLE `users` (\n `id` bigint NOT NULL AUTO_INCREMENT,\n `email` varchar(255) DEFAULT NULL,\n PRIMARY KEY (`id`)\n) ENGINE=InnoDB"; + +const mocks = vi.hoisted(() => ({ + connection: { + id: "structure-ddl-tab", + name: "MySQL", + db_type: "mysql", + driver_label: "MySQL", + }, + ensureConnected: vi.fn(), + executeQuery: vi.fn(), + executeBatch: vi.fn(), + listDataTypes: vi.fn(), + buildTableStructureChangeSql: vi.fn(), + buildMysqlAutoIncrementSql: vi.fn(), + buildTableOwnerChangeSql: vi.fn(), + getTablePartitionStatus: vi.fn(), + getTableOwner: vi.fn(), + updateEditorSettings: vi.fn(), + loadObjectDdl: vi.fn(), + invalidateObjectDdl: vi.fn(), + loadObjectMetadataFacet: vi.fn(), + invalidateObjectMetadataCache: vi.fn(), + invalidateTableMetadataCache: vi.fn(), + toast: vi.fn(), +})); + +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key }) })); + +vi.mock("@lucide/vue", async () => { + const { defineComponent, h } = await import("vue"); + const Icon = defineComponent({ name: "Icon", setup: () => () => h("span") }); + return { + AlertTriangle: Icon, + Check: Icon, + ChevronDown: Icon, + ChevronLeft: Icon, + ChevronRight: Icon, + ChevronUp: Icon, + Copy: Icon, + Database: Icon, + Info: Icon, + Keyboard: Icon, + KeyRound: Icon, + ListChevronsUpDown: Icon, + Loader2: Icon, + Maximize2: Icon, + Pencil: Icon, + Plus: Icon, + RefreshCw: Icon, + RotateCcw: Icon, + Save: Icon, + Search: Icon, + Settings: Icon, + SlidersHorizontal: Icon, + Trash2: Icon, + UserRound: Icon, + X: Icon, + }; +}); + +vi.mock("@/components/ui/button", async () => { + const { defineComponent, h } = await import("vue"); + return { + Button: defineComponent({ + name: "Button", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }), + }; +}); +vi.mock("@/components/ui/input", async () => { + const { defineComponent, h } = await import("vue"); + return { + Input: defineComponent({ + name: "Input", + inheritAttrs: false, + props: { modelValue: { type: [String, Number], default: "" } }, + emits: ["update:modelValue"], + setup: + (props, { attrs, emit }) => + () => + h("input", { + ...attrs, + value: props.modelValue, + onInput: (event: Event) => emit("update:modelValue", (event.target as HTMLInputElement).value), + }), + }), + }; +}); +vi.mock("@/components/ui/badge", async () => { + const { defineComponent, h } = await import("vue"); + return { + Badge: defineComponent({ + name: "Badge", + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("span", attrs, slots.default?.()), + }), + }; +}); +// Tabs mock that reproduces the one detail this regression depends on: reka-ui's +// TabsContent renders its slot through Presence, and `usePresence` awaits a +// `nextTick` before dispatching MOUNT. So the pane (and every template ref +// inside it) mounts one tick *after* the tab became active. A mock that renders +// panes unconditionally — like the other structure specs use — cannot see the +// bug at all. +vi.mock("@/components/ui/tabs", async () => { + const { computed, defineComponent, h, inject, nextTick, provide, ref, watch } = await import("vue"); + const TabsSelectKey = Symbol("tabs:select"); + const TabsActiveKey = Symbol("tabs:active"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Tabs = defineComponent({ + name: "MockTabs", + inheritAttrs: false, + props: { modelValue: { type: String, default: "" } }, + emits: ["update:modelValue"], + setup: (props, { attrs, slots, emit }) => { + provide(TabsSelectKey, (value: string) => emit("update:modelValue", value)); + provide( + TabsActiveKey, + computed(() => props.modelValue), + ); + return () => h("div", attrs, slots.default?.()); + }, + }); + const TabsContent = defineComponent({ + name: "MockTabsContent", + inheritAttrs: false, + props: { value: { type: String, required: true } }, + setup: (props, { attrs, slots }) => { + const active = inject<{ value: string }>(TabsActiveKey, ref("")); + const selected = computed(() => active.value === props.value); + const present = ref(selected.value); + watch(selected, async (isSelected) => { + if (!isSelected) { + present.value = false; + return; + } + await nextTick(); + present.value = true; + }); + return () => h("div", attrs, present.value ? slots.default?.() : undefined); + }, + }); + const TabsTrigger = defineComponent({ + name: "MockTabsTrigger", + inheritAttrs: false, + props: { value: { type: String, required: true } }, + setup: (props, { attrs, slots }) => { + const select = inject<(value: string) => void>(TabsSelectKey, () => {}); + return () => h("button", { ...attrs, type: "button", "data-tab-trigger": props.value, onClick: () => select(props.value) }, slots.default?.()); + }, + }); + return { Tabs, TabsContent, TabsList: Div, TabsTrigger }; +}); +vi.mock("@/components/ui/dropdown-menu", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + const Button = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("button", attrs, slots.default?.()), + }); + return { DropdownMenu: Div, DropdownMenuCheckboxItem: Div, DropdownMenuContent: Div, DropdownMenuItem: Button, DropdownMenuTrigger: Div }; +}); +vi.mock("@/components/ui/popover", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Popover: Div, PopoverContent: Div, PopoverTrigger: Div }; +}); +vi.mock("@/components/ui/tooltip", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Tooltip: Div, TooltipContent: Div, TooltipTrigger: Div }; +}); +vi.mock("@/components/ui/searchable-select", async () => { + const { defineComponent, h } = await import("vue"); + return { + SearchableSelect: defineComponent({ + name: "SearchableSelect", + inheritAttrs: false, + props: { modelValue: { type: String, default: "" } }, + emits: ["update:modelValue"], + setup: + (props, { attrs }) => + () => + h("button", { ...attrs, type: "button", "data-model-value": props.modelValue }), + }), + }; +}); +vi.mock("@/components/ui/select", async () => { + const { defineComponent, h } = await import("vue"); + const Div = defineComponent({ + inheritAttrs: false, + setup: + (_props, { attrs, slots }) => + () => + h("div", attrs, slots.default?.()), + }); + return { Select: Div, SelectContent: Div, SelectItem: Div, SelectTrigger: Div, SelectValue: Div }; +}); +vi.mock("@/components/editor/EditorSearchPanel.vue", async () => { + const { defineComponent, h } = await import("vue"); + return { + default: defineComponent({ + name: "MockEditorSearchPanel", + setup: () => ({ openSearch: () => false, closeSearch: () => false }), + render: () => h("div", { "data-editor-search-panel": "true" }), + }), + }; +}); + +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + ensureConnected: mocks.ensureConnected, + getConfig: (connectionId: string) => (connectionId === mocks.connection.id ? mocks.connection : undefined), + }), +})); +vi.mock("@/stores/productionSafetyStore", () => ({ useProductionSafetyStore: () => ({ requestConfirmation: vi.fn() }) })); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ tableStructureRefreshVersion: () => 0 }) })); +vi.mock("@/stores/historyStore", () => ({ useHistoryStore: () => ({ add: vi.fn() }) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { structureEditorDensity: "compact", sqlFormatter: {}, tableColumnTemplateFields: [], fontSize: 13, fontFamily: "monospace", theme: "default" }, + updateEditorSettings: mocks.updateEditorSettings, + }), +})); +vi.mock("@/composables/useTheme", () => ({ useTheme: () => ({ isDark: { value: false }, themePalette: { value: "pearl" } }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: mocks.toast }) })); +vi.mock("@/lib/sql/sqlHighlighter", () => ({ createShikiSqlHighlighter: vi.fn(async () => (sql: string) => sql) })); +vi.mock("@/lib/sql/sqlFormatter", () => ({ + formatSqlForDisplay: vi.fn(async (sql: string) => sql), + sqlFormatDialectForDbType: vi.fn(() => "mysql"), +})); +vi.mock("@/lib/editor/editorThemes", () => ({ loadEditorTheme: vi.fn(async () => []), editorFontTheme: vi.fn(() => []) })); +vi.mock("@/lib/metadata/objectDdlCache", () => ({ + loadObjectDdl: mocks.loadObjectDdl, + invalidateObjectDdl: mocks.invalidateObjectDdl, +})); +vi.mock("@/lib/metadata/objectMetadataCache", () => ({ loadObjectMetadataFacet: mocks.loadObjectMetadataFacet, invalidateObjectMetadataCache: mocks.invalidateObjectMetadataCache })); +vi.mock("@/lib/metadata/tableMetadataCache", () => ({ invalidateTableMetadataCache: mocks.invalidateTableMetadataCache })); +vi.mock("@/lib/backend/api", () => ({ + executeQuery: mocks.executeQuery, + executeBatch: mocks.executeBatch, + listDataTypes: mocks.listDataTypes, + buildTableStructureChangeSql: mocks.buildTableStructureChangeSql, + buildMysqlAutoIncrementSql: mocks.buildMysqlAutoIncrementSql, + buildTableOwnerChangeSql: mocks.buildTableOwnerChangeSql, + getTablePartitionStatus: mocks.getTablePartitionStatus, + getTableOwner: mocks.getTableOwner, +})); + +import TableStructureEditor from "@/components/structure/TableStructureEditor.vue"; + +const mountedApps: App[] = []; + +function structureDraft(overrides: Partial> = {}): Record { + return { + dirty: false, + activeTab: "columns", + newTableName: "", + tableComment: "", + originalTableComment: "", + columns: [], + indexes: [], + foreignKeys: [], + constraints: [], + triggers: [], + initialized: true, + ...overrides, + }; +} + +async function mountStructureEditor(props: Record = {}) { + const root = document.createElement("div"); + document.body.append(root); + const app = createApp(TableStructureEditor, { + connectionId: mocks.connection.id, + database: "test", + tableName: "users", + ...props, + }); + mountedApps.push(app); + app.mount(root); + await vi.waitFor( + () => { + expect(root.querySelector('[data-tab-trigger="foreignKeys"]')).not.toBeNull(); + expect(root.textContent).toContain("structureEditor.noChanges"); + }, + { timeout: 3000 }, + ); + await settle(); + return root; +} + +/** Let every already-queued load/preview microtask land before touching the DOM. */ +async function settle() { + for (let i = 0; i < 30; i++) { + await nextTick(); + await Promise.resolve(); + } +} + +beforeEach(() => { + vi.clearAllMocks(); + mocks.ensureConnected.mockResolvedValue(undefined); + mocks.executeQuery.mockResolvedValue({ columns: [], rows: [] }); + mocks.executeBatch.mockResolvedValue({ rowsAffected: 0 }); + mocks.listDataTypes.mockResolvedValue([]); + mocks.getTablePartitionStatus.mockResolvedValue({ isPartitionedParent: false, isPartition: false }); + mocks.getTableOwner.mockResolvedValue(""); + mocks.buildTableOwnerChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + mocks.buildTableStructureChangeSql.mockResolvedValue({ statements: [], warnings: [] }); + mocks.loadObjectDdl.mockResolvedValue({ ddl: TABLE_DDL, cacheStatus: "remote" }); + mocks.loadObjectMetadataFacet.mockImplementation(async (_request: unknown, facet: string) => ({ + value: + facet === "comment" + ? "" + : facet === "columns" + ? [ + { name: "id", data_type: "bigint", nullable: false, default_value: null, comment: "" }, + { name: "email", data_type: "varchar(255)", nullable: true, default_value: null, comment: "" }, + ] + : [], + cacheStatus: "remote", + })); +}); + +afterEach(() => { + for (const app of mountedApps.splice(0)) app.unmount(); + document.body.innerHTML = ""; +}); + +describe("TableStructureEditor initial tab", () => { + it("keeps the draft-restored tab when the initial tab was already consumed", async () => { + // Regression for #8419: the tab keeps structureInitialTab="foreignKeys" + // after the side panel opened the editor on that facet once. A remount + // (switching tabs and coming back) must keep the user's last selection. + const root = await mountStructureEditor({ + initialTab: "foreignKeys", + initialTabRequestId: 1, + draft: structureDraft({ activeTab: "columns", appliedInitialTabRequestId: 1 }), + }); + + expect(root.textContent).not.toContain("structureEditor.addForeignKey"); + expect(root.textContent).toContain("structureEditor.addColumn"); + }); + + it("applies the initial tab when navigation bumped the request id", async () => { + const root = await mountStructureEditor({ + initialTab: "foreignKeys", + initialTabRequestId: 2, + draft: structureDraft({ activeTab: "columns", appliedInitialTabRequestId: 1 }), + }); + + expect(root.textContent).toContain("structureEditor.addForeignKey"); + }); + + it("seeds a fresh editor with the initial tab", async () => { + const root = await mountStructureEditor({ initialTab: "foreignKeys", initialTabRequestId: 1 }); + + expect(root.textContent).toContain("structureEditor.addForeignKey"); + }); +}); diff --git a/apps/desktop/src/types/database.ts b/apps/desktop/src/types/database.ts index 271d566817..656123737f 100644 --- a/apps/desktop/src/types/database.ts +++ b/apps/desktop/src/types/database.ts @@ -1104,6 +1104,8 @@ export interface TableStructureEditorDraft { triggersLoaded?: boolean; loadedMetadataFacets?: import("@/lib/metadata/objectMetadataCache").ObjectMetadataFacet[]; scrollPositions?: Partial>; + /** Request id of the structureInitialTab the editor already applied; remounts must not replay a consumed initial tab over the restored draft. */ + appliedInitialTabRequestId?: number; initialized: boolean; }