diff --git a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts b/packages/@core/base/shared/src/utils/__tests__/tree.test.ts index cef887396..a43ccd5c6 100644 --- a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts +++ b/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', () => { // 复现场景:低权限用户登录后,同一会话内再以更高权限重新生成路由。 // 修复前,上一次被过滤掉的子节点会被永久写回源数据, diff --git a/packages/@core/base/shared/src/utils/tree.ts b/packages/@core/base/shared/src/utils/tree.ts index ca772815d..5595d0b63 100644 --- a/packages/@core/base/shared/src/utils/tree.ts +++ b/packages/@core/base/shared/src/utils/tree.ts @@ -44,11 +44,13 @@ function traverseTreeValues( * 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。 * * 该函数是纯函数:不会把过滤后的结果写回传入的树(源数据不被修改)。 - * 只有命中且带有子节点的节点会被浅拷贝,并用过滤后的子数组替换 children; - * 没有子节点的命中节点仍按引用返回,以保留原有的引用语义。 * 否则第二次用不同条件过滤同一棵树时(例如切换用户角色后重新生成路由), * 上一次被过滤掉的子节点将永久丢失。 * + * 引用语义:只有当子节点序列真的发生变化时,命中的分支节点才会被浅拷贝; + * 子节点未发生变化(或节点没有子节点)时,仍按引用返回原节点, + * 因此按引用缓存或比较节点的调用方,其行为保持不变。 + * * @param tree 要过滤的树结构的根节点数组。 * @param filter 用于匹配每个节点的条件。 * @param options 作为子节点数组的可选属性名称。 @@ -73,9 +75,21 @@ function filterTree>( const children = (node as Record)[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 }, ); }