fix(structure): stop replaying consumed initial tab on remount

Closes #8419
This commit is contained in:
t8y2 committed 2026-09-08 15:10:48 +08:00
1 parent a081f6a839
commit c7dbaf5965
3 files changed
+424 -2

No files matched your search

@@ -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}`;
}
@@ -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<string, unknown>> = {}): Record<string, unknown> {
return {
dirty: false,
activeTab: "columns",
newTableName: "",
tableComment: "",
originalTableComment: "",
columns: [],
indexes: [],
foreignKeys: [],
constraints: [],
triggers: [],
initialized: true,
...overrides,
};
}
async function mountStructureEditor(props: Record<string, unknown> = {}) {
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");
});
});
+2
View File
@@ -1104,6 +1104,8 @@ export interface TableStructureEditorDraft {
triggersLoaded?: boolean;
loadedMetadataFacets?: import("@/lib/metadata/objectMetadataCache").ObjectMetadataFacet[];
scrollPositions?: Partial<Record<TableInfoTab, TableStructureEditorViewport>>;
/** 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;
}