Skip to content

Commit 62febc8

Browse files
DrJKLampcode-com
andcommitted
feat: Use store cache for AssetBrowserModal with stale-while-revalidate pattern
Amp-Thread-ID: https://ampcode.com/threads/T-019b99d4-72f2-71fe-84d6-7c4f4847a47c Co-authored-by: Amp <amp@ampcode.com>
1 parent 2bb46b6 commit 62febc8

File tree

3 files changed

+154
-114
lines changed

3 files changed

+154
-114
lines changed

src/platform/assets/components/AssetBrowserModal.test.ts

Lines changed: 47 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
55
import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue'
66
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
77

8-
const mockAssetService = vi.hoisted(() => ({
9-
getAssetsForNodeType: vi.fn(),
10-
getAssetsByTag: vi.fn(),
11-
getAssetDetails: vi.fn((id: string) =>
12-
Promise.resolve({
13-
id,
14-
name: 'Test Model',
15-
user_metadata: {
16-
filename: 'Test Model'
17-
}
18-
})
19-
)
8+
const mockAssetsStore = vi.hoisted(() => ({
9+
modelAssetsByNodeType: new Map<string, unknown[]>(),
10+
modelLoadingByNodeType: new Map<string, boolean>(),
11+
updateModelsForNodeType: vi.fn()
2012
}))
2113

2214
vi.mock('@/i18n', () => ({
@@ -25,8 +17,8 @@ vi.mock('@/i18n', () => ({
2517
d: (date: Date) => date.toLocaleDateString()
2618
}))
2719

28-
vi.mock('@/platform/assets/services/assetService', () => ({
29-
assetService: mockAssetService
20+
vi.mock('@/stores/assetsStore', () => ({
21+
useAssetsStore: () => mockAssetsStore
3022
}))
3123

3224
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -191,8 +183,9 @@ describe('AssetBrowserModal', () => {
191183
}
192184

193185
beforeEach(() => {
194-
mockAssetService.getAssetsForNodeType.mockReset()
195-
mockAssetService.getAssetsByTag.mockReset()
186+
mockAssetsStore.modelAssetsByNodeType.clear()
187+
mockAssetsStore.modelLoadingByNodeType.clear()
188+
mockAssetsStore.updateModelsForNodeType.mockReset()
196189
})
197190

198191
describe('Integration with useAssetBrowser', () => {
@@ -201,7 +194,10 @@ describe('AssetBrowserModal', () => {
201194
createTestAsset('asset1', 'Model A', 'checkpoints'),
202195
createTestAsset('asset2', 'Model B', 'loras')
203196
]
204-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
197+
mockAssetsStore.modelAssetsByNodeType.set(
198+
'CheckpointLoaderSimple',
199+
assets
200+
)
205201

206202
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
207203
await flushPromises()
@@ -218,7 +214,10 @@ describe('AssetBrowserModal', () => {
218214
createTestAsset('c1', 'model.safetensors', 'checkpoints'),
219215
createTestAsset('l1', 'lora.pt', 'loras')
220216
]
221-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
217+
mockAssetsStore.modelAssetsByNodeType.set(
218+
'CheckpointLoaderSimple',
219+
assets
220+
)
222221

223222
const wrapper = createWrapper({
224223
nodeType: 'CheckpointLoaderSimple',
@@ -234,31 +233,39 @@ describe('AssetBrowserModal', () => {
234233
})
235234

236235
describe('Data fetching', () => {
237-
it('fetches assets for node type', async () => {
238-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
239-
236+
it('triggers store refresh for node type on mount', async () => {
240237
createWrapper({ nodeType: 'CheckpointLoaderSimple' })
241238
await flushPromises()
242239

243-
expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith(
240+
expect(mockAssetsStore.updateModelsForNodeType).toHaveBeenCalledWith(
244241
'CheckpointLoaderSimple'
245242
)
246243
})
247244

248-
it('fetches assets for tag when node type not provided', async () => {
249-
mockAssetService.getAssetsByTag.mockResolvedValueOnce([])
245+
it('displays cached assets immediately from store', async () => {
246+
const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')]
247+
mockAssetsStore.modelAssetsByNodeType.set(
248+
'CheckpointLoaderSimple',
249+
assets
250+
)
250251

251-
createWrapper({ assetType: 'loras' })
252-
await flushPromises()
252+
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
253+
254+
const assetGrid = wrapper.findComponent({ name: 'AssetGrid' })
255+
const gridAssets = assetGrid.props('assets') as AssetItem[]
253256

254-
expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras')
257+
expect(gridAssets).toHaveLength(1)
258+
expect(gridAssets[0].name).toBe('Cached Model')
255259
})
256260
})
257261

258262
describe('Asset Selection', () => {
259263
it('emits asset-select event when asset is selected', async () => {
260264
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
261-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
265+
mockAssetsStore.modelAssetsByNodeType.set(
266+
'CheckpointLoaderSimple',
267+
assets
268+
)
262269

263270
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
264271
await flushPromises()
@@ -271,7 +278,10 @@ describe('AssetBrowserModal', () => {
271278

272279
it('executes onSelect callback when provided', async () => {
273280
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
274-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
281+
mockAssetsStore.modelAssetsByNodeType.set(
282+
'CheckpointLoaderSimple',
283+
assets
284+
)
275285

276286
const onSelect = vi.fn()
277287
const wrapper = createWrapper({
@@ -289,8 +299,6 @@ describe('AssetBrowserModal', () => {
289299

290300
describe('Left Panel Conditional Logic', () => {
291301
it('hides left panel by default when showLeftPanel is undefined', async () => {
292-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
293-
294302
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
295303
await flushPromises()
296304

@@ -299,8 +307,6 @@ describe('AssetBrowserModal', () => {
299307
})
300308

301309
it('shows left panel when showLeftPanel prop is explicitly true', async () => {
302-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
303-
304310
const wrapper = createWrapper({
305311
nodeType: 'CheckpointLoaderSimple',
306312
showLeftPanel: true
@@ -318,7 +324,10 @@ describe('AssetBrowserModal', () => {
318324
createTestAsset('asset1', 'Model A', 'checkpoints'),
319325
createTestAsset('asset2', 'Model B', 'loras')
320326
]
321-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
327+
mockAssetsStore.modelAssetsByNodeType.set(
328+
'CheckpointLoaderSimple',
329+
assets
330+
)
322331

323332
const wrapper = createWrapper({
324333
nodeType: 'CheckpointLoaderSimple',
@@ -339,8 +348,6 @@ describe('AssetBrowserModal', () => {
339348

340349
describe('Title Management', () => {
341350
it('passes custom title to BaseModalLayout when title prop provided', async () => {
342-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([])
343-
344351
const wrapper = createWrapper({
345352
nodeType: 'CheckpointLoaderSimple',
346353
title: 'Custom Title'
@@ -353,7 +360,10 @@ describe('AssetBrowserModal', () => {
353360

354361
it('passes computed contentTitle to BaseModalLayout when no title prop', async () => {
355362
const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')]
356-
mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets)
363+
mockAssetsStore.modelAssetsByNodeType.set(
364+
'CheckpointLoaderSimple',
365+
assets
366+
)
357367

358368
const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' })
359369
await flushPromises()

src/platform/assets/components/AssetBrowserModal.vue

Lines changed: 51 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,8 @@
6363
</template>
6464

6565
<script setup lang="ts">
66-
import {
67-
breakpointsTailwind,
68-
useAsyncState,
69-
useBreakpoints
70-
} from '@vueuse/core'
71-
import { computed, provide, watch } from 'vue'
66+
import { breakpointsTailwind, useBreakpoints } from '@vueuse/core'
67+
import { computed, provide, ref, watchEffect } from 'vue'
7268
import { useI18n } from 'vue-i18n'
7369
7470
import SearchBox from '@/components/common/SearchBox.vue'
@@ -81,68 +77,81 @@ import type { AssetDisplayItem } from '@/platform/assets/composables/useAssetBro
8177
import { useAssetBrowser } from '@/platform/assets/composables/useAssetBrowser'
8278
import { useModelUpload } from '@/platform/assets/composables/useModelUpload'
8379
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
84-
import { assetService } from '@/platform/assets/services/assetService'
8580
import { formatCategoryLabel } from '@/platform/assets/utils/categoryLabel'
86-
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
81+
import { useAssetsStore } from '@/stores/assetsStore'
8782
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
8883
import { OnCloseKey } from '@/types/widgetTypes'
8984
85+
const { t } = useI18n()
86+
const assetStore = useAssetsStore()
87+
const modelToNodeStore = useModelToNodeStore()
88+
const breakpoints = useBreakpoints(breakpointsTailwind)
89+
9090
const props = defineProps<{
9191
nodeType?: string
92+
assetType?: string
9293
onSelect?: (asset: AssetItem) => void
9394
onClose?: () => void
9495
showLeftPanel?: boolean
9596
title?: string
96-
assetType?: string
9797
}>()
9898
99-
const { t } = useI18n()
100-
10199
const emit = defineEmits<{
102100
'asset-select': [asset: AssetDisplayItem]
103101
close: []
104102
}>()
105103
106-
const breakpoints = useBreakpoints(breakpointsTailwind)
107-
108104
provide(OnCloseKey, props.onClose ?? (() => {}))
109105
110-
const fetchAssets = async () => {
111-
if (props.nodeType) {
112-
return (await assetService.getAssetsForNodeType(props.nodeType)) ?? []
113-
}
114-
115-
if (props.assetType) {
116-
return (await assetService.getAssetsByTag(props.assetType)) ?? []
117-
}
106+
// Compute the cache key based on nodeType or assetType
107+
const cacheKey = computed(() => {
108+
if (props.nodeType) return props.nodeType
109+
if (props.assetType) return `tag:${props.assetType}`
110+
return ''
111+
})
118112
119-
return []
120-
}
113+
// Read directly from store cache - reactive to any store updates
114+
const fetchedAssets = computed(
115+
() => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? []
116+
)
121117
122-
const {
123-
state: fetchedAssets,
124-
isLoading,
125-
execute
126-
} = useAsyncState<AssetItem[]>(fetchAssets, [], { immediate: false })
127-
128-
watch(
129-
() => [props.nodeType, props.assetType],
130-
async () => {
131-
await execute()
132-
},
133-
{ immediate: true }
118+
const isStoreLoading = computed(
119+
() => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false
134120
)
135121
136-
const assetDownloadStore = useAssetDownloadStore()
122+
// Only show loading spinner when loading AND no cached data
123+
const isLoading = computed(
124+
() => isStoreLoading.value && fetchedAssets.value.length === 0
125+
)
137126
138-
watch(
139-
() => assetDownloadStore.hasActiveDownloads,
140-
async (currentlyActive, previouslyActive) => {
141-
if (previouslyActive && !currentlyActive) {
142-
await execute()
127+
// Track if we've triggered a refresh for this key
128+
const refreshedKey = ref<string | null>(null)
129+
130+
// Trigger refresh when nodeType/assetType changes (runs on mount and prop change)
131+
watchEffect(async () => {
132+
const key = cacheKey.value
133+
if (key && key !== refreshedKey.value) {
134+
refreshedKey.value = key
135+
if (props.nodeType) {
136+
await assetStore.updateModelsForNodeType(props.nodeType)
137+
} else if (props.assetType) {
138+
await assetStore.updateModelsForTag(props.assetType)
143139
}
144140
}
145-
)
141+
})
142+
143+
async function refreshAssets(): Promise<AssetItem[]> {
144+
if (props.nodeType) {
145+
return await assetStore.updateModelsForNodeType(props.nodeType)
146+
}
147+
if (props.assetType) {
148+
return await assetStore.updateModelsForTag(props.assetType)
149+
}
150+
return []
151+
}
152+
153+
const { isUploadButtonEnabled, showUploadDialog } =
154+
useModelUpload(refreshAssets)
146155
147156
const {
148157
searchQuery,
@@ -153,8 +162,6 @@ const {
153162
updateFilters
154163
} = useAssetBrowser(fetchedAssets)
155164
156-
const modelToNodeStore = useModelToNodeStore()
157-
158165
const primaryCategoryTag = computed(() => {
159166
const assets = fetchedAssets.value ?? []
160167
const tagFromAssets = assets
@@ -202,6 +209,4 @@ function handleAssetSelectAndEmit(asset: AssetDisplayItem) {
202209
// It handles the appropriate transformation (filename extraction or full asset)
203210
props.onSelect?.(asset)
204211
}
205-
206-
const { isUploadButtonEnabled, showUploadDialog } = useModelUpload(execute)
207212
</script>

0 commit comments

Comments
 (0)