diff --git a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx index f196914af5..9f8d8db0f8 100644 --- a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx +++ b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.spec.tsx @@ -3,6 +3,7 @@ import type { AppAssetTreeResponse, AppAssetTreeView } from '@/types/app-asset' import { renderHook } from '@testing-library/react' import { useStore as useAppStore } from '@/app/components/app/store' import { + getSkillAssetIndex, useExistingSkillNames, useSkillAssetNodeMap, useSkillAssetTreeData, @@ -102,6 +103,30 @@ describe('useSkillAssetTree', () => { expect(map.size).toBe(2) }) + it('should reuse the same selector reference and cached map for the same tree response', () => { + renderHook(() => useSkillAssetNodeMap()) + renderHook(() => useSkillAssetNodeMap()) + + const firstOptions = mockUseQuery.mock.calls[0][0] as { + select: (data: AppAssetTreeResponse) => Map + } + const secondOptions = mockUseQuery.mock.calls[1][0] as { + select: (data: AppAssetTreeResponse) => Map + } + const treeData = { + children: [ + createTreeNode({ + id: 'folder-1', + node_type: 'folder', + name: 'skill-a', + }), + ], + } satisfies AppAssetTreeResponse + + expect(firstOptions.select).toBe(secondOptions.select) + expect(firstOptions.select(treeData)).toBe(firstOptions.select(treeData)) + }) + it('should return an empty map when tree response has no children', () => { renderHook(() => useSkillAssetNodeMap()) @@ -157,6 +182,30 @@ describe('useSkillAssetTree', () => { expect(names.size).toBe(2) }) + it('should reuse the same selector reference and cached names for the same tree response', () => { + renderHook(() => useExistingSkillNames()) + renderHook(() => useExistingSkillNames()) + + const firstOptions = mockUseQuery.mock.calls[0][0] as { + select: (data: AppAssetTreeResponse) => Set + } + const secondOptions = mockUseQuery.mock.calls[1][0] as { + select: (data: AppAssetTreeResponse) => Set + } + const treeData = { + children: [ + createTreeNode({ + id: 'folder-1', + node_type: 'folder', + name: 'skill-a', + }), + ], + } satisfies AppAssetTreeResponse + + expect(firstOptions.select).toBe(secondOptions.select) + expect(firstOptions.select(treeData)).toBe(firstOptions.select(treeData)) + }) + it('should return an empty set when tree response has no children', () => { renderHook(() => useExistingSkillNames()) @@ -169,4 +218,41 @@ describe('useSkillAssetTree', () => { expect(names.size).toBe(0) }) }) + + describe('getSkillAssetIndex', () => { + it('should share the same normalized index for the same tree response', () => { + const treeData = { + children: [ + createTreeNode({ + id: 'folder-1', + node_type: 'folder', + name: 'skill-a', + children: [ + createTreeNode({ + id: 'file-1', + node_type: 'file', + name: 'README.md', + extension: 'md', + }), + ], + }), + ], + } satisfies AppAssetTreeResponse + + const firstIndex = getSkillAssetIndex(treeData) + const secondIndex = getSkillAssetIndex(treeData) + + expect(firstIndex).toBe(secondIndex) + expect(firstIndex.nodeMap).toBe(secondIndex.nodeMap) + expect(firstIndex.existingSkillNames).toBe(secondIndex.existingSkillNames) + expect(firstIndex.nodeMap.get('file-1')?.name).toBe('README.md') + expect(firstIndex.existingSkillNames.has('skill-a')).toBe(true) + }) + + it('should reuse the shared empty index when tree data is missing', () => { + expect(getSkillAssetIndex()).toBe(getSkillAssetIndex(undefined)) + expect(getSkillAssetIndex(null).nodeMap.size).toBe(0) + expect(getSkillAssetIndex(null).existingSkillNames.size).toBe(0) + }) + }) }) diff --git a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.ts b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.ts index 58bb22ab74..2b1987a05e 100644 --- a/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.ts +++ b/web/app/components/workflow/skill/hooks/file-tree/data/use-skill-asset-tree.ts @@ -4,11 +4,49 @@ import { useStore as useAppStore } from '@/app/components/app/store' import { appAssetTreeOptions } from '@/service/use-app-asset' import { buildNodeMap } from '../../../utils/tree-utils' +type SkillAssetIndex = { + nodeMap: Map + existingSkillNames: Set +} + +const EMPTY_NODE_MAP = new Map() +const EMPTY_SKILL_NAMES = new Set() +const EMPTY_SKILL_ASSET_INDEX: SkillAssetIndex = { + nodeMap: EMPTY_NODE_MAP, + existingSkillNames: EMPTY_SKILL_NAMES, +} +const skillAssetIndexCache = new WeakMap() + function useSkillAppId(): string { const appDetail = useAppStore(s => s.appDetail) return appDetail?.id || '' } +export function getSkillAssetIndex(data?: AppAssetTreeResponse | null): SkillAssetIndex { + if (!data?.children?.length) + return EMPTY_SKILL_ASSET_INDEX + + const cachedIndex = skillAssetIndexCache.get(data) + if (cachedIndex) + return cachedIndex + + const existingSkillNames = new Set() + for (const node of data.children) { + if (node.node_type === 'folder') + existingSkillNames.add(node.name) + } + + const index = { + nodeMap: buildNodeMap(data.children), + existingSkillNames, + } + skillAssetIndexCache.set(data, index) + return index +} + +const selectSkillAssetNodeMap = (data: AppAssetTreeResponse) => getSkillAssetIndex(data).nodeMap +const selectExistingSkillNames = (data: AppAssetTreeResponse) => getSkillAssetIndex(data).existingSkillNames + export function useSkillAssetTreeData() { const appId = useSkillAppId() return useQuery(appAssetTreeOptions(appId)) @@ -18,11 +56,7 @@ export function useSkillAssetNodeMap() { const appId = useSkillAppId() return useQuery({ ...appAssetTreeOptions(appId), - select: (data: AppAssetTreeResponse): Map => { - if (!data?.children) - return new Map() - return buildNodeMap(data.children) - }, + select: selectSkillAssetNodeMap, }) } @@ -30,15 +64,6 @@ export function useExistingSkillNames() { const appId = useSkillAppId() return useQuery({ ...appAssetTreeOptions(appId), - select: (data: AppAssetTreeResponse): Set => { - if (!data?.children) - return new Set() - const names = new Set() - for (const node of data.children) { - if (node.node_type === 'folder') - names.add(node.name) - } - return names - }, + select: selectExistingSkillNames, }) }