fix(dep): 修复共用节点 id 的多个根节点收集依赖时互相清除

多个根节点(如复制出的页面片)共用节点 id 时,逐节点“删除旧依赖 + 收集”会让后一棵树的删除清掉前一棵树刚写入的依赖。改为先统一删除所有节点的旧依赖,再统一收集。
This commit is contained in:
roymondchen 2026-10-27 20:52:23 +08:00
parent d01bc45b17
commit c4cf3b9525
4 changed files with 135 additions and 2 deletions

View File

@ -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);
}
}

View File

@ -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');
});
});

View File

@ -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);
}
}

View File

@ -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);