Browse Source

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.
pull/8370/head
LostInTwilight 4 weeks ago
parent
commit
b175a5f2b4
  1. 11
      .changeset/quiet-trees-filter.md
  2. 94
      packages/@core/base/shared/src/utils/__tests__/tree.test.ts
  3. 28
      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.

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

@ -135,6 +135,100 @@ 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 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 = [

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

@ -42,6 +42,11 @@ function traverseTreeValues<T, V>(
/**
* 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。
*
* 该函数是纯函数:不会修改传入的树,也不会把源节点直接放进结果里。
* 否则第二次用不同条件过滤同一棵树时(例如切换用户角色后重新生成路由),
* 上一次被过滤掉的子节点将永久丢失。
*
* @param tree 要过滤的树结构的根节点数组。
* @param filter 用于匹配每个节点的条件。
* @param options 作为子节点数组的可选属性名称。
@ -57,15 +62,22 @@ 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];
// 只在需要替换子节点时才复制节点,避免污染源数据
result.push(
children ? { ...node, [childProps]: _filterTree(children) } : node,
);
}
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