diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 69b024bc..8a161393 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -29,6 +29,7 @@ import { getDepNodeIds, getNodes, isPage, isValueIncludeDataSource } from '@tmag import PropsPanel from './layouts/PropsPanel.vue'; import { isIncludeDataSource } from './utils/editor'; +import { error as logError } from './utils/logger'; import { EditorProps } from './editorProps'; import { Services } from './type'; @@ -36,6 +37,19 @@ export declare type LooseRequired = { [P in string & keyof T]: T[P]; }; +/** + * select 为异步串行执行,节点不可选中(如已从 DSL 中移除)时会抛错。 + * 在 watch 回调中调用不能把拒绝抛回调用方,否则会变成 Vue 的「Unhandled error during execution of watcher callback」, + * 这里统一兜底成日志。 + */ +const selectNode = async (editorService: Services['editorService'], id: Id) => { + try { + await editorService.select(id); + } catch (e) { + logError(e); + } +}; + export const initServiceState = ( props: EditorProps, { @@ -193,9 +207,17 @@ export const initServiceState = ( }, ); + // defaultSelected 可能早于 modelValue 到位(immediate 首次执行时 root 还是空的,或节点尚未出现在 DSL 中), + // 此时 select 会因为「获取不到组件信息」而抛错,交由 root-change 中的选中逻辑在 root 就绪后补上即可。 watch( () => props.defaultSelected, - (defaultSelected) => defaultSelected && editorService.select(defaultSelected), + (defaultSelected) => { + if (!defaultSelected || !editorService.getNodeById(defaultSelected)) { + return; + } + + selectNode(editorService, defaultSelected); + }, { immediate: true, }, diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index 77de5d14..60270801 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -4,12 +4,13 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { defineComponent, h } from 'vue'; +import { defineComponent, h, nextTick } from 'vue'; import { mount } from '@vue/test-utils'; import { DepTargetType } from '@tmagic/core'; import { initServiceEvents, initServiceState } from '@editor/initService'; +import * as logger from '@editor/utils/logger'; const mkServices = () => { const handlers: Record> = {}; @@ -151,6 +152,10 @@ vi.mock('@editor/utils/editor', () => ({ isIncludeDataSource: vi.fn(() => false), })); +vi.mock('@editor/utils/logger', () => ({ + error: vi.fn(), +})); + const Wrap = (props: any, services: any) => defineComponent({ setup() { @@ -233,11 +238,30 @@ describe('initServiceState', () => { }); test('defaultSelected 调用 select', () => { + services.editorService.getNodeById.mockReturnValue({ id: 'n1' }); const props = { defaultSelected: 'n1' } as any; mount(Wrap(props, services)); expect(services.editorService.select).toHaveBeenCalledWith('n1'); }); + test('defaultSelected 对应节点不存在时不调用 select', () => { + services.editorService.getNodeById.mockReturnValue(null); + const props = { defaultSelected: 'n1' } as any; + mount(Wrap(props, services)); + expect(services.editorService.select).not.toHaveBeenCalled(); + }); + + test('defaultSelected select 失败时兜底成日志,不产生未处理的拒绝', async () => { + const err = new Error('获取不到组件信息'); + vi.mocked(logger.error).mockClear(); + services.editorService.getNodeById.mockReturnValue({ id: 'n1' }); + services.editorService.select.mockRejectedValue(err); + const props = { defaultSelected: 'n1' } as any; + mount(Wrap(props, services)); + await nextTick(); + expect(logger.error).toHaveBeenCalledWith(err); + }); + test('stageRect 设置 ui state', () => { const props = { stageRect: { width: 100 } } as any; mount(Wrap(props, services));