Browse Source

fix(@vben-core/shared): preserve branch references in filterTree

filterTree copied every matched branch node, so filtering a tree where nothing
changed still returned new branch objects. The previous implementation used
Array#filter, which keeps element references, so callers could rely on
filterTree(input, () => true)[0] === input[0].

Branch nodes are now shallow-copied only when their child sequence actually
changes; unchanged nodes are returned by reference, restoring the reference
semantics callers had before. The mutation fix itself is unaffected: the
filtered child array is never assigned back onto the source node.
pull/8370/head
LostInTwilight 4 weeks ago
parent
commit
8042eb60ea
  1. 34
      packages/@core/base/shared/src/utils/__tests__/tree.test.ts
  2. 22
      packages/@core/base/shared/src/utils/tree.ts

34
packages/@core/base/shared/src/utils/__tests__/tree.test.ts

@ -190,6 +190,40 @@ describe('filterTree immutability', () => {
expect(result.find((node) => node.id === 11)).toBe(leaf);
});
it('should keep branch nodes by reference when no child is dropped', () => {
// 过滤前后子节点序列完全一致时,分支节点必须仍是原来的对象,
// 否则按引用缓存路由/菜单节点的调用方会视为内容发生变化。
const tree = buildTree();
const result = filterTree(tree, () => true);
expect(result[0]).toBe(tree[0]);
expect(result[1]).toBe(tree[1]);
expect(result[0]?.children).toEqual(tree[0]?.children);
expect(result[0]?.children?.[1]).toBe(tree[0]?.children?.[1]);
});
it('should only copy the branches on the path of a dropped node', () => {
const tree = buildTree();
// 只丢弃深层的一个节点(id 6),其余分支应当保持原引用
const result = filterTree(tree, (node) => node.id !== 6);
const sourceBranch = tree[0]?.children?.find((node) => node.id === 3);
const resultBranch = result[0]?.children?.find((node) => node.id === 3);
// 受影响的分支:被复制,源节点不受影响
expect(resultBranch).not.toBe(sourceBranch);
expect(sourceBranch?.children?.map((child) => child.id)).toEqual([4, 5, 6]);
expect(resultBranch?.children?.map((child) => child.id)).toEqual([4, 5]);
// 未受影响的兄弟分支与根节点以外的分支:保持原引用
expect(result[0]?.children?.[0]).toBe(tree[0]?.children?.[0]);
expect(result[0]?.children?.[2]).toBe(tree[0]?.children?.[2]);
expect(result[1]).toBe(tree[1]);
expect(result[2]).toBe(tree[2]);
});
it('should keep returning nodes that an earlier filter run dropped', () => {
// 复现场景:低权限用户登录后,同一会话内再以更高权限重新生成路由。
// 修复前,上一次被过滤掉的子节点会被永久写回源数据,

22
packages/@core/base/shared/src/utils/tree.ts

@ -44,11 +44,13 @@ function traverseTreeValues<T, V>(
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
*
* 该函数是纯函数:不会把过滤后的结果写回传入的树(源数据不被修改)。
* 只有命中且带有子节点的节点会被浅拷贝,并用过滤后的子数组替换 children;
* 没有子节点的命中节点仍按引用返回,以保留原有的引用语义。
* 否则第二次用不同条件过滤同一棵树时(例如切换用户角色后重新生成路由),
* 上一次被过滤掉的子节点将永久丢失。
*
* 引用语义:只有当子节点序列真的发生变化时,命中的分支节点才会被浅拷贝;
* 子节点未发生变化(或节点没有子节点)时,仍按引用返回原节点,
* 因此按引用缓存或比较节点的调用方,其行为保持不变。
*
* @param tree 要过滤的树结构的根节点数组。
* @param filter 用于匹配每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
@ -73,9 +75,21 @@ function filterTree<T extends Record<string, any>>(
const children = (node as Record<string, any>)[childProps];
// 只在需要替换子节点时才复制节点,避免污染源数据
if (!children) {
result.push(node);
continue;
}
const filteredChildren = _filterTree(children);
// 子节点序列没有变化时按引用返回原节点,保持原有的引用语义;
// 只有确实需要替换子节点时才复制节点,避免污染源数据
const childrenUnchanged =
filteredChildren.length === children.length &&
filteredChildren.every((child, index) => child === children[index]);
result.push(
children ? { ...node, [childProps]: _filterTree(children) } : node,
childrenUnchanged ? node : { ...node, [childProps]: filteredChildren },
);
}

Loading…
Cancel
Save