perf(skill): cache normalized asset tree index

This commit is contained in:
yyh
2026-03-27 12:51:00 +08:00
parent 53bba21dd9
commit 86bfbfc51a
2 changed files with 126 additions and 15 deletions

View File

@@ -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<string, AppAssetTreeView>
}
const secondOptions = mockUseQuery.mock.calls[1][0] as {
select: (data: AppAssetTreeResponse) => Map<string, AppAssetTreeView>
}
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<string>
}
const secondOptions = mockUseQuery.mock.calls[1][0] as {
select: (data: AppAssetTreeResponse) => Set<string>
}
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)
})
})
})

View File

@@ -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<string, AppAssetTreeView>
existingSkillNames: Set<string>
}
const EMPTY_NODE_MAP = new Map<string, AppAssetTreeView>()
const EMPTY_SKILL_NAMES = new Set<string>()
const EMPTY_SKILL_ASSET_INDEX: SkillAssetIndex = {
nodeMap: EMPTY_NODE_MAP,
existingSkillNames: EMPTY_SKILL_NAMES,
}
const skillAssetIndexCache = new WeakMap<AppAssetTreeResponse, SkillAssetIndex>()
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<string>()
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<string, AppAssetTreeView> => {
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<string> => {
if (!data?.children)
return new Set()
const names = new Set<string>()
for (const node of data.children) {
if (node.node_type === 'folder')
names.add(node.name)
}
return names
},
select: selectExistingSkillNames,
})
}