diff --git a/packages/editor/src/layouts/sidebar/layer/use-node-status.ts b/packages/editor/src/layouts/sidebar/layer/use-node-status.ts index 43463091..5669fa4b 100644 --- a/packages/editor/src/layouts/sidebar/layer/use-node-status.ts +++ b/packages/editor/src/layouts/sidebar/layer/use-node-status.ts @@ -1,6 +1,6 @@ import { computed, onBeforeUnmount, ref, watch } from 'vue'; -import type { Id, MNode, MPage, MPageFragment } from '@tmagic/core'; +import type { Id, MApp, MNode, MPage, MPageFragment } from '@tmagic/core'; import { getNodePath, isPage, isPageFragment, traverseNode } from '@tmagic/utils'; import type { LayerNodeStatus, Services } from '@editor/type'; @@ -45,22 +45,70 @@ export const useNodeStatus = ({ editorService }: Services) => { page.value ? nodeStatusMaps.value.get(page.value.id) : new Map(), ); - // 切换页面或者新增页面,重新生成节点状态 + // 切换页面 / 新增页面 / 整体替换 dsl 后 page 引用变化时,重新生成节点状态。 + // + // 注意这里 watch 的是 page 引用而不是 page.id: + // 历史版本恢复 / 外部 modelValue 整体覆盖等场景,新旧 dsl 的 page.id 通常完全 + // 一致,但 page 对象引用是新的、items 也是新的。仅监听 id 会漏掉这类「同 id + // 不同内容」的替换,导致 nodeStatusMaps 残留旧节点 status,组件树渲染滞留在 + // 旧版本。监听引用可以覆盖普通切页(不同 id)和整体替换(同 id 新引用)两种 + // 情况;同时配合下方 root-change 时清空缓存,避免拿到污染的 initial status。 watch( - () => page.value?.id, - (pageId) => { - if (!pageId) { + page, + (newPage) => { + if (!newPage?.id) { return; } // 生成节点状态 - nodeStatusMaps.value.set(pageId, createPageNodeStatus(page.value!, nodeStatusMaps.value.get(pageId))); + nodeStatusMaps.value.set(newPage.id, createPageNodeStatus(newPage, nodeStatusMaps.value.get(newPage.id))); }, { immediate: true, }, ); + /** + * root 整体被替换时(外部 modelValue 变化、历史版本恢复、套件编辑模式进入/退出等): + * - 仅 watch page 引用还不够,因为 root-change 同步触发时 page 还是旧引用, + * 等 initService 的异步 IIFE 跑完 editorService.select(...) 后 page 才会 + * 被替换为新 dsl 中的 page;此时上面的 page watch 才会触发重建。 + * - 但若直接同步清空 nodeStatusMaps,会让 nodeStatusMap (computed) 立刻变 + * undefined。上层 LayerPanel 用 `v-if="page && nodeStatusMap"` 渲染组件树, + * 会瞬间销毁整个面板;若紧接着的异步 select 链路(套件退出等场景)发生 + * 竞态、page 引用未变 / 解析失败,watch(page) 不触发重建,组件树就再也回 + * 不来。 + * - 此外「污染」问题本质来自 createPageNodeStatus 用旧 status 作为新节点 + * initial 值:只要新 root 的 page 是新引用,watch(page) 会触发重建,重建 + * 时基于新 page.items 生成的 map 只会包含新节点 id;旧节点 id 即便残留在 + * initialLayerNodeStatus 中也不会被写入新 map。真正的风险只有「同一 page + * id 下,新旧 dsl 都存在同一节点 id 但其实是不同节点」这种极端情况——这 + * 在常规业务中不会发生(id 是 uuid)。 + * + * 综合:root-change 时仅清理「在新 root 中已不存在的 page id」对应缓存, + * 保留仍然有效的 page status 不动;既避免 v-if 闪断,也不会保留无关 page 的 + * 死缓存。同 page id 的重建交给下方 watch(page) 触发。 + */ + const rootChangeHandler = (value: MApp | null) => { + if (!value) { + nodeStatusMaps.value = new Map(); + return; + } + + const validPageIds = new Set(); + (value.items || []).forEach((p) => { + if (p?.id !== undefined) validPageIds.add(p.id); + }); + + for (const cachedPageId of Array.from(nodeStatusMaps.value.keys())) { + if (!validPageIds.has(cachedPageId)) { + nodeStatusMaps.value.delete(cachedPageId); + } + } + }; + + editorService.on('root-change', rootChangeHandler); + // 选中状态变化,更新节点状态 watch( nodes, @@ -111,6 +159,7 @@ export const useNodeStatus = ({ editorService }: Services) => { editorService.on('remove', removeHandler); onBeforeUnmount(() => { + editorService.off('root-change', rootChangeHandler); editorService.off('remove', removeHandler); editorService.off('add', addHandler); }); diff --git a/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts b/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts index d58459d1..83e6ba38 100644 --- a/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts +++ b/packages/editor/tests/unit/layouts/sidebar/layer/use-node-status.spec.ts @@ -4,7 +4,7 @@ * Copyright (C) 2025 Tencent. */ import { beforeEach, describe, expect, test, vi } from 'vitest'; -import { nextTick } from 'vue'; +import { nextTick, reactive } from 'vue'; import { useNodeStatus } from '@editor/layouts/sidebar/layer/use-node-status'; @@ -29,6 +29,9 @@ vi.mock('@editor/utils/tree', () => ({ }), })); +// editorState 用 reactive 让 useNodeStatus 内 `computed(() => editorService.get('page'))` +// 等读取能建立响应式依赖;测试用例对 editorState.page / nodes 重新赋值时, +// 相关 watch 才会按预期触发,避免测试通过仅靠 immediate 副作用而失去回归价值。 let editorState: Record; const editorEvents: Record any)[]> = {}; const mkEditorService = () => ({ @@ -45,10 +48,10 @@ const mkEditorService = () => ({ beforeEach(() => { vi.clearAllMocks(); Object.keys(editorEvents).forEach((k) => delete editorEvents[k]); - editorState = { + editorState = reactive({ page: { id: 'p1', type: 'page', items: [{ id: 'n1', type: 'node' }] }, nodes: [{ id: 'n1' }], - }; + }); }); describe('useNodeStatus (layer)', () => { @@ -95,4 +98,87 @@ describe('useNodeStatus (layer)', () => { expect(editorService.on).toHaveBeenCalledWith('add', expect.any(Function)); expect(editorService.on).toHaveBeenCalledWith('remove', expect.any(Function)); }); + + test('注册 root-change 事件', () => { + const editorService = mkEditorService(); + useNodeStatus({ editorService } as any); + expect(editorService.on).toHaveBeenCalledWith('root-change', expect.any(Function)); + }); + + // 历史版本恢复 / 外部 modelValue 整体覆盖:page.id 不变但 page 引用与 items 全换。 + // 仅 watch page.id 时 nodeStatusMaps 不会重建并残留旧节点 id; + // 现在 watch page 引用 + root-change 时清缓存,能让组件树跟随新 dsl 重建。 + test('root 整体替换(page.id 不变)后 nodeStatusMap 跟随新 dsl 重建', async () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + + // 初始:旧 dsl 含 n1 + expect(nodeStatusMap.value?.has('n1')).toBe(true); + expect(nodeStatusMap.value?.has('n2')).toBe(false); + + // 模拟 root 整体被替换:先派发 root-change,再把 page 切换到 + // 「同 id 但 items 全新」的对象(异步 select 后效果)。 + // 新 root 中 p1 仍然存在,root-change 不会清掉 p1 缓存; + // 真正的重建由下面 page 引用变化触发的 watch(page) 完成。 + editorEvents['root-change'][0]({ + id: 'app', + items: [{ id: 'p1', type: 'page', items: [{ id: 'n2', type: 'node' }] }], + }); + editorState.page = { id: 'p1', type: 'page', items: [{ id: 'n2', type: 'node' }] }; + await nextTick(); + + expect(nodeStatusMap.value?.has('n1')).toBe(false); + expect(nodeStatusMap.value?.has('n2')).toBe(true); + expect(nodeStatusMap.value?.get('p1')?.expand).toBe(true); + }); + + // 套件编辑模式进入/退出场景:set('root', ...) 同步触发 root-change,但 page + // 引用要等 initService 的异步 IIFE 跑完 editorService.select(...) 才会换。 + // root-change 当下不能让 nodeStatusMap 变 undefined(否则 LayerPanel 的 + // `v-if="page && nodeStatusMap"` 会瞬间销毁组件树;若后续异步 select 因竞态 + // 没让 page 引用变化,watch(page) 不触发重建,组件树就再也回不来)。 + test('root-change 同步阶段(page 引用未变)nodeStatusMap 不应变 undefined', () => { + const editorService = mkEditorService(); + const { nodeStatusMap } = useNodeStatus({ editorService } as any); + + expect(nodeStatusMap.value?.has('p1')).toBe(true); + + // 模拟 set('root', ...) 同步触发 root-change,但 page 引用还来不及切。 + // 新 root 中 p1 仍然存在,因此 p1 的 status 缓存必须被保留。 + editorEvents['root-change'][0]({ + id: 'app', + items: [{ id: 'p1', type: 'page', items: [{ id: 'n1', type: 'node' }] }], + }); + + expect(nodeStatusMap.value).toBeDefined(); + expect(nodeStatusMap.value?.has('p1')).toBe(true); + }); + + // 新 root 中已不存在的 page id 缓存需要清理,避免脏数据长期残留。 + test('root-change 后清理新 root 中不存在的 page 缓存', () => { + const editorService = mkEditorService(); + const { nodeStatusMaps } = useNodeStatus({ editorService } as any); + + // 手动塞一个无关 page 缓存,模拟历史上访问过的另一个 page + nodeStatusMaps.value.set('p_old', new Map()); + expect(nodeStatusMaps.value.has('p_old')).toBe(true); + + editorEvents['root-change'][0]({ + id: 'app', + items: [{ id: 'p1', type: 'page', items: [] }], + }); + + expect(nodeStatusMaps.value.has('p_old')).toBe(false); + expect(nodeStatusMaps.value.has('p1')).toBe(true); + }); + + // root 被置空(卸载 / 退出编辑器)时整体清空。 + test('root-change 传入 null 时整体清空缓存', () => { + const editorService = mkEditorService(); + const { nodeStatusMaps } = useNodeStatus({ editorService } as any); + + expect(nodeStatusMaps.value.size).toBeGreaterThan(0); + editorEvents['root-change'][0](null); + expect(nodeStatusMaps.value.size).toBe(0); + }); });