diff --git a/packages/editor/src/initService.ts b/packages/editor/src/initService.ts index 38c76a84..01a2b5d0 100644 --- a/packages/editor/src/initService.ts +++ b/packages/editor/src/initService.ts @@ -427,12 +427,26 @@ export const initServiceEvents = ( }); }; + /** + * root 是否已经不是编辑器当前的 root。 + * + * `editorService.set('root', v)` 是同步赋值后再派发 `root-change`,因此派发那一刻 `v` 一定 + * 就是当前 root;但处理 `root-change` 要等 stage / runtime / 依赖收集等异步过程,期间 root + * 可能被新的一次整体替换(外部重设 DSL、更新 root 节点、源码保存等)顶掉,此时手上的 `v` + * 已是过期快照,继续用它刷画布或回写给外部都会与新 root 互相覆盖。 + */ + const isStaleRoot = (value: MApp | null) => toRaw(editorService.get('root')) !== toRaw(value); + const updateStageDsl = async (value: MApp | null) => { const stage = await getStage(); const runtime = await stage.renderer?.getRuntime(); const app = await getTMagicApp(); + // 等 stage / runtime 就绪期间 root 已被替换:新 root 的刷新可能已经完成, + // 再把旧 dsl 推给 runtime 会让画布回退到旧内容 + if (isStaleRoot(value)) return; + if (!app?.dataSourceManager) { runtime?.updateRootConfig?.(cloneDeep(toRaw(value))!); } @@ -449,6 +463,8 @@ export const initServiceEvents = ( await (typeof Worker === 'undefined' ? collectIdle(value.items, true) : depService.collectByWorker(value)); + if (isStaleRoot(value)) return; + const dsl = cloneDeep(toRaw(value)); if (dsl.dataSources && dsl.dataSourceDeps && app?.dataSourceManager) { for (const node of getNodes(getDepNodeIds(dsl.dataSourceDeps), dsl.items)) { @@ -466,7 +482,7 @@ export const initServiceEvents = ( depService.addTarget(createDataSourceCondTarget(ds, reactive({}))); }; - const rootChangeHandler = (value: MApp | null, preValue?: MApp | null) => { + const rootChangeHandler = (value: MApp | null) => { if (!value) return; value.codeBlocks = value.codeBlocks || {}; @@ -509,7 +525,13 @@ export const initServiceEvents = ( editorService.set('page', null); } - if (toRaw(value) !== toRaw(preValue)) { + // 上面的 select 是异步的,期间 root 可能已被替换,过期快照不能再回写给外部: + // 两次整体替换各自持有一个快照时,回写会把对方的 root 顶掉,外部 modelValue 变化又会 + // 重新 set root,两条链路无休止地交替下去(表现为编辑器卡死) + if (isStaleRoot(value)) return; + + // 外部已经持有这个 root(如本次变化就是 modelValue 传进来的)时无需回写 + if (toRaw(props.modelValue) !== toRaw(value)) { emit('update:modelValue', value); } })(); diff --git a/packages/editor/tests/unit/initService.spec.ts b/packages/editor/tests/unit/initService.spec.ts index 60c1a8ba..0b37dae0 100644 --- a/packages/editor/tests/unit/initService.spec.ts +++ b/packages/editor/tests/unit/initService.spec.ts @@ -357,7 +357,6 @@ describe('initServiceEvents', () => { }); test('rootChange 处理代码块和数据源', async () => { - services.editorService.state.root = { id: 'r' }; mount(WrapEvents({} as any, emit, services)); const value: any = { id: 'r', @@ -365,6 +364,8 @@ describe('initServiceEvents', () => { dataSources: [{ id: 'd1', type: 'base' }], items: [], }; + // set('root', v) 是先赋值再派发事件,mock 中同样先对齐 state 再 emit + services.editorService.state.root = value; services.editorService.emit('root-change', value, null); await new Promise((r) => setTimeout(r, 0)); expect(services.codeBlockService.setCodeDsl).toHaveBeenCalled(); @@ -593,13 +594,15 @@ describe('initServiceEvents', () => { services.editorService.state.node = { id: 'n1' }; mount(WrapEvents({} as any, emit, services)); - services.editorService.emit('root-change', { + const value: any = { id: 'r', items: [{ id: 'n1', type: 'text' }], dataSources: [], dataSourceDeps: { d1: {} }, codeBlocks: {}, - }); + }; + services.editorService.state.root = value; + services.editorService.emit('root-change', value); await new Promise((r) => setTimeout(r, 10)); expect(stage.runtime.updatePageId).toHaveBeenCalledWith('p1'); @@ -608,10 +611,10 @@ describe('initServiceEvents', () => { }); test('rootChange items 不是数组时清空依赖', async () => { - services.editorService.state.root = { id: 'r' }; mount(WrapEvents({} as any, emit, services)); const value: any = { id: 'r', dataSourceDeps: { a: {} }, dataSourceCondDeps: { b: {} } }; + services.editorService.state.root = value; services.editorService.emit('root-change', value); await new Promise((r) => setTimeout(r, 0)); @@ -625,6 +628,7 @@ describe('initServiceEvents', () => { mount(WrapEvents({} as any, emit, services)); const value: any = { id: 'r', items: [] }; + services.editorService.state.root = value; services.editorService.emit('root-change', value, { id: 'prev' }); await new Promise((r) => setTimeout(r, 0)); @@ -638,12 +642,44 @@ describe('initServiceEvents', () => { services.editorService.getNodeById.mockReturnValue(null); mount(WrapEvents({} as any, emit, services)); - services.editorService.emit('root-change', { id: 'r', items: [{ id: 'first', type: 'page' }] }); + const value: any = { id: 'r', items: [{ id: 'first', type: 'page' }] }; + services.editorService.state.root = value; + services.editorService.emit('root-change', value); await new Promise((r) => setTimeout(r, 0)); expect(services.editorService.select).toHaveBeenCalledWith({ id: 'first', type: 'page' }); }); + test('rootChange 外部已持有该 root 时不回写 modelValue', async () => { + services.editorService.getNodeById.mockReturnValue(null); + const value: any = { id: 'r', items: [] }; + mount(WrapEvents({ modelValue: value } as any, emit, services)); + + services.editorService.state.root = value; + services.editorService.emit('root-change', value); + await new Promise((r) => setTimeout(r, 0)); + + expect(emit).not.toHaveBeenCalled(); + }); + + test('rootChange 处理期间 root 被替换:不刷旧 dsl 也不回写过期快照', async () => { + const app: any = { dsl: {}, dataSourceManager: mkDataSourceManager() }; + const stage = mkReadyStage(app); + services.editorService.state.stage = stage; + services.editorService.getNodeById.mockReturnValue(null); + mount(WrapEvents({} as any, emit, services)); + + const value: any = { id: 'r', items: [{ id: 'n1', type: 'text' }], dataSources: [], codeBlocks: {} }; + services.editorService.state.root = value; + services.editorService.emit('root-change', value); + // 异步处理还没跑完,root 已被新的一次整体替换顶掉 + services.editorService.state.root = { id: 'r', items: [], dataSources: [], codeBlocks: {} }; + await new Promise((r) => setTimeout(r, 10)); + + expect(stage.runtime.updateRootConfig).not.toHaveBeenCalled(); + expect(emit).not.toHaveBeenCalled(); + }); + test('update 事件:ROOT 节点、无 propPath、命中已收集依赖三种分支', async () => { services.editorService.state.root = { id: 'r', items: [] }; services.depService.getTargets.mockReturnValue({