mirror of
https://github.com/Tencent/tmagic-editor.git
synced 2026-05-30 04:08:04 +00:00
fix(editor): 修复 root 整体替换时图层面板节点状态残留与组件树闪烁问题
This commit is contained in:
parent
08011efd6d
commit
b9a6dd5b84
@ -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);
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user