Browse Source

fix(@vben-core/shared): keep filterTree from mutating the source tree (#8370)

* fix(@vben-core/shared): keep filterTree from mutating the source tree

filterTree assigned the filtered child array back onto the source node, so nodes that failed
the predicate were dropped from the input tree permanently instead of only from the result.

In frontend/mixed access mode generateRoutesByFrontend filters the module-level accessRoutes
on every login and role refresh, so routes stripped for a low-privilege user stayed missing
for later sessions until a full page reload.

filterTree is now pure: matched nodes owning a child array are copied, nodes without children
are still returned by reference.

* docs(@vben-core/shared): correct filterTree reference-semantics comment

filterTree does not write its result back into the input tree, but childless
matched nodes are still returned by reference. The previous comment claimed no
source node is ever returned, which contradicted the implementation and could
mislead callers.

* 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/8383/head
loseintwilight 3 weeks ago
committed by GitHub
parent
commit
9c1c38806b
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 11
      .changeset/quiet-trees-filter.md
  2. 128
      packages/@core/base/shared/src/utils/__tests__/tree.test.ts
  3. 44
      packages/@core/base/shared/src/utils/tree.ts
  4. 23
      packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts

11
.changeset/quiet-trees-filter.md

@ -0,0 +1,11 @@
---
'@vben-core/shared': patch
---
fix(@vben-core/shared): keep filterTree from mutating the source tree
`filterTree` wrote the filtered child array back onto the source node (`node[childProps] = _filterTree(node[childProps])`), so every node that failed the predicate was permanently dropped from the input tree.
That input is a module-level constant in real apps: `generateRoutesByFrontend` filters `accessRoutes` on every login and role refresh. After a low-privilege user signed in, the routes removed for them were gone for every later session in the same page, so an admin would end up with an empty `/dashboard` submenu until a full page reload.
`filterTree` is now pure: matched nodes that own a child array are copied, nodes without children are still returned by reference.

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

@ -135,6 +135,134 @@ describe('filterTree', () => {
});
});
describe('filterTree immutability', () => {
type TreeNode = {
children?: TreeNode[];
id: number;
};
// 模拟 apps/*/src/router/routes 下的模块级路由常量。
// 它在每次登录或角色刷新时都会被 filterTree 重新过滤一次。
const buildTree = (): TreeNode[] => [
{
id: 1,
children: [
{ id: 2 },
{ id: 3, children: [{ id: 4 }, { id: 5 }, { id: 6 }] },
{ id: 7 },
],
},
{ id: 8, children: [{ id: 9 }, { id: 10 }] },
{ id: 11 },
];
const keepEven = (node: TreeNode) => node.id % 2 === 0;
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T;
it('should not mutate the source tree', () => {
const tree = buildTree();
const snapshot = clone(tree);
filterTree(tree, keepEven);
expect(tree).toEqual(snapshot);
});
it('should not write filtered children back onto the source node', () => {
const tree = buildTree();
const result = filterTree(tree, keepEven);
const sourceParent = tree.find((node) => node.id === 8);
const resultParent = result.find((node) => node.id === 8);
expect(sourceParent?.children?.map((child) => child.id)).toEqual([9, 10]);
expect(resultParent).not.toBe(sourceParent);
expect(resultParent?.children?.map((child) => child.id)).toEqual([10]);
});
it('should keep nodes without children by reference', () => {
const tree = buildTree();
const leaf = tree.find((node) => node.id === 11);
const result = filterTree(tree, () => true);
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', () => {
// 复现场景:低权限用户登录后,同一会话内再以更高权限重新生成路由。
// 修复前,上一次被过滤掉的子节点会被永久写回源数据,
// 导致后续过滤(即使是更宽松的条件)再也拿不到它们。
const tree = buildTree();
// 先用较窄的条件过滤一次(模拟低权限用户),丢弃 id 为 9 的子节点
filterTree(tree, (node) => node.id !== 9);
// 再放宽条件(模拟更高权限用户),被丢弃的节点应当重新出现
const widened = filterTree(tree, () => true);
expect(widened).toEqual(buildTree());
});
it('should not mutate the source tree with a custom childProps', () => {
type CustomNode = {
id: number;
items?: CustomNode[];
};
const tree: CustomNode[] = [
{ id: 1, items: [{ id: 2 }, { id: 3 }] },
{ id: 4, items: [{ id: 5 }] },
];
const snapshot = clone(tree);
const result = filterTree(tree, (node) => node.id !== 2, {
childProps: 'items',
});
expect(tree).toEqual(snapshot);
expect(result).toEqual([
{ id: 1, items: [{ id: 3 }] },
{ id: 4, items: [{ id: 5 }] },
]);
});
});
describe('mapTree', () => {
it('map infinite depth tree using mapTree', () => {
const tree = [

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

@ -42,6 +42,15 @@ function traverseTreeValues<T, V>(
/**
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
*
* 该函数是纯函数:不会把过滤后的结果写回传入的树(源数据不被修改)。
* 否则第二次用不同条件过滤同一棵树时(例如切换用户角色后重新生成路由),
* 上一次被过滤掉的子节点将永久丢失。
*
* 引用语义:只有当子节点序列真的发生变化时,命中的分支节点才会被浅拷贝;
* 子节点未发生变化(或节点没有子节点)时,仍按引用返回原节点,
* 因此按引用缓存或比较节点的调用方,其行为保持不变。
*
* @param tree 要过滤的树结构的根节点数组。
* @param filter 用于匹配每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
@ -57,15 +66,34 @@ function filterTree<T extends Record<string, any>>(
};
const _filterTree = (nodes: T[]): T[] => {
return nodes.filter((node: Record<string, any>) => {
if (filter(node as T)) {
if (node[childProps]) {
node[childProps] = _filterTree(node[childProps]);
}
return true;
const result: T[] = [];
for (const node of nodes) {
if (!filter(node)) {
continue;
}
return false;
});
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(
childrenUnchanged ? node : { ...node, [childProps]: filteredChildren },
);
}
return result;
};
return _filterTree(tree);

23
packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts

@ -102,4 +102,27 @@ describe('generateRoutesByFrontend', () => {
{ meta: { authority: ['admin'] }, path: '/path3' },
]);
});
it('should not corrupt the source route table across repeated generations', async () => {
// 复现场景:低权限用户先登录,同一会话内再以更高权限重新生成路由。
// filterTree 曾把上一次过滤掉的子节点写回源路由表,
// 导致高权限用户再也拿不到那些路由。
const routes = [
{
meta: { authority: ['admin', 'user'] },
path: '/dashboard',
children: [
{ path: '/dashboard/overview', meta: { authority: ['admin'] } },
{ path: '/dashboard/stats', meta: { authority: ['user'] } },
],
},
] as unknown as RouteRecordRaw[];
await generateRoutesByFrontend(routes, ['user']);
const asAdmin = await generateRoutesByFrontend(routes, ['admin']);
expect(asAdmin[0]?.children?.map((child) => child.path)).toEqual([
'/dashboard/overview',
]);
});
});

Loading…
Cancel
Save