From dc39d79891f44cf2eca1e1372cf778c3eb4dc251 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Tue, 20 Jan 2026 23:36:49 -0800 Subject: [PATCH 01/18] feat: implement progressive pagination for Asset Browser model assets Amp-Thread-ID: https://ampcode.com/threads/T-019bdf76-f9a9-726f-84ea-6c95809f6438 Co-authored-by: Amp --- .../components/AssetBrowserModal.test.ts | 50 +++-- .../assets/components/AssetBrowserModal.vue | 8 +- src/platform/assets/services/assetService.ts | 29 ++- .../useAssetWidgetData.desktop.test.ts | 6 +- .../composables/useAssetWidgetData.test.ts | 44 ++-- .../widgets/composables/useAssetWidgetData.ts | 14 +- src/stores/assetsStore.ts | 195 ++++++++++++------ 7 files changed, 218 insertions(+), 128 deletions(-) diff --git a/src/platform/assets/components/AssetBrowserModal.test.ts b/src/platform/assets/components/AssetBrowserModal.test.ts index 87135b46893..6fb02751982 100644 --- a/src/platform/assets/components/AssetBrowserModal.test.ts +++ b/src/platform/assets/components/AssetBrowserModal.test.ts @@ -6,6 +6,9 @@ import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vu import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useAssetsStore } from '@/stores/assetsStore' +const mockAssetsByKey = vi.hoisted(() => new Map()) +const mockLoadingByKey = vi.hoisted(() => new Map()) + vi.mock('@/i18n', () => ({ t: (key: string, params?: Record) => params ? `${key}:${JSON.stringify(params)}` : key, @@ -13,13 +16,20 @@ vi.mock('@/i18n', () => ({ })) vi.mock('@/stores/assetsStore', () => { - const store = { - modelAssetsByNodeType: new Map(), - modelLoadingByNodeType: new Map(), - updateModelsForNodeType: vi.fn(), - updateModelsForTag: vi.fn() + const getAssets = vi.fn((key: string) => mockAssetsByKey.get(key) ?? []) + const isModelLoading = vi.fn( + (key: string) => mockLoadingByKey.get(key) ?? false + ) + const updateModelsForNodeType = vi.fn() + const updateModelsForTag = vi.fn() + return { + useAssetsStore: () => ({ + getAssets, + isModelLoading, + updateModelsForNodeType, + updateModelsForTag + }) } - return { useAssetsStore: () => store } }) vi.mock('@/stores/modelToNodeStore', () => ({ @@ -183,12 +193,10 @@ describe('AssetBrowserModal', () => { }) } - const mockStore = useAssetsStore() - beforeEach(() => { vi.resetAllMocks() - mockStore.modelAssetsByNodeType.clear() - mockStore.modelLoadingByNodeType.clear() + mockAssetsByKey.clear() + mockLoadingByKey.clear() }) describe('Integration with useAssetBrowser', () => { @@ -197,7 +205,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -214,7 +222,7 @@ describe('AssetBrowserModal', () => { createTestAsset('c1', 'model.safetensors', 'checkpoints'), createTestAsset('l1', 'lora.pt', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -231,17 +239,18 @@ describe('AssetBrowserModal', () => { describe('Data fetching', () => { it('triggers store refresh for node type on mount', async () => { + const store = useAssetsStore() createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith( + expect(store.updateModelsForNodeType).toHaveBeenCalledWith( 'CheckpointLoaderSimple' ) }) it('displays cached assets immediately from store', async () => { const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) @@ -253,15 +262,16 @@ describe('AssetBrowserModal', () => { }) it('triggers store refresh for asset type (tag) on mount', async () => { + const store = useAssetsStore() createWrapper({ assetType: 'models' }) await flushPromises() - expect(mockStore.updateModelsForTag).toHaveBeenCalledWith('models') + expect(store.updateModelsForTag).toHaveBeenCalledWith('models') }) it('uses tag: prefix for cache key when assetType is provided', async () => { const assets = [createTestAsset('asset1', 'Tagged Model', 'models')] - mockStore.modelAssetsByNodeType.set('tag:models', assets) + mockAssetsByKey.set('tag:models', assets) const wrapper = createWrapper({ assetType: 'models' }) await flushPromises() @@ -277,7 +287,7 @@ describe('AssetBrowserModal', () => { describe('Asset Selection', () => { it('emits asset-select event when asset is selected', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -290,7 +300,7 @@ describe('AssetBrowserModal', () => { it('executes onSelect callback when provided', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const onSelect = vi.fn() const wrapper = createWrapper({ @@ -333,7 +343,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -366,7 +376,7 @@ describe('AssetBrowserModal', () => { it('passes computed contentTitle to BaseModalLayout when no title prop', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) + mockAssetsByKey.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index 8344a807fe9..19159274462 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -112,13 +112,9 @@ const cacheKey = computed(() => { }) // Read directly from store cache - reactive to any store updates -const fetchedAssets = computed( - () => assetStore.modelAssetsByNodeType.get(cacheKey.value) ?? [] -) +const fetchedAssets = computed(() => assetStore.getAssets(cacheKey.value)) -const isStoreLoading = computed( - () => assetStore.modelLoadingByNodeType.get(cacheKey.value) ?? false -) +const isStoreLoading = computed(() => assetStore.isModelLoading(cacheKey.value)) // Only show loading spinner when loading AND no cached data const isLoading = computed( diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index da87681bbe3..c07b8c0f61e 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -1,6 +1,11 @@ import { fromZodError } from 'zod-validation-error' import { st } from '@/i18n' + +export interface PaginationOptions { + limit?: number + offset?: number +} import { assetItemSchema, assetResponseSchema, @@ -169,9 +174,15 @@ function createAssetService() { * and fetching all assets with that category tag * * @param nodeType - The ComfyUI node type (e.g., 'CheckpointLoaderSimple') + * @param options - Pagination options + * @param options.limit - Maximum number of assets to return (default: 500) + * @param options.offset - Number of assets to skip (default: 0) * @returns Promise - Full asset objects with preserved metadata */ - async function getAssetsForNodeType(nodeType: string): Promise { + async function getAssetsForNodeType( + nodeType: string, + { limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {} + ): Promise { if (!nodeType || typeof nodeType !== 'string') { return [] } @@ -184,9 +195,18 @@ function createAssetService() { return [] } + const queryParams = new URLSearchParams({ + include_tags: `${MODELS_TAG},${category}`, + limit: limit.toString() + }) + + if (offset > 0) { + queryParams.set('offset', offset.toString()) + } + // Fetch assets for this category using same API pattern as getAssetModels const data = await handleAssetRequest( - `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${category}&limit=${DEFAULT_LIMIT}`, + `${ASSETS_ENDPOINT}?${queryParams.toString()}`, `assets for ${nodeType}` ) @@ -242,10 +262,7 @@ function createAssetService() { async function getAssetsByTag( tag: string, includePublic: boolean = true, - { - limit = DEFAULT_LIMIT, - offset = 0 - }: { limit?: number; offset?: number } = {} + { limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {} ): Promise { const queryParams = new URLSearchParams({ include_tags: tag, diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts index 2410f096451..1c51b9d308e 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.desktop.test.ts @@ -12,9 +12,9 @@ const mockGetCategoryForNodeType = vi.fn() vi.mock('@/stores/assetsStore', () => ({ useAssetsStore: () => ({ - modelAssetsByNodeType: new Map(), - modelLoadingByNodeType: new Map(), - modelErrorByNodeType: new Map(), + getAssets: () => [], + isModelLoading: () => false, + getError: () => undefined, updateModelsForNodeType: mockUpdateModelsForNodeType }) })) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts index 9c54564f1aa..131d3245da9 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.test.ts @@ -8,17 +8,17 @@ vi.mock('@/platform/distribution/types', () => ({ isCloud: true })) -const mockModelAssetsByNodeType = new Map() -const mockModelLoadingByNodeType = new Map() -const mockModelErrorByNodeType = new Map() +const mockAssetsByKey = new Map() +const mockLoadingByKey = new Map() +const mockErrorByKey = new Map() const mockUpdateModelsForNodeType = vi.fn() const mockGetCategoryForNodeType = vi.fn() vi.mock('@/stores/assetsStore', () => ({ useAssetsStore: () => ({ - modelAssetsByNodeType: mockModelAssetsByNodeType, - modelLoadingByNodeType: mockModelLoadingByNodeType, - modelErrorByNodeType: mockModelErrorByNodeType, + getAssets: (key: string) => mockAssetsByKey.get(key) ?? [], + isModelLoading: (key: string) => mockLoadingByKey.get(key) ?? false, + getError: (key: string) => mockErrorByKey.get(key), updateModelsForNodeType: mockUpdateModelsForNodeType }) })) @@ -32,9 +32,9 @@ vi.mock('@/stores/modelToNodeStore', () => ({ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { beforeEach(() => { vi.clearAllMocks() - mockModelAssetsByNodeType.clear() - mockModelLoadingByNodeType.clear() - mockModelErrorByNodeType.clear() + mockAssetsByKey.clear() + mockLoadingByKey.clear() + mockErrorByKey.clear() mockGetCategoryForNodeType.mockReturnValue(undefined) mockUpdateModelsForNodeType.mockImplementation( @@ -76,8 +76,8 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelAssetsByNodeType.set(_nodeType, mockAssets) - mockModelLoadingByNodeType.set(_nodeType, false) + mockAssetsByKey.set(_nodeType, mockAssets) + mockLoadingByKey.set(_nodeType, false) return mockAssets } ) @@ -108,9 +108,9 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelErrorByNodeType.set(_nodeType, mockError) - mockModelAssetsByNodeType.set(_nodeType, []) - mockModelLoadingByNodeType.set(_nodeType, false) + mockErrorByKey.set(_nodeType, mockError) + mockAssetsByKey.set(_nodeType, []) + mockLoadingByKey.set(_nodeType, false) return [] } ) @@ -130,8 +130,8 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelAssetsByNodeType.set(_nodeType, []) - mockModelLoadingByNodeType.set(_nodeType, false) + mockAssetsByKey.set(_nodeType, []) + mockLoadingByKey.set(_nodeType, false) return [] } ) @@ -154,8 +154,8 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockGetCategoryForNodeType.mockReturnValue('checkpoints') mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelAssetsByNodeType.set(_nodeType, mockAssets) - mockModelLoadingByNodeType.set(_nodeType, false) + mockAssetsByKey.set(_nodeType, mockAssets) + mockLoadingByKey.set(_nodeType, false) return mockAssets } ) @@ -182,8 +182,8 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockGetCategoryForNodeType.mockReturnValue('loras') mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelAssetsByNodeType.set(_nodeType, mockAssets) - mockModelLoadingByNodeType.set(_nodeType, false) + mockAssetsByKey.set(_nodeType, mockAssets) + mockLoadingByKey.set(_nodeType, false) return mockAssets } ) @@ -209,8 +209,8 @@ describe('useAssetWidgetData (cloud mode, isCloud=true)', () => { mockGetCategoryForNodeType.mockReturnValue('checkpoints') mockUpdateModelsForNodeType.mockImplementation( async (_nodeType: string): Promise => { - mockModelAssetsByNodeType.set(_nodeType, mockAssets) - mockModelLoadingByNodeType.set(_nodeType, false) + mockAssetsByKey.set(_nodeType, mockAssets) + mockLoadingByKey.set(_nodeType, false) return mockAssets } ) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts index b03f1dac93d..6f2618b25df 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts @@ -34,23 +34,17 @@ export function useAssetWidgetData( const assets = computed(() => { const resolvedType = toValue(nodeType) - return resolvedType - ? (assetsStore.modelAssetsByNodeType.get(resolvedType) ?? []) - : [] + return resolvedType ? assetsStore.getAssets(resolvedType) : [] }) const isLoading = computed(() => { const resolvedType = toValue(nodeType) - return resolvedType - ? (assetsStore.modelLoadingByNodeType.get(resolvedType) ?? false) - : false + return resolvedType ? assetsStore.isModelLoading(resolvedType) : false }) const error = computed(() => { const resolvedType = toValue(nodeType) - return resolvedType - ? (assetsStore.modelErrorByNodeType.get(resolvedType) ?? null) - : null + return resolvedType ? (assetsStore.getError(resolvedType) ?? null) : null }) const dropdownItems = computed(() => { @@ -71,7 +65,7 @@ export function useAssetWidgetData( return } - const hasData = assetsStore.modelAssetsByNodeType.has(currentNodeType) + const hasData = assetsStore.getAssets(currentNodeType).length > 0 if (!hasData) { await assetsStore.updateModelsForNodeType(currentNodeType) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index a387aea7d3a..737e67c9a5f 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -1,13 +1,13 @@ import { useAsyncState, whenever } from '@vueuse/core' -import { isEqual } from 'es-toolkit' import { defineStore } from 'pinia' -import { computed, shallowReactive, ref } from 'vue' +import { computed, reactive, ref, shallowReactive } from 'vue' import { mapInputFileToAssetItem, mapTaskOutputToAssetItem } from '@/platform/assets/composables/media/assetMappers' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { assetService } from '@/platform/assets/services/assetService' +import type { PaginationOptions } from '@/platform/assets/services/assetService' import { isCloud } from '@/platform/distribution/types' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' import { api } from '@/scripts/api' @@ -251,6 +251,16 @@ export const useAssetsStore = defineStore('assets', () => { return inputAssetsByFilename.value.get(filename)?.name ?? filename } + const MODEL_BATCH_SIZE = 500 + + interface ModelPaginationState { + assets: Map + offset: number + hasMore: boolean + isLoading: boolean + error?: Error + } + /** * Model assets cached by node type (e.g., 'CheckpointLoaderSimple', 'LoraLoader') * Used by multiple loader nodes to avoid duplicate fetches @@ -258,62 +268,116 @@ export const useAssetsStore = defineStore('assets', () => { */ const getModelState = () => { if (isCloud) { - const modelAssetsByNodeType = shallowReactive( - new Map() - ) - const modelLoadingByNodeType = shallowReactive(new Map()) - const modelErrorByNodeType = shallowReactive( - new Map() - ) + const modelStateByKey = ref(new Map()) + + function createInitialState(): ModelPaginationState { + const state: ModelPaginationState = { + assets: new Map(), + offset: 0, + hasMore: true, + isLoading: false + } + return reactive(state) + } - const stateByNodeType = shallowReactive( - new Map>>() - ) + function getOrCreateState(key: string): ModelPaginationState { + if (!modelStateByKey.value.has(key)) { + modelStateByKey.value.set(key, createInitialState()) + } + return modelStateByKey.value.get(key)! + } + + function resetPaginationForKey(key: string) { + const state = getOrCreateState(key) + state.assets = new Map() + state.offset = 0 + state.hasMore = true + delete state.error + } + + function getAssets(key: string): AssetItem[] { + return Array.from(modelStateByKey.value.get(key)?.assets.values() ?? []) + } + + function isLoading(key: string): boolean { + return modelStateByKey.value.get(key)?.isLoading ?? false + } + + function getError(key: string): Error | undefined { + return modelStateByKey.value.get(key)?.error + } + + function hasMore(key: string): boolean { + return modelStateByKey.value.get(key)?.hasMore ?? false + } /** - * Internal helper to fetch and cache assets with a given key and fetcher + * Internal helper to fetch and cache assets with a given key and fetcher. + * Loads first batch immediately, then progressively loads remaining batches. */ async function updateModelsForKey( key: string, - fetcher: () => Promise + fetcher: (options: PaginationOptions) => Promise ): Promise { - if (!stateByNodeType.has(key)) { - stateByNodeType.set( - key, - useAsyncState(fetcher, [], { - immediate: false, - resetOnExecute: false, - onError: (err) => { - console.error(`Error fetching model assets for ${key}:`, err) - } - }) - ) - } - - const state = stateByNodeType.get(key)! + const state = getOrCreateState(key) - modelLoadingByNodeType.set(key, true) - modelErrorByNodeType.set(key, null) + resetPaginationForKey(key) + state.isLoading = true try { - await state.execute() + const assets = await fetcher({ + limit: MODEL_BATCH_SIZE, + offset: 0 + }) + + state.assets = new Map(assets.map((a) => [a.id, a])) + state.offset = assets.length + state.hasMore = assets.length === MODEL_BATCH_SIZE + + if (state.hasMore) { + void loadRemainingBatches(key, fetcher) + } + + return assets + } catch (err) { + state.error = err instanceof Error ? err : new Error(String(err)) + console.error(`Error fetching model assets for ${key}:`, err) + return [] } finally { - modelLoadingByNodeType.set(key, state.isLoading.value) + state.isLoading = false } + } - const assets = state.state.value - const existingAssets = modelAssetsByNodeType.get(key) + /** + * Progressively load remaining batches until complete + */ + async function loadRemainingBatches( + key: string, + fetcher: (options: PaginationOptions) => Promise + ): Promise { + const state = modelStateByKey.value.get(key) + if (!state) return + + while (state.hasMore) { + try { + const newAssets = await fetcher({ + limit: MODEL_BATCH_SIZE, + offset: state.offset + }) - if (!isEqual(existingAssets, assets)) { - modelAssetsByNodeType.set(key, assets) + for (const asset of newAssets) { + if (!state.assets.has(asset.id)) { + state.assets.set(asset.id, asset) + } + } + + state.offset += newAssets.length + state.hasMore = newAssets.length === MODEL_BATCH_SIZE + } catch (err) { + console.error(`Error loading batch for ${key}:`, err) + break + } } - - modelErrorByNodeType.set( - key, - state.error.value instanceof Error ? state.error.value : null - ) - - return assets } /** @@ -324,8 +388,8 @@ export const useAssetsStore = defineStore('assets', () => { async function updateModelsForNodeType( nodeType: string ): Promise { - return updateModelsForKey(nodeType, () => - assetService.getAssetsForNodeType(nodeType) + return updateModelsForKey(nodeType, (opts) => + assetService.getAssetsForNodeType(nodeType, opts) ) } @@ -336,31 +400,37 @@ export const useAssetsStore = defineStore('assets', () => { */ async function updateModelsForTag(tag: string): Promise { const key = `tag:${tag}` - return updateModelsForKey(key, () => assetService.getAssetsByTag(tag)) + return updateModelsForKey(key, (opts) => + assetService.getAssetsByTag(tag, true, opts) + ) } return { - modelAssetsByNodeType, - modelLoadingByNodeType, - modelErrorByNodeType, + getAssets, + isLoading, + getError, + hasMore, updateModelsForNodeType, updateModelsForTag } } + const emptyAssets: AssetItem[] = [] return { - modelAssetsByNodeType: shallowReactive(new Map()), - modelLoadingByNodeType: shallowReactive(new Map()), - modelErrorByNodeType: shallowReactive(new Map()), - updateModelsForNodeType: async () => [], - updateModelsForTag: async () => [] + getAssets: () => emptyAssets, + isLoading: () => false, + getError: () => undefined, + hasMore: () => false, + updateModelsForNodeType: async () => emptyAssets, + updateModelsForTag: async () => emptyAssets } } const { - modelAssetsByNodeType, - modelLoadingByNodeType, - modelErrorByNodeType, + getAssets, + isLoading: isModelLoading, + getError, + hasMore, updateModelsForNodeType, updateModelsForTag } = getModelState() @@ -422,10 +492,13 @@ export const useAssetsStore = defineStore('assets', () => { inputAssetsByFilename, getInputName, - // Model assets - modelAssetsByNodeType, - modelLoadingByNodeType, - modelErrorByNodeType, + // Model assets - accessors + getAssets, + isModelLoading, + getError, + hasMore, + + // Model assets - actions updateModelsForNodeType, updateModelsForTag } From b6e0ef76b13bf04a1985f5df1f77c740cae75847 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Sat, 17 Jan 2026 19:07:44 -0800 Subject: [PATCH 02/18] fix: add defensive checks for undefined assets in useAssetWidgetData --- src/platform/assets/services/assetService.test.ts | 4 ++-- .../vueNodes/widgets/composables/useAssetWidgetData.ts | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts index ead7902ffbf..91240a25e0f 100644 --- a/src/platform/assets/services/assetService.test.ts +++ b/src/platform/assets/services/assetService.test.ts @@ -231,9 +231,9 @@ describe('assetService', () => { ) expect(result).toEqual(testAssets) - // Verify API call includes correct category + // Verify API call includes correct category (comma is URL-encoded by URLSearchParams) expect(api.fetchApi).toHaveBeenCalledWith( - '/assets?include_tags=models,checkpoints&limit=500' + '/assets?include_tags=models%2Ccheckpoints&limit=500' ) }) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts index 6f2618b25df..25b6de1ceb2 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts @@ -48,7 +48,7 @@ export function useAssetWidgetData( }) const dropdownItems = computed(() => { - return assets.value.map((asset) => ({ + return (assets.value ?? []).map((asset) => ({ id: asset.id, name: (asset.user_metadata?.filename as string | undefined) ?? asset.name, @@ -65,7 +65,8 @@ export function useAssetWidgetData( return } - const hasData = assetsStore.getAssets(currentNodeType).length > 0 + const existingAssets = assetsStore.getAssets(currentNodeType) ?? [] + const hasData = existingAssets.length > 0 if (!hasData) { await assetsStore.updateModelsForNodeType(currentNodeType) From d8da949a9ad1adc23fefc1d0d23e5427fa32fa71 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:06:58 -0800 Subject: [PATCH 03/18] fix: improve assetsStore performance with array caching and error handling Amp-Thread-ID: https://ampcode.com/threads/T-019be1bc-860d-749d-be2b-6f25d0884206 Co-authored-by: Amp --- src/platform/assets/services/assetService.ts | 9 +++--- .../widgets/composables/useAssetWidgetData.ts | 4 +-- src/stores/assetsStore.ts | 30 ++++++++++++++++++- 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index c07b8c0f61e..be3830ca8a5 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -2,10 +2,6 @@ import { fromZodError } from 'zod-validation-error' import { st } from '@/i18n' -export interface PaginationOptions { - limit?: number - offset?: number -} import { assetItemSchema, assetResponseSchema, @@ -22,6 +18,11 @@ import type { import { api } from '@/scripts/api' import { useModelToNodeStore } from '@/stores/modelToNodeStore' +export interface PaginationOptions { + limit?: number + offset?: number +} + /** * Maps CivitAI validation error codes to localized error messages */ diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts index 25b6de1ceb2..6178df4f6f8 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts @@ -48,7 +48,7 @@ export function useAssetWidgetData( }) const dropdownItems = computed(() => { - return (assets.value ?? []).map((asset) => ({ + return assets.value.map((asset) => ({ id: asset.id, name: (asset.user_metadata?.filename as string | undefined) ?? asset.name, @@ -65,7 +65,7 @@ export function useAssetWidgetData( return } - const existingAssets = assetsStore.getAssets(currentNodeType) ?? [] + const existingAssets = assetsStore.getAssets(currentNodeType) const hasData = existingAssets.length > 0 if (!hasData) { diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 737e67c9a5f..e1f31132ee0 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -270,6 +270,11 @@ export const useAssetsStore = defineStore('assets', () => { if (isCloud) { const modelStateByKey = ref(new Map()) + const assetsArrayCache = new Map< + string, + { source: Map; array: AssetItem[] } + >() + function createInitialState(): ModelPaginationState { const state: ModelPaginationState = { assets: new Map(), @@ -293,10 +298,22 @@ export const useAssetsStore = defineStore('assets', () => { state.offset = 0 state.hasMore = true delete state.error + assetsArrayCache.delete(key) } function getAssets(key: string): AssetItem[] { - return Array.from(modelStateByKey.value.get(key)?.assets.values() ?? []) + const state = modelStateByKey.value.get(key) + const assetsMap = state?.assets + if (!assetsMap) return [] + + const cached = assetsArrayCache.get(key) + if (cached && cached.source === assetsMap) { + return cached.array + } + + const array = Array.from(assetsMap.values()) + assetsArrayCache.set(key, { source: assetsMap, array }) + return array } function isLoading(key: string): boolean { @@ -365,15 +382,26 @@ export const useAssetsStore = defineStore('assets', () => { offset: state.offset }) + let addedAny = false for (const asset of newAssets) { if (!state.assets.has(asset.id)) { state.assets.set(asset.id, asset) + addedAny = true } } + if (addedAny) { + assetsArrayCache.delete(key) + } state.offset += newAssets.length state.hasMore = newAssets.length === MODEL_BATCH_SIZE + + if (state.hasMore) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } } catch (err) { + state.error = err instanceof Error ? err : new Error(String(err)) + state.hasMore = false console.error(`Error loading batch for ${key}:`, err) break } From b09cc3373ba22fb853a8fab628f0a556d9f75efc Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:26:41 -0800 Subject: [PATCH 04/18] fix: invalidate assetsArrayCache before mutating state.assets Prevents race condition where getAssets could return stale cached array during loadRemainingBatches mutations. Amp-Thread-ID: https://ampcode.com/threads/T-019be1c8-c164-774d-bffb-ad0ba2d0dc45 Co-authored-by: Amp --- src/stores/assetsStore.test.ts | 108 +++++++++++++++++++++++++++++++-- src/stores/assetsStore.ts | 11 ++-- 2 files changed, 108 insertions(+), 11 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 576c40d7e8d..f290011c2dd 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -1,9 +1,10 @@ import { createPinia, setActivePinia } from 'pinia' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAssetsStore } from '@/stores/assetsStore' import { api } from '@/scripts/api' import type { JobListItem } from '@/platform/remote/comfyui/jobs/jobTypes' +import { assetService } from '@/platform/assets/services/assetService' // Mock the api module vi.mock('@/scripts/api', () => ({ @@ -20,13 +21,17 @@ vi.mock('@/scripts/api', () => ({ // Mock the asset service vi.mock('@/platform/assets/services/assetService', () => ({ assetService: { - getAssetsByTag: vi.fn() + getAssetsByTag: vi.fn(), + getAssetsForNodeType: vi.fn() } })) -// Mock distribution type +// Mock distribution type - hoisted so it can be changed per test +const mockIsCloud = vi.hoisted(() => ({ value: false })) vi.mock('@/platform/distribution/types', () => ({ - isCloud: false + get isCloud() { + return mockIsCloud.value + } })) // Mock TaskItemImpl @@ -453,3 +458,98 @@ describe('assetsStore - Refactored (Option A)', () => { }) }) }) + +describe('assetsStore - Model Assets Cache (Cloud)', () => { + beforeEach(() => { + mockIsCloud.value = true + vi.clearAllMocks() + }) + + afterEach(() => { + mockIsCloud.value = false + }) + + const createMockAsset = (id: string) => ({ + id, + name: `asset-${id}`, + size: 100, + created_at: new Date().toISOString(), + tags: ['models'], + preview_url: `http://test.com/${id}` + }) + + describe('getAssets cache invalidation', () => { + it('should invalidate cache before mutating assets during batch loading', async () => { + setActivePinia(createPinia()) + const store = useAssetsStore() + const nodeType = 'CheckpointLoaderSimple' + + const firstBatch = Array.from({ length: 500 }, (_, i) => + createMockAsset(`asset-${i}`) + ) + const secondBatch = Array.from({ length: 100 }, (_, i) => + createMockAsset(`asset-${500 + i}`) + ) + + let callCount = 0 + vi.mocked(assetService.getAssetsForNodeType).mockImplementation( + async () => { + callCount++ + return callCount === 1 ? firstBatch : secondBatch + } + ) + + await store.updateModelsForNodeType(nodeType) + + // Wait for background batch loading to complete + await vi.waitFor(() => { + expect( + vi.mocked(assetService.getAssetsForNodeType) + ).toHaveBeenCalledTimes(2) + }) + + const assets = store.getAssets(nodeType) + expect(assets).toHaveLength(600) + }) + + it('should not return stale cached array after background batch completes', async () => { + setActivePinia(createPinia()) + const store = useAssetsStore() + const nodeType = 'LoraLoader' + + // First batch must be exactly MODEL_BATCH_SIZE (500) to trigger hasMore + const firstBatch = Array.from({ length: 500 }, (_, i) => + createMockAsset(`first-${i}`) + ) + const secondBatch = [createMockAsset('new-asset')] + + let callCount = 0 + vi.mocked(assetService.getAssetsForNodeType).mockImplementation( + async () => { + callCount++ + return callCount === 1 ? firstBatch : secondBatch + } + ) + + await store.updateModelsForNodeType(nodeType) + + // Wait for background batch loading to complete + await vi.waitFor(() => { + expect( + vi.mocked(assetService.getAssetsForNodeType) + ).toHaveBeenCalledTimes(2) + }) + + // Cache the current array reference + const firstArrayRef = store.getAssets(nodeType) + expect(firstArrayRef).toHaveLength(501) + + // Call getAssets again - should return same cached array (same reference) + const secondArrayRef = store.getAssets(nodeType) + expect(secondArrayRef).toBe(firstArrayRef) + + // Verify the new asset is included (cache was properly invalidated before mutation) + expect(secondArrayRef.map((a) => a.id)).toContain('new-asset') + }) + }) +}) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index e1f31132ee0..323b1673610 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -382,16 +382,13 @@ export const useAssetsStore = defineStore('assets', () => { offset: state.offset }) - let addedAny = false - for (const asset of newAssets) { - if (!state.assets.has(asset.id)) { + const assetsToAdd = newAssets.filter((a) => !state.assets.has(a.id)) + if (assetsToAdd.length > 0) { + assetsArrayCache.delete(key) + for (const asset of assetsToAdd) { state.assets.set(asset.id, asset) - addedAny = true } } - if (addedAny) { - assetsArrayCache.delete(key) - } state.offset += newAssets.length state.hasMore = newAssets.length === MODEL_BATCH_SIZE From 20aeac4c8cb9bea01539d76907bcf2fd5683741d Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 10:39:40 -0800 Subject: [PATCH 05/18] refactor: use shallowReactive for ModelPaginationState - Prevent Vue from deeply proxying the assets Map - Add test to verify reactivity triggers on isModelLoading change Amp-Thread-ID: https://ampcode.com/threads/T-019be1d0-f08e-7318-8d63-a6486cc52efd Co-authored-by: Amp --- src/stores/assetsStore.test.ts | 23 +++++++++++++++++++++++ src/stores/assetsStore.ts | 4 ++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index f290011c2dd..310f21a56e4 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -1,5 +1,6 @@ import { createPinia, setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick, watch } from 'vue' import { useAssetsStore } from '@/stores/assetsStore' import { api } from '@/scripts/api' @@ -552,4 +553,26 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { expect(secondArrayRef.map((a) => a.id)).toContain('new-asset') }) }) + + describe('shallowReactive state reactivity', () => { + it('should trigger reactivity on isModelLoading change', async () => { + setActivePinia(createPinia()) + const store = useAssetsStore() + const nodeType = 'CheckpointLoaderSimple' + + const loadingStates: boolean[] = [] + watch( + () => store.isModelLoading(nodeType), + (val) => loadingStates.push(val), + { immediate: true } + ) + + vi.mocked(assetService.getAssetsForNodeType).mockResolvedValue([]) + await store.updateModelsForNodeType(nodeType) + await nextTick() + + expect(loadingStates).toContain(true) + expect(loadingStates).toContain(false) + }) + }) }) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 323b1673610..c1d8f194a04 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -1,6 +1,6 @@ import { useAsyncState, whenever } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, reactive, ref, shallowReactive } from 'vue' +import { computed, ref, shallowReactive } from 'vue' import { mapInputFileToAssetItem, mapTaskOutputToAssetItem @@ -282,7 +282,7 @@ export const useAssetsStore = defineStore('assets', () => { hasMore: true, isLoading: false } - return reactive(state) + return shallowReactive(state) } function getOrCreateState(key: string): ModelPaginationState { From a61121eae1c17587593f4c6c75bc778d02f3e35e Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:04:58 -0800 Subject: [PATCH 06/18] fix: prevent concurrent model requests from corrupting pagination state Use object identity to detect stale requests instead of request IDs Add isStale() helper and test for concurrent request handling Amp-Thread-ID: https://ampcode.com/threads/T-019be1de-5bb4-7449-b0f6-8ac70955b3d9 Co-authored-by: Amp --- src/stores/assetsStore.test.ts | 43 ++++++++++++++++++++++++++++++ src/stores/assetsStore.ts | 48 ++++++++++++++++------------------ 2 files changed, 66 insertions(+), 25 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 310f21a56e4..4f0557abda7 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -554,6 +554,49 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { }) }) + describe('concurrent request handling', () => { + it('should discard stale request when newer request starts', async () => { + setActivePinia(createPinia()) + const store = useAssetsStore() + const nodeType = 'CheckpointLoaderSimple' + const firstBatch = Array.from({ length: 5 }, (_, i) => + createMockAsset(`first-${i}`) + ) + const secondBatch = Array.from({ length: 10 }, (_, i) => + createMockAsset(`second-${i}`) + ) + + let resolveFirst: (value: ReturnType[]) => void + const firstPromise = new Promise[]>( + (resolve) => { + resolveFirst = resolve + } + ) + let callCount = 0 + vi.mocked(assetService.getAssetsForNodeType).mockImplementation( + async () => { + callCount++ + return callCount === 1 ? firstPromise : secondBatch + } + ) + + const firstRequest = store.updateModelsForNodeType(nodeType) + const secondRequest = store.updateModelsForNodeType(nodeType) + resolveFirst!(firstBatch) + const [firstResult, secondResult] = await Promise.all([ + firstRequest, + secondRequest + ]) + + expect(firstResult).toEqual([]) + expect(secondResult).toHaveLength(10) + expect(store.getAssets(nodeType)).toHaveLength(10) + expect( + store.getAssets(nodeType).every((a) => a.id.startsWith('second-')) + ).toBe(true) + }) + }) + describe('shallowReactive state reactivity', () => { it('should trigger reactivity on isModelLoading change', async () => { setActivePinia(createPinia()) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index c1d8f194a04..c5512bec64c 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -275,30 +275,23 @@ export const useAssetsStore = defineStore('assets', () => { { source: Map; array: AssetItem[] } >() - function createInitialState(): ModelPaginationState { - const state: ModelPaginationState = { + function createState(): ModelPaginationState { + return shallowReactive({ assets: new Map(), offset: 0, hasMore: true, isLoading: false - } - return shallowReactive(state) + }) } - function getOrCreateState(key: string): ModelPaginationState { - if (!modelStateByKey.value.has(key)) { - modelStateByKey.value.set(key, createInitialState()) - } - return modelStateByKey.value.get(key)! + function startNewRequest(key: string): ModelPaginationState { + const state = createState() + modelStateByKey.value.set(key, state) + return state } - function resetPaginationForKey(key: string) { - const state = getOrCreateState(key) - state.assets = new Map() - state.offset = 0 - state.hasMore = true - delete state.error - assetsArrayCache.delete(key) + function isStale(key: string, state: ModelPaginationState): boolean { + return modelStateByKey.value.get(key) !== state } function getAssets(key: string): AssetItem[] { @@ -336,9 +329,8 @@ export const useAssetsStore = defineStore('assets', () => { key: string, fetcher: (options: PaginationOptions) => Promise ): Promise { - const state = getOrCreateState(key) - - resetPaginationForKey(key) + const state = startNewRequest(key) + assetsArrayCache.delete(key) state.isLoading = true try { @@ -347,21 +339,26 @@ export const useAssetsStore = defineStore('assets', () => { offset: 0 }) + if (isStale(key, state)) return [] + state.assets = new Map(assets.map((a) => [a.id, a])) state.offset = assets.length state.hasMore = assets.length === MODEL_BATCH_SIZE if (state.hasMore) { - void loadRemainingBatches(key, fetcher) + void loadRemainingBatches(key, fetcher, state) } return assets } catch (err) { + if (isStale(key, state)) return [] state.error = err instanceof Error ? err : new Error(String(err)) console.error(`Error fetching model assets for ${key}:`, err) return [] } finally { - state.isLoading = false + if (!isStale(key, state)) { + state.isLoading = false + } } } @@ -370,11 +367,9 @@ export const useAssetsStore = defineStore('assets', () => { */ async function loadRemainingBatches( key: string, - fetcher: (options: PaginationOptions) => Promise + fetcher: (options: PaginationOptions) => Promise, + state: ModelPaginationState ): Promise { - const state = modelStateByKey.value.get(key) - if (!state) return - while (state.hasMore) { try { const newAssets = await fetcher({ @@ -382,6 +377,8 @@ export const useAssetsStore = defineStore('assets', () => { offset: state.offset }) + if (isStale(key, state)) return + const assetsToAdd = newAssets.filter((a) => !state.assets.has(a.id)) if (assetsToAdd.length > 0) { assetsArrayCache.delete(key) @@ -397,6 +394,7 @@ export const useAssetsStore = defineStore('assets', () => { await new Promise((resolve) => setTimeout(resolve, 50)) } } catch (err) { + if (isStale(key, state)) return state.error = err instanceof Error ? err : new Error(String(err)) state.hasMore = false console.error(`Error loading batch for ${key}:`, err) From 58ef1a9f9058cd2ef2141994d87909ac4a2d6195 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:12:39 -0800 Subject: [PATCH 07/18] test: use createTestingPinia in assetsStore tests - Move Pinia setup into beforeEach lifecycle hooks - Replace createPinia with createTestingPinia - Remove inline setActivePinia calls from individual tests --- src/stores/assetsStore.test.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 4f0557abda7..11197a4b215 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -1,4 +1,5 @@ -import { createPinia, setActivePinia } from 'pinia' +import { createTestingPinia } from '@pinia/testing' +import { setActivePinia } from 'pinia' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { nextTick, watch } from 'vue' @@ -121,7 +122,7 @@ describe('assetsStore - Refactored (Option A)', () => { }) beforeEach(() => { - setActivePinia(createPinia()) + setActivePinia(createTestingPinia({ stubActions: false })) store = useAssetsStore() vi.clearAllMocks() }) @@ -462,6 +463,7 @@ describe('assetsStore - Refactored (Option A)', () => { describe('assetsStore - Model Assets Cache (Cloud)', () => { beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) mockIsCloud.value = true vi.clearAllMocks() }) @@ -481,7 +483,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { describe('getAssets cache invalidation', () => { it('should invalidate cache before mutating assets during batch loading', async () => { - setActivePinia(createPinia()) const store = useAssetsStore() const nodeType = 'CheckpointLoaderSimple' @@ -514,7 +515,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { }) it('should not return stale cached array after background batch completes', async () => { - setActivePinia(createPinia()) const store = useAssetsStore() const nodeType = 'LoraLoader' @@ -556,7 +556,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { describe('concurrent request handling', () => { it('should discard stale request when newer request starts', async () => { - setActivePinia(createPinia()) const store = useAssetsStore() const nodeType = 'CheckpointLoaderSimple' const firstBatch = Array.from({ length: 5 }, (_, i) => @@ -599,7 +598,6 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { describe('shallowReactive state reactivity', () => { it('should trigger reactivity on isModelLoading change', async () => { - setActivePinia(createPinia()) const store = useAssetsStore() const nodeType = 'CheckpointLoaderSimple' From c3f0b46b18d7a2b11804484c1a88924f7baa6df7 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:14:52 -0800 Subject: [PATCH 08/18] test: improve assetsStore cache test assertions - Replace reference equality check with behavioral assertions - Add dedicated test for getAssets caching semantics --- src/stores/assetsStore.test.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 11197a4b215..94b99fe46a0 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -541,16 +541,19 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { ).toHaveBeenCalledTimes(2) }) - // Cache the current array reference - const firstArrayRef = store.getAssets(nodeType) - expect(firstArrayRef).toHaveLength(501) + const assets = store.getAssets(nodeType) + expect(assets).toHaveLength(501) + expect(assets.map((a) => a.id)).toContain('new-asset') + }) + + it('should return cached array on subsequent getAssets calls', () => { + const store = useAssetsStore() + const nodeType = 'TestLoader' - // Call getAssets again - should return same cached array (same reference) - const secondArrayRef = store.getAssets(nodeType) - expect(secondArrayRef).toBe(firstArrayRef) + const firstCall = store.getAssets(nodeType) + const secondCall = store.getAssets(nodeType) - // Verify the new asset is included (cache was properly invalidated before mutation) - expect(secondArrayRef.map((a) => a.id)).toContain('new-asset') + expect(secondCall).toBe(firstCall) }) }) From 5dd232318ed8de14f03fae67e297bf5b16c04639 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:31:26 -0800 Subject: [PATCH 09/18] Just use a full reactive. --- src/stores/assetsStore.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index c5512bec64c..96558995d85 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -1,6 +1,6 @@ import { useAsyncState, whenever } from '@vueuse/core' import { defineStore } from 'pinia' -import { computed, ref, shallowReactive } from 'vue' +import { computed, reactive, ref, shallowReactive } from 'vue' import { mapInputFileToAssetItem, mapTaskOutputToAssetItem @@ -276,7 +276,7 @@ export const useAssetsStore = defineStore('assets', () => { >() function createState(): ModelPaginationState { - return shallowReactive({ + return reactive({ assets: new Map(), offset: 0, hasMore: true, From 01c595373ed91cb22a3b81832afefc0f0f5a5d78 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 11:50:18 -0800 Subject: [PATCH 10/18] fix: handle undefined getAssets return in useAssetWidgetData - Add ?? [] fallback for getAssets in computed and watch handler - Use shared EMPTY_ASSETS constant for stable cache reference Amp-Thread-ID: https://ampcode.com/threads/T-019be204-2bc0-702f-904c-2f22f51b2280 Co-authored-by: Amp --- .../vueNodes/widgets/composables/useAssetWidgetData.ts | 4 ++-- src/stores/assetsStore.ts | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts index 6178df4f6f8..06179edb743 100644 --- a/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts +++ b/src/renderer/extensions/vueNodes/widgets/composables/useAssetWidgetData.ts @@ -34,7 +34,7 @@ export function useAssetWidgetData( const assets = computed(() => { const resolvedType = toValue(nodeType) - return resolvedType ? assetsStore.getAssets(resolvedType) : [] + return resolvedType ? (assetsStore.getAssets(resolvedType) ?? []) : [] }) const isLoading = computed(() => { @@ -65,7 +65,7 @@ export function useAssetWidgetData( return } - const existingAssets = assetsStore.getAssets(currentNodeType) + const existingAssets = assetsStore.getAssets(currentNodeType) ?? [] const hasData = existingAssets.length > 0 if (!hasData) { diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 96558995d85..01239230800 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -294,10 +294,12 @@ export const useAssetsStore = defineStore('assets', () => { return modelStateByKey.value.get(key) !== state } + const EMPTY_ASSETS: AssetItem[] = [] + function getAssets(key: string): AssetItem[] { const state = modelStateByKey.value.get(key) const assetsMap = state?.assets - if (!assetsMap) return [] + if (!assetsMap) return EMPTY_ASSETS const cached = assetsArrayCache.get(key) if (cached && cached.source === assetsMap) { From bdac181794603ad3eebb1a549cc6b962acbbbdad Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 12:50:56 -0800 Subject: [PATCH 11/18] refactor: consolidate handleAssetRequest query params into AssetRequestOptions interface Amp-Thread-ID: https://ampcode.com/threads/T-019be24c-6847-734f-9e61-a49ea791d836 Co-authored-by: Amp --- src/platform/assets/services/assetService.ts | 52 +++++++++++--------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index be3830ca8a5..6c0b27c5390 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -23,6 +23,11 @@ export interface PaginationOptions { offset?: number } +export interface AssetRequestOptions extends PaginationOptions { + includeTags: string[] + includePublic?: boolean +} + /** * Maps CivitAI validation error codes to localized error messages */ @@ -83,9 +88,27 @@ function createAssetService() { * Handles API response with consistent error handling and Zod validation */ async function handleAssetRequest( - url: string, + options: AssetRequestOptions, context: string ): Promise { + const { + includeTags, + limit = DEFAULT_LIMIT, + offset, + includePublic + } = options + const queryParams = new URLSearchParams({ + include_tags: includeTags.join(','), + limit: limit.toString() + }) + if (offset !== undefined && offset > 0) { + queryParams.set('offset', offset.toString()) + } + if (includePublic !== undefined) { + queryParams.set('include_public', includePublic ? 'true' : 'false') + } + + const url = `${ASSETS_ENDPOINT}?${queryParams.toString()}` const res = await api.fetchApi(url) if (!res.ok) { throw new Error( @@ -107,7 +130,7 @@ function createAssetService() { */ async function getAssetModelFolders(): Promise { const data = await handleAssetRequest( - `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG}&limit=${DEFAULT_LIMIT}`, + { includeTags: [MODELS_TAG] }, 'model folders' ) @@ -136,7 +159,7 @@ function createAssetService() { */ async function getAssetModels(folder: string): Promise { const data = await handleAssetRequest( - `${ASSETS_ENDPOINT}?include_tags=${MODELS_TAG},${folder}&limit=${DEFAULT_LIMIT}`, + { includeTags: [MODELS_TAG, folder] }, `models for ${folder}` ) @@ -196,18 +219,9 @@ function createAssetService() { return [] } - const queryParams = new URLSearchParams({ - include_tags: `${MODELS_TAG},${category}`, - limit: limit.toString() - }) - - if (offset > 0) { - queryParams.set('offset', offset.toString()) - } - // Fetch assets for this category using same API pattern as getAssetModels const data = await handleAssetRequest( - `${ASSETS_ENDPOINT}?${queryParams.toString()}`, + { includeTags: [MODELS_TAG, category], limit, offset }, `assets for ${nodeType}` ) @@ -265,18 +279,8 @@ function createAssetService() { includePublic: boolean = true, { limit = DEFAULT_LIMIT, offset = 0 }: PaginationOptions = {} ): Promise { - const queryParams = new URLSearchParams({ - include_tags: tag, - limit: limit.toString(), - include_public: includePublic ? 'true' : 'false' - }) - - if (offset > 0) { - queryParams.set('offset', offset.toString()) - } - const data = await handleAssetRequest( - `${ASSETS_ENDPOINT}?${queryParams.toString()}`, + { includeTags: [tag], limit, offset, includePublic }, `assets for tag ${tag}` ) From 770fa684116ca4a9d5e398ec106660adbb297865 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:21:49 -0800 Subject: [PATCH 12/18] refactor: consolidate batch loading into single loop - Merge updateModelsForKey and loadRemainingBatches into one loadBatches function - Change return type to void since callers use getAssets() instead - Address PR review feedback Amp-Thread-ID: https://ampcode.com/threads/T-019be255-b4d6-73ed-a9dd-62b92b0af1c4 Co-authored-by: Amp --- .../assets/components/AssetBrowserModal.vue | 10 +- src/stores/assetsStore.test.ts | 7 +- src/stores/assetsStore.ts | 125 ++++++++---------- 3 files changed, 58 insertions(+), 84 deletions(-) diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index 19159274462..c30dd02b630 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -121,14 +121,12 @@ const isLoading = computed( () => isStoreLoading.value && fetchedAssets.value.length === 0 ) -async function refreshAssets(): Promise { +async function refreshAssets(): Promise { if (props.nodeType) { - return await assetStore.updateModelsForNodeType(props.nodeType) + await assetStore.updateModelsForNodeType(props.nodeType) + } else if (props.assetType) { + await assetStore.updateModelsForTag(props.assetType) } - if (props.assetType) { - return await assetStore.updateModelsForTag(props.assetType) - } - return [] } // Trigger background refresh on mount diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 94b99fe46a0..85f0cb668bd 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -585,13 +585,8 @@ describe('assetsStore - Model Assets Cache (Cloud)', () => { const firstRequest = store.updateModelsForNodeType(nodeType) const secondRequest = store.updateModelsForNodeType(nodeType) resolveFirst!(firstBatch) - const [firstResult, secondResult] = await Promise.all([ - firstRequest, - secondRequest - ]) + await Promise.all([firstRequest, secondRequest]) - expect(firstResult).toEqual([]) - expect(secondResult).toHaveLength(10) expect(store.getAssets(nodeType)).toHaveLength(10) expect( store.getAssets(nodeType).every((a) => a.id.startsWith('second-')) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 01239230800..94c02bffa61 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -330,90 +330,72 @@ export const useAssetsStore = defineStore('assets', () => { async function updateModelsForKey( key: string, fetcher: (options: PaginationOptions) => Promise - ): Promise { + ): Promise { const state = startNewRequest(key) assetsArrayCache.delete(key) state.isLoading = true - try { - const assets = await fetcher({ - limit: MODEL_BATCH_SIZE, - offset: 0 - }) - - if (isStale(key, state)) return [] - - state.assets = new Map(assets.map((a) => [a.id, a])) - state.offset = assets.length - state.hasMore = assets.length === MODEL_BATCH_SIZE - - if (state.hasMore) { - void loadRemainingBatches(key, fetcher, state) - } + async function loadBatches(): Promise { + while (state.hasMore) { + try { + const newAssets = await fetcher({ + limit: MODEL_BATCH_SIZE, + offset: state.offset + }) + + if (isStale(key, state)) return + + const isFirstBatch = state.offset === 0 + if (isFirstBatch) { + state.assets = new Map(newAssets.map((a) => [a.id, a])) + } else { + const assetsToAdd = newAssets.filter( + (a) => !state.assets.has(a.id) + ) + if (assetsToAdd.length > 0) { + assetsArrayCache.delete(key) + for (const asset of assetsToAdd) { + state.assets.set(asset.id, asset) + } + } + } - return assets - } catch (err) { - if (isStale(key, state)) return [] - state.error = err instanceof Error ? err : new Error(String(err)) - console.error(`Error fetching model assets for ${key}:`, err) - return [] - } finally { - if (!isStale(key, state)) { - state.isLoading = false - } - } - } + state.offset += newAssets.length + state.hasMore = newAssets.length === MODEL_BATCH_SIZE - /** - * Progressively load remaining batches until complete - */ - async function loadRemainingBatches( - key: string, - fetcher: (options: PaginationOptions) => Promise, - state: ModelPaginationState - ): Promise { - while (state.hasMore) { - try { - const newAssets = await fetcher({ - limit: MODEL_BATCH_SIZE, - offset: state.offset - }) - - if (isStale(key, state)) return - - const assetsToAdd = newAssets.filter((a) => !state.assets.has(a.id)) - if (assetsToAdd.length > 0) { - assetsArrayCache.delete(key) - for (const asset of assetsToAdd) { - state.assets.set(asset.id, asset) + if (isFirstBatch) { + state.isLoading = false + if (state.hasMore) { + void loadBatches() + } + return } - } - state.offset += newAssets.length - state.hasMore = newAssets.length === MODEL_BATCH_SIZE - - if (state.hasMore) { - await new Promise((resolve) => setTimeout(resolve, 50)) + if (state.hasMore) { + await new Promise((resolve) => setTimeout(resolve, 50)) + } + } catch (err) { + if (isStale(key, state)) return + state.error = err instanceof Error ? err : new Error(String(err)) + state.hasMore = false + console.error(`Error loading batch for ${key}:`, err) + if (state.offset === 0) { + state.isLoading = false + } + return } - } catch (err) { - if (isStale(key, state)) return - state.error = err instanceof Error ? err : new Error(String(err)) - state.hasMore = false - console.error(`Error loading batch for ${key}:`, err) - break } } + + await loadBatches() } /** * Fetch and cache model assets for a specific node type * @param nodeType The node type to fetch assets for (e.g., 'CheckpointLoaderSimple') - * @returns Promise resolving to the fetched assets */ - async function updateModelsForNodeType( - nodeType: string - ): Promise { - return updateModelsForKey(nodeType, (opts) => + async function updateModelsForNodeType(nodeType: string): Promise { + await updateModelsForKey(nodeType, (opts) => assetService.getAssetsForNodeType(nodeType, opts) ) } @@ -421,11 +403,10 @@ export const useAssetsStore = defineStore('assets', () => { /** * Fetch and cache model assets for a specific tag * @param tag The tag to fetch assets for (e.g., 'models') - * @returns Promise resolving to the fetched assets */ - async function updateModelsForTag(tag: string): Promise { + async function updateModelsForTag(tag: string): Promise { const key = `tag:${tag}` - return updateModelsForKey(key, (opts) => + await updateModelsForKey(key, (opts) => assetService.getAssetsByTag(tag, true, opts) ) } @@ -446,8 +427,8 @@ export const useAssetsStore = defineStore('assets', () => { isLoading: () => false, getError: () => undefined, hasMore: () => false, - updateModelsForNodeType: async () => emptyAssets, - updateModelsForTag: async () => emptyAssets + updateModelsForNodeType: async () => {}, + updateModelsForTag: async () => {} } } From cf237cf59939643ef9572f4e19a30a3b4d9e8fdf Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:24:43 -0800 Subject: [PATCH 13/18] fix: delay cache invalidation until new data available - Remove export from AssetRequestOptions (only used internally) - Move assetsArrayCache.delete() inside loadBatches after first batch fetch Amp-Thread-ID: https://ampcode.com/threads/T-019be270-a632-728b-843d-270d1971593e Co-authored-by: Amp --- src/platform/assets/services/assetService.ts | 2 +- src/stores/assetsStore.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/platform/assets/services/assetService.ts b/src/platform/assets/services/assetService.ts index 6c0b27c5390..fcf0367e84e 100644 --- a/src/platform/assets/services/assetService.ts +++ b/src/platform/assets/services/assetService.ts @@ -23,7 +23,7 @@ export interface PaginationOptions { offset?: number } -export interface AssetRequestOptions extends PaginationOptions { +interface AssetRequestOptions extends PaginationOptions { includeTags: string[] includePublic?: boolean } diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 94c02bffa61..23c6120755a 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -332,7 +332,6 @@ export const useAssetsStore = defineStore('assets', () => { fetcher: (options: PaginationOptions) => Promise ): Promise { const state = startNewRequest(key) - assetsArrayCache.delete(key) state.isLoading = true async function loadBatches(): Promise { @@ -347,6 +346,7 @@ export const useAssetsStore = defineStore('assets', () => { const isFirstBatch = state.offset === 0 if (isFirstBatch) { + assetsArrayCache.delete(key) state.assets = new Map(newAssets.map((a) => [a.id, a])) } else { const assetsToAdd = newAssets.filter( From 3a4e716472e444d8041fe67d23d918c155497aea Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:27:41 -0800 Subject: [PATCH 14/18] fix: keep existing assets visible until new data fetched - Track pending requests separately from committed state - Only replace modelStateByKey after first batch succeeds - Update isStale() to check both committed and pending state Amp-Thread-ID: https://ampcode.com/threads/T-019be270-a632-728b-843d-270d1971593e Co-authored-by: Amp --- src/stores/assetsStore.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 23c6120755a..b5698789b5c 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -275,6 +275,8 @@ export const useAssetsStore = defineStore('assets', () => { { source: Map; array: AssetItem[] } >() + const pendingRequestByKey = new Map() + function createState(): ModelPaginationState { return reactive({ assets: new Map(), @@ -284,14 +286,10 @@ export const useAssetsStore = defineStore('assets', () => { }) } - function startNewRequest(key: string): ModelPaginationState { - const state = createState() - modelStateByKey.value.set(key, state) - return state - } - function isStale(key: string, state: ModelPaginationState): boolean { - return modelStateByKey.value.get(key) !== state + const committed = modelStateByKey.value.get(key) + const pending = pendingRequestByKey.get(key) + return committed !== state && pending !== state } const EMPTY_ASSETS: AssetItem[] = [] @@ -326,13 +324,15 @@ export const useAssetsStore = defineStore('assets', () => { /** * Internal helper to fetch and cache assets with a given key and fetcher. * Loads first batch immediately, then progressively loads remaining batches. + * Keeps existing data visible until new data is successfully fetched. */ async function updateModelsForKey( key: string, fetcher: (options: PaginationOptions) => Promise ): Promise { - const state = startNewRequest(key) + const state = createState() state.isLoading = true + pendingRequestByKey.set(key, state) async function loadBatches(): Promise { while (state.hasMore) { @@ -347,6 +347,8 @@ export const useAssetsStore = defineStore('assets', () => { const isFirstBatch = state.offset === 0 if (isFirstBatch) { assetsArrayCache.delete(key) + pendingRequestByKey.delete(key) + modelStateByKey.value.set(key, state) state.assets = new Map(newAssets.map((a) => [a.id, a])) } else { const assetsToAdd = newAssets.filter( From 48493e239bcdad135ffdb211706d0c4c147eb2ba Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:44:05 -0800 Subject: [PATCH 15/18] fix: commit state immediately when no cached data exists - If no existing data, commit new state right away for loading indicator - If existing data, keep it visible until first batch succeeds Amp-Thread-ID: https://ampcode.com/threads/T-019be270-a632-728b-843d-270d1971593e Co-authored-by: Amp --- src/stores/assetsStore.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index b5698789b5c..746227b78cd 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -332,7 +332,13 @@ export const useAssetsStore = defineStore('assets', () => { ): Promise { const state = createState() state.isLoading = true - pendingRequestByKey.set(key, state) + + const hasExistingData = modelStateByKey.value.has(key) + if (hasExistingData) { + pendingRequestByKey.set(key, state) + } else { + modelStateByKey.value.set(key, state) + } async function loadBatches(): Promise { while (state.hasMore) { @@ -347,8 +353,10 @@ export const useAssetsStore = defineStore('assets', () => { const isFirstBatch = state.offset === 0 if (isFirstBatch) { assetsArrayCache.delete(key) - pendingRequestByKey.delete(key) - modelStateByKey.value.set(key, state) + if (hasExistingData) { + pendingRequestByKey.delete(key) + modelStateByKey.value.set(key, state) + } state.assets = new Map(newAssets.map((a) => [a.id, a])) } else { const assetsToAdd = newAssets.filter( From cda777c43327f32761a68bfa4a4bb6ccadea41cf Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:49:24 -0800 Subject: [PATCH 16/18] fix: remove mixed loop/recursion in loadBatches The while loop handles all batch iterations; recursive call was redundant. Amp-Thread-ID: https://ampcode.com/threads/T-019be286-74b8-72c3-b177-07cfb7e14476 Co-authored-by: Amp --- src/stores/assetsStore.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 746227b78cd..edd4b27714a 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -375,10 +375,6 @@ export const useAssetsStore = defineStore('assets', () => { if (isFirstBatch) { state.isLoading = false - if (state.hasMore) { - void loadBatches() - } - return } if (state.hasMore) { From da869faf32374ac3b284559287372e4c67783b98 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:53:42 -0800 Subject: [PATCH 17/18] fix: update assetService test assertions for URL encoding and param order Amp-Thread-ID: https://ampcode.com/threads/T-019be289-70f2-74c0-aa43-6a13d5ff7523 Co-authored-by: Amp --- src/platform/assets/services/assetService.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/platform/assets/services/assetService.test.ts b/src/platform/assets/services/assetService.test.ts index 91240a25e0f..e126fd66c56 100644 --- a/src/platform/assets/services/assetService.test.ts +++ b/src/platform/assets/services/assetService.test.ts @@ -160,7 +160,7 @@ describe('assetService', () => { const result = await assetService.getAssetModels('checkpoints') expect(api.fetchApi).toHaveBeenCalledWith( - '/assets?include_tags=models,checkpoints&limit=500' + '/assets?include_tags=models%2Ccheckpoints&limit=500' ) expect(result).toEqual([ expect.objectContaining({ name: 'valid.safetensors', pathIndex: 0 }) @@ -400,7 +400,7 @@ describe('assetService', () => { }) expect(api.fetchApi).toHaveBeenCalledWith( - '/assets?include_tags=models&limit=500&include_public=true&offset=50' + '/assets?include_tags=models&limit=500&offset=50&include_public=true' ) expect(result).toEqual(testAssets) }) @@ -415,7 +415,7 @@ describe('assetService', () => { }) expect(api.fetchApi).toHaveBeenCalledWith( - '/assets?include_tags=input&limit=100&include_public=false&offset=25' + '/assets?include_tags=input&limit=100&offset=25&include_public=false' ) expect(result).toEqual(testAssets) }) From c28127861c5ef3e7734d8e3c2cbfff2df0788055 Mon Sep 17 00:00:00 2001 From: Alexander Brown <448862+DrJKL@users.noreply.github.com> Date: Wed, 21 Jan 2026 14:03:49 -0800 Subject: [PATCH 18/18] fix: clean up pendingRequestByKey on first-batch failure Prevents memory leak by deleting orphaned request from pendingRequestByKey when first batch fails to load. Amp-Thread-ID: https://ampcode.com/threads/T-019be291-311a-7339-b764-d7ded3487894 Co-authored-by: Amp --- src/stores/assetsStore.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index edd4b27714a..c5c0f423179 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -387,6 +387,8 @@ export const useAssetsStore = defineStore('assets', () => { console.error(`Error loading batch for ${key}:`, err) if (state.offset === 0) { state.isLoading = false + pendingRequestByKey.delete(key) + // TODO: Add toast indicator for first-batch load failures } return }