diff --git a/.changeset/quiet-trees-filter.md b/.changeset/quiet-trees-filter.md new file mode 100644 index 000000000..08cf22b0f --- /dev/null +++ b/.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. 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 afe43cc5c..cef887396 100644 --- a/packages/@core/base/shared/src/utils/__tests__/tree.test.ts +++ b/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 = (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 = [ diff --git a/packages/@core/base/shared/src/utils/tree.ts b/packages/@core/base/shared/src/utils/tree.ts index 1a8a2ecab..ce43dc527 100644 --- a/packages/@core/base/shared/src/utils/tree.ts +++ b/packages/@core/base/shared/src/utils/tree.ts @@ -42,6 +42,11 @@ function traverseTreeValues( /** * 根据条件过滤给定树结构的节点,并以原有顺序返回所有匹配节点的数组。 + * + * 该函数是纯函数:不会修改传入的树,也不会把源节点直接放进结果里。 + * 否则第二次用不同条件过滤同一棵树时(例如切换用户角色后重新生成路由), + * 上一次被过滤掉的子节点将永久丢失。 + * * @param tree 要过滤的树结构的根节点数组。 * @param filter 用于匹配每个节点的条件。 * @param options 作为子节点数组的可选属性名称。 @@ -57,15 +62,22 @@ function filterTree>( }; const _filterTree = (nodes: T[]): T[] => { - return nodes.filter((node: Record) => { - 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)[childProps]; + + // 只在需要替换子节点时才复制节点,避免污染源数据 + result.push( + children ? { ...node, [childProps]: _filterTree(children) } : node, + ); + } + + return result; }; return _filterTree(tree); diff --git a/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts b/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts index 8e018530a..ef6fe34c8 100644 --- a/packages/utils/src/helpers/__tests__/generate-routes-frontend.test.ts +++ b/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', + ]); + }); });