diff --git a/apps/desktop/src/components/objects/ObjectBrowser.vue b/apps/desktop/src/components/objects/ObjectBrowser.vue index 636e0d89c3..76bc7c6935 100644 --- a/apps/desktop/src/components/objects/ObjectBrowser.vue +++ b/apps/desktop/src/components/objects/ObjectBrowser.vue @@ -396,6 +396,10 @@ const objectColumnWidths = ref>({ comment: 260, }); const objectBrowserRowsLoadGuard = createObjectBrowserRowsLoadGuard(); +// schema 列表的失效只跟随上下文(连接/库/schema 变化、卸载),不跟随对象列表加载: +// loadObjects() 会推进 objectBrowserRowsLoadGuard 的 epoch,若两者共用一个 epoch, +// 与对象列表并行发起的 loadSchemas 会被判定为过期而放弃写入 schema 列表 (#8665)。 +const schemaListLoadGuard = createObjectBrowserRowsLoadGuard(); let stopColumnResize: (() => void) | null = null; let preserveObjectFilterScrollOnce = false; @@ -2992,7 +2996,7 @@ async function saveSource() { } async function loadSchemas(epoch: number): Promise { - if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false; + if (!schemaListLoadGuard.isEpochCurrent(epoch)) return false; if (!needsSchema.value) { schemas.value = []; selectedSchema.value = undefined; @@ -3005,7 +3009,7 @@ async function loadSchemas(epoch: number): Promise { const names = filterSchemaNamesForConnection(await api.listSchemas(connectionId, database), props.connection, database, { showSystemSchemas: props.connection.show_system_schemas === true, }); - if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return false; + if (!schemaListLoadGuard.isEpochCurrent(epoch)) return false; schemas.value = names; if (names.length === 0) { selectedSchema.value = undefined; @@ -3016,7 +3020,7 @@ async function loadSchemas(epoch: number): Promise { } return true; } finally { - if (objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false; + if (schemaListLoadGuard.isEpochCurrent(epoch)) loadingSchemas.value = false; } } @@ -3210,17 +3214,30 @@ function normalizeStatisticNumber(value: number | null | undefined): number | nu } async function reload(options?: { allowCachedObjects?: boolean; contextEpoch?: number; preserveExistingRows?: boolean }) { - const epoch = options?.contextEpoch ?? objectBrowserRowsLoadGuard.invalidate(); - try { - if (!(await loadSchemas(epoch))) return; - } catch (e) { + const contextEpoch = options?.contextEpoch ?? schemaListLoadGuard.invalidate(); + // 对象列表只依赖本标签页绑定的 schema,而 schema 列表在部分引擎上可能很慢 + // (如达梦需要扫描庞大的 SYS.SYSOBJECTS,#8665)。这里让对象列表的加载与 + // listSchemas 并行进行,避免慢速 schema 列表让标签页长时间停在“没有找到对象” + // 的空白态;若 schema 解析结果确实改变了目标 schema,随后按新 schema 重载。 + const schemaBeforeSchemas = selectedSchema.value; + const schemasPromise = loadSchemas(contextEpoch).catch((e) => { // listSchemas 失败(如共享连接上 SET SCHEMA 后的二次元数据请求)时, // loadSchemas 不会触碰 selectedSchema,这里也不清空它:保留 props.schema // 或用户已选值,继续尝试加载对象列表(达梦走用户名回退),避免标签静默 // 空白 (#8301)。对象列表若同样失败,loadObjects 自身的 catch 会展示错误。 console.warn("[ObjectBrowser] loadSchemas failed, keeping selected schema for", props.connection.id, e); + return false; + }); + const parallelObjectLoad = needsSchema.value && selectedSchema.value ? loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows }) : undefined; + const schemasLoaded = await schemasPromise; + await parallelObjectLoad; + if (!schemaListLoadGuard.isEpochCurrent(contextEpoch)) return; + if (parallelObjectLoad) { + if (schemasLoaded && needsSchema.value && selectedSchema.value !== schemaBeforeSchemas) { + await loadObjects({ allowCached: true }); + } + return; } - if (!objectBrowserRowsLoadGuard.isEpochCurrent(epoch)) return; await loadObjects({ allowCached: options?.allowCachedObjects, preserveExistingRows: options?.preserveExistingRows }); } @@ -3316,13 +3333,15 @@ defineExpose({ focusSearch, refresh, matchesRefreshScope }); onBeforeUnmount(() => { objectBrowserRowsLoadGuard.invalidate(); + schemaListLoadGuard.invalidate(); stopColumnResize?.(); }); watch( [() => props.connection.id, () => props.database, () => props.schema], async () => { - const contextEpoch = objectBrowserRowsLoadGuard.invalidate(); + objectBrowserRowsLoadGuard.invalidate(); + const contextEpoch = schemaListLoadGuard.invalidate(); selectedSchema.value = props.schema; userHasSelectedFilter.value = false; objectFilter.value = "all"; @@ -3341,7 +3360,7 @@ watch( } catch (e) { console.warn("[DBX] ensureConnected failed for", props.connection.id, e); } - if (!objectBrowserRowsLoadGuard.isEpochCurrent(contextEpoch)) return; + if (!schemaListLoadGuard.isEpochCurrent(contextEpoch)) return; void reload({ allowCachedObjects: true, contextEpoch }); }, { immediate: true }, diff --git a/apps/desktop/src/components/objects/__tests__/ObjectBrowser.slowSchemaList.component.spec.ts b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.slowSchemaList.component.spec.ts new file mode 100644 index 0000000000..1f706443da --- /dev/null +++ b/apps/desktop/src/components/objects/__tests__/ObjectBrowser.slowSchemaList.component.spec.ts @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import { createApp, defineComponent, h, ref, type App } from "vue"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ObjectBrowser from "@/components/objects/ObjectBrowser.vue"; +import { invalidateObjectBrowserRowsCache } from "@/lib/table/objectBrowserRowsCache"; +import type { ConnectionConfig } from "@/types/database"; + +const mocks = vi.hoisted(() => ({ + listObjects: vi.fn(), + listSchemas: vi.fn(), + ensureConnected: vi.fn(), +})); + +vi.mock("@/lib/backend/api", () => ({ + listObjects: (...args: unknown[]) => mocks.listObjects(...args), + listSchemas: (...args: unknown[]) => mocks.listSchemas(...args), + listObjectStatistics: vi.fn().mockResolvedValue([]), +})); +vi.mock("@/stores/connectionStore", () => ({ + useConnectionStore: () => ({ + getConfig: () => connection, + ensureConnected: mocks.ensureConnected, + orderByPinnedTreeNodes: (rows: unknown[]) => rows, + }), +})); +vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({}) })); +vi.mock("@/stores/settingsStore", () => ({ + useSettingsStore: () => ({ + editorSettings: { + shortcuts: { refreshData: "F5" }, + objectBrowserViewMode: "list", + objectBrowserShowCheckbox: false, + sidebarCopyTableNameSeparator: "newline", + sidebarCopyTableNameIncludeSchema: false, + }, + }), +})); +vi.mock("vue-i18n", () => ({ useI18n: () => ({ t: (key: string) => key, locale: ref("en-US") }) })); +vi.mock("@/i18n", () => ({ default: { install: () => undefined } })); +vi.mock("@/composables/useSqlHighlighter", () => ({ useSqlHighlighter: () => ({ highlight: (sql: string) => sql }) })); +vi.mock("@/composables/useToast", () => ({ useToast: () => ({ toast: vi.fn() }) })); +vi.mock("vue-virtual-scroller", () => ({ + RecycleScroller: defineComponent({ + props: { items: { type: Array, default: () => [] } }, + setup(props, { slots }) { + return () => + h( + "div", + props.items.map((item) => slots.default?.({ item })), + ); + }, + }), +})); +vi.mock("@/components/ui/searchable-select", () => ({ SearchableSelect: { render: () => null } })); +vi.mock("@/components/ui/ToolbarOverflowMenu.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/ui/CustomContextMenu.vue", () => ({ + default: defineComponent({ + setup(_, { slots }) { + return () => slots.default?.({ onContextMenu: () => undefined, isOpen: false }); + }, + }), +})); +vi.mock("@/components/editor/QueryEditor.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/editor/DangerConfirmDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/ProcedureExecutionDialog.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/objects/CustomTypeInfoPanel.vue", () => ({ default: { render: () => null } })); +vi.mock("@/components/export/XlsxHeaderDialog.vue", () => ({ default: { render: () => null } })); + +const connection = { + id: "dameng-slow-schemas", + name: "Dameng", + db_type: "dameng", + database: "TEST", + driver_profile: null, + url_params: null, + transport_layers: [], +} as unknown as ConnectionConfig; + +const mountedApps: Array<{ app: App; host: HTMLElement }> = []; + +beforeEach(() => { + vi.clearAllMocks(); + mocks.listObjects.mockResolvedValue([{ name: "TEST2026", object_type: "TABLE" }]); + mocks.ensureConnected.mockResolvedValue(undefined); + invalidateObjectBrowserRowsCache({}); +}); + +afterEach(() => { + for (const { app, host } of mountedApps.splice(0)) { + app.unmount(); + host.remove(); + } + invalidateObjectBrowserRowsCache({}); +}); + +async function mountBrowser() { + const host = document.createElement("div"); + document.body.append(host); + const app = createApp({ setup: () => () => h(ObjectBrowser, { connection, database: "TEST", schema: "TEST" }) }); + mountedApps.push({ app, host }); + app.mount(host); + return host; +} + +describe("ObjectBrowser with a slow schema list", () => { + it("loads the object list without waiting for listSchemas (#8665)", async () => { + // listSchemas 一直不返回,模拟达梦扫描庞大 SYS.SYSOBJECTS 的场景。 + mocks.listSchemas.mockReturnValue(new Promise(() => {})); + + const host = await mountBrowser(); + + await vi.waitFor(() => expect(host.textContent).toContain("TEST2026")); + expect(mocks.listObjects).toHaveBeenCalledWith("dameng-slow-schemas", "TEST", "TEST", undefined, undefined, undefined, undefined, undefined); + expect(mocks.listSchemas).toHaveBeenCalledWith("dameng-slow-schemas", "TEST"); + expect(host.textContent).not.toContain("objects.empty"); + }); + + it("still applies the schema list once a slow listSchemas resolves", async () => { + let resolveSchemas: (names: string[]) => void = () => undefined; + mocks.listSchemas.mockReturnValue(new Promise((resolve) => (resolveSchemas = resolve))); + + const host = await mountBrowser(); + await vi.waitFor(() => expect(host.textContent).toContain("TEST2026")); + + resolveSchemas(["TEST", "OTHER"]); + await vi.waitFor(() => expect(mocks.listObjects).toHaveBeenCalledTimes(1)); + }); +});