fix(editor): 修复 root 整体替换时图层面板节点状态残留与组件树闪烁问题

This commit is contained in:
roymondchen 2026-05-26 17:06:45 +08:00
parent 08011efd6d
commit b9a6dd5b84
2 changed files with 144 additions and 9 deletions

View File

@ -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<Id, LayerNodeStatus>(),
);
// 切换页面或者新增页面,重新生成节点状态
// 切换页面 / 新增页面 / 整体替换 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<Id>();
(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);
});

View File

@ -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<string, any>;
const editorEvents: Record<string, ((..._args: any[]) => 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);
});
});