From c4cf3b9525af8a39888ff15c5afe06dc5fbfdda5 Mon Sep 17 00:00:00 2001 From: roymondchen Date: Tue, 27 Oct 2026 20:52:23 +0800 Subject: [PATCH] =?UTF-8?q?fix(dep):=20=E4=BF=AE=E5=A4=8D=E5=85=B1?= =?UTF-8?q?=E7=94=A8=E8=8A=82=E7=82=B9=20id=20=E7=9A=84=E5=A4=9A=E4=B8=AA?= =?UTF-8?q?=E6=A0=B9=E8=8A=82=E7=82=B9=E6=94=B6=E9=9B=86=E4=BE=9D=E8=B5=96?= =?UTF-8?q?=E6=97=B6=E4=BA=92=E7=9B=B8=E6=B8=85=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 多个根节点(如复制出的页面片)共用节点 id 时,逐节点“删除旧依赖 + 收集”会让后一棵树的删除清掉前一棵树刚写入的依赖。改为先统一删除所有节点的旧依赖,再统一收集。 --- packages/dep/src/Watcher.ts | 6 +- packages/dep/tests/Watch.spec.ts | 82 +++++++++++++++++++ packages/editor/src/sdk/services/Dep.ts | 6 +- .../editor/tests/unit/services/dep.spec.ts | 43 ++++++++++ 4 files changed, 135 insertions(+), 2 deletions(-) diff --git a/packages/dep/src/Watcher.ts b/packages/dep/src/Watcher.ts index 4bb3de32..48ca090d 100644 --- a/packages/dep/src/Watcher.ts +++ b/packages/dep/src/Watcher.ts @@ -133,9 +133,13 @@ export default class Watcher { return; } - // 整棵树只遍历一次、在每个属性上检查所有 target,把结构遍历开销从 ×targets 降到 ×1(详见 collectItems) + // 先清掉本次节点上的旧依赖,再统一收集。 + // 复制出的页面片会共用节点 id。如果在收集每一棵树之前立刻删除, + // 后一棵树会按相同 id 把前一棵树刚写入的依赖清掉。 for (const node of nodes) { this.removeTargetsDep(targets, node); + } + for (const node of nodes) { this.collectItems(node, targets, depExtendedData, deep); } } diff --git a/packages/dep/tests/Watch.spec.ts b/packages/dep/tests/Watch.spec.ts index a0c8a554..204965e8 100644 --- a/packages/dep/tests/Watch.spec.ts +++ b/packages/dep/tests/Watch.spec.ts @@ -249,4 +249,86 @@ describe('Watcher', () => { expect(target1?.deps?.node_1).toBeUndefined(); expect(target1?.deps?.node_2).toBeUndefined(); }); + + test('collect keeps deps when roots share node ids', () => { + const watcher = new Watcher(); + + watcher.addTarget( + new Target({ + type: 'target', + id: 'ds_a', + isTarget: (key: string | number, value: any) => key === 'text' && value === 'a', + }), + ); + watcher.addTarget( + new Target({ + type: 'target', + id: 'ds_b', + isTarget: (key: string | number, value: any) => key === 'text' && value === 'b', + }), + ); + + watcher.collect( + [ + { + id: 'fragment_a', + name: 'fragmentA', + items: [ + { + id: 'shared', + name: 'shared-a', + text: 'a', + }, + ], + }, + { + id: 'fragment_b', + name: 'fragmentB', + items: [ + { + id: 'shared', + name: 'shared-b', + text: 'b', + }, + ], + }, + ], + {}, + true, + ); + + const targetA = watcher.getTarget('ds_a', 'target'); + const targetB = watcher.getTarget('ds_b', 'target'); + + expect(targetA?.deps?.shared.keys).toEqual(['text']); + expect(targetA?.deps?.shared.name).toBe('shared-a'); + expect(targetB?.deps?.shared.keys).toEqual(['text']); + expect(targetB?.deps?.shared.name).toBe('shared-b'); + }); + + test('collect merges keys when one target matches shared-id nodes in several roots', () => { + const watcher = new Watcher(); + + watcher.addTarget( + new Target({ + type: 'target', + id: 'ds', + isTarget: (key: string | number, value: any) => key === 'text' && value === 'x', + }), + ); + + watcher.collect( + [ + { id: 'root_a', items: [{ id: 'shared', name: 'shared-a', text: 'x' }] }, + { id: 'root_b', items: [{ id: 'shared', name: 'shared-b', title: 'y', text: 'x' }] }, + ], + {}, + true, + ); + + const dep = watcher.getTarget('ds', 'target')?.deps?.shared; + expect(dep?.keys).toEqual(['text']); + // 同 id 的依赖只保留一条,name 取最后收集的节点 + expect(dep?.name).toBe('shared-b'); + }); }); diff --git a/packages/editor/src/sdk/services/Dep.ts b/packages/editor/src/sdk/services/Dep.ts index 1da4df0f..11ccb0cc 100644 --- a/packages/editor/src/sdk/services/Dep.ts +++ b/packages/editor/src/sdk/services/Dep.ts @@ -150,8 +150,10 @@ class Dep extends BaseService { const targets = this.watcher.getCollectableTargets(type); if (targets.length) { + // 先统一删除所有节点的旧依赖,再统一收集。 + // 如果逐节点“删除 + 收集”,共用 id 的多棵树(如复制出的页面片), + // 后一棵树的删除会清掉前一棵树刚写入的依赖。 for (const node of nodes) { - // 先删除原有依赖,再批量收集 if (isPage(node)) { for (const target of targets) { this.removePageDep(target, depExtendedData); @@ -159,6 +161,8 @@ class Dep extends BaseService { } else { this.watcher.removeTargetsDep(targets, node); } + } + for (const node of nodes) { this.watcher.collectItems(node, targets, depExtendedData, deep); } } diff --git a/packages/editor/tests/unit/services/dep.spec.ts b/packages/editor/tests/unit/services/dep.spec.ts index 507fc948..f1f88bf7 100644 --- a/packages/editor/tests/unit/services/dep.spec.ts +++ b/packages/editor/tests/unit/services/dep.spec.ts @@ -227,6 +227,49 @@ describe('Dep service', () => { expect(() => depService.collect([{ id: 'p1', type: 'page', items: [] }] as any, { pageId: 'p1' })).not.toThrow(); }); + const makeTextTarget = (id: string, text: string) => + new Target({ + id, + type: DepTargetType.DEFAULT, + isTarget: (key: string | number, value: any) => key === 'text' && value === text, + }); + + const makeSharedIdFragments = () => [ + { id: 'fragment_a', type: 'container', items: [{ id: 'shared', type: 'text', text: 'a' }] }, + { id: 'fragment_b', type: 'container', items: [{ id: 'shared', type: 'text', text: 'b' }] }, + ]; + + test('collect 多个根节点共用子节点 id 时不会互相清掉依赖', () => { + const targetA = makeTextTarget('ds_a', 'a'); + const targetB = makeTextTarget('ds_b', 'b'); + depService.addTarget(targetA); + depService.addTarget(targetB); + + depService.collect(makeSharedIdFragments() as any, {}, true); + + expect(targetA.deps.shared?.keys).toEqual(['text']); + expect(targetB.deps.shared?.keys).toEqual(['text']); + }); + + test('collect 多个 page 节点同 pageId 时不会互相清掉依赖', () => { + const target = makeTextTarget('ds_a', 'a'); + depService.addTarget(target); + target.deps.stale = { name: 'stale', keys: ['text'], data: { pageId: 'p' } }; + + depService.collect( + [ + { id: 'page_1', type: 'page', text: 'a', items: [] }, + { id: 'page_2', type: 'page', items: [{ id: 'child', type: 'text', text: 'a' }] }, + ] as any, + { pageId: 'p' }, + true, + ); + + expect(target.deps.stale).toBeUndefined(); + expect(target.deps.page_1?.keys).toEqual(['text']); + expect(target.deps.child?.keys).toEqual(['text']); + }); + test('collectNode 支持 page 与普通节点两条路径', () => { const target = makeTarget('node-target'); depService.addTarget(target);