From f1b874eeedac49c415dac5dc0c4b21f1a1b478a5 Mon Sep 17 00:00:00 2001 From: Comfy Org PR Bot Date: Thu, 8 Jan 2026 09:42:29 +0900 Subject: [PATCH] [backport cloud/1.36] feat: Stale-while-revalidate pattern for AssetBrowserModal (#7889) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backport of #7880 to `cloud/1.36` Automatically created by backport workflow. ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-7889-backport-cloud-1-36-feat-Stale-while-revalidate-pattern-for-AssetBrowserModal-2e26d73d365081fb854bfe4189a94bef) by [Unito](https://www.unito.io) Co-authored-by: Alexander Brown Co-authored-by: Amp Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../assets/components/AssetBrowserModal.vue | 82 ++++++------- .../assets/composables/useModelUpload.ts | 6 +- src/stores/assetsStore.ts | 108 +++++++++++------- .../components/AssetBrowserModal.test.ts | 91 ++++++++------- 4 files changed, 158 insertions(+), 129 deletions(-) diff --git a/src/platform/assets/components/AssetBrowserModal.vue b/src/platform/assets/components/AssetBrowserModal.vue index cd89591aa4..e0c47361d1 100644 --- a/src/platform/assets/components/AssetBrowserModal.vue +++ b/src/platform/assets/components/AssetBrowserModal.vue @@ -63,12 +63,8 @@ diff --git a/src/platform/assets/composables/useModelUpload.ts b/src/platform/assets/composables/useModelUpload.ts index 86372473d4..a8a1459160 100644 --- a/src/platform/assets/composables/useModelUpload.ts +++ b/src/platform/assets/composables/useModelUpload.ts @@ -3,13 +3,11 @@ import UploadModelDialog from '@/platform/assets/components/UploadModelDialog.vu import UploadModelDialogHeader from '@/platform/assets/components/UploadModelDialogHeader.vue' import UploadModelUpgradeModal from '@/platform/assets/components/UploadModelUpgradeModal.vue' import UploadModelUpgradeModalHeader from '@/platform/assets/components/UploadModelUpgradeModalHeader.vue' -import type { AssetItem } from '@/platform/assets/schemas/assetSchema' import { useDialogStore } from '@/stores/dialogStore' -import type { UseAsyncStateReturn } from '@vueuse/core' import { computed } from 'vue' export function useModelUpload( - execute?: UseAsyncStateReturn['execute'] + onUploadSuccess?: () => Promise | void ) { const dialogStore = useDialogStore() const { flags } = useFeatureFlags() @@ -37,7 +35,7 @@ export function useModelUpload( component: UploadModelDialog, props: { onUploadSuccess: async () => { - await execute?.() + await onUploadSuccess?.() } }, dialogComponentProps: { diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index b75b454009..5fe64e9110 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -1,4 +1,5 @@ import { useAsyncState } from '@vueuse/core' +import { isEqual } from 'es-toolkit' import { defineStore } from 'pinia' import { computed, shallowReactive, ref, watch } from 'vue' import { @@ -279,59 +280,81 @@ export const useAssetsStore = defineStore('assets', () => { new Map>>() ) + /** + * Internal helper to fetch and cache assets with a given key and fetcher + */ + async function updateModelsForKey( + key: string, + fetcher: () => 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)! + + modelLoadingByNodeType.set(key, true) + modelErrorByNodeType.set(key, null) + + try { + await state.execute() + } finally { + modelLoadingByNodeType.set(key, state.isLoading.value) + } + + const assets = state.state.value + const existingAssets = modelAssetsByNodeType.get(key) + + if (!isEqual(existingAssets, assets)) { + modelAssetsByNodeType.set(key, assets) + } + + modelErrorByNodeType.set( + key, + state.error.value instanceof Error ? state.error.value : null + ) + + return assets + } + /** * Fetch and cache model assets for a specific node type - * Uses VueUse's useAsyncState for automatic loading/error tracking * @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 { - if (!stateByNodeType.has(nodeType)) { - stateByNodeType.set( - nodeType, - useAsyncState( - () => assetService.getAssetsForNodeType(nodeType), - [], - { - immediate: false, - resetOnExecute: false, - onError: (err) => { - console.error( - `Error fetching model assets for ${nodeType}:`, - err - ) - } - } - ) - ) - } + return updateModelsForKey(nodeType, () => + assetService.getAssetsForNodeType(nodeType) + ) + } - const state = stateByNodeType.get(nodeType)! - - modelLoadingByNodeType.set(nodeType, true) - modelErrorByNodeType.set(nodeType, null) - - try { - await state.execute() - const assets = state.state.value - modelAssetsByNodeType.set(nodeType, assets) - modelErrorByNodeType.set( - nodeType, - state.error.value instanceof Error ? state.error.value : null - ) - return assets - } finally { - modelLoadingByNodeType.set(nodeType, state.isLoading.value) - } + /** + * 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 { + const key = `tag:${tag}` + return updateModelsForKey(key, () => assetService.getAssetsByTag(tag)) } return { modelAssetsByNodeType, modelLoadingByNodeType, modelErrorByNodeType, - updateModelsForNodeType + updateModelsForNodeType, + updateModelsForTag } } @@ -339,7 +362,8 @@ export const useAssetsStore = defineStore('assets', () => { modelAssetsByNodeType: shallowReactive(new Map()), modelLoadingByNodeType: shallowReactive(new Map()), modelErrorByNodeType: shallowReactive(new Map()), - updateModelsForNodeType: async () => [] + updateModelsForNodeType: async () => [], + updateModelsForTag: async () => [] } } @@ -347,7 +371,8 @@ export const useAssetsStore = defineStore('assets', () => { modelAssetsByNodeType, modelLoadingByNodeType, modelErrorByNodeType, - updateModelsForNodeType + updateModelsForNodeType, + updateModelsForTag } = getModelState() // Watch for completed downloads and refresh model caches @@ -403,6 +428,7 @@ export const useAssetsStore = defineStore('assets', () => { modelAssetsByNodeType, modelLoadingByNodeType, modelErrorByNodeType, - updateModelsForNodeType + updateModelsForNodeType, + updateModelsForTag } }) diff --git a/tests-ui/platform/assets/components/AssetBrowserModal.test.ts b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts index 756dee2628..87135b4689 100644 --- a/tests-ui/platform/assets/components/AssetBrowserModal.test.ts +++ b/tests-ui/platform/assets/components/AssetBrowserModal.test.ts @@ -4,20 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import AssetBrowserModal from '@/platform/assets/components/AssetBrowserModal.vue' import type { AssetItem } from '@/platform/assets/schemas/assetSchema' - -const mockAssetService = vi.hoisted(() => ({ - getAssetsForNodeType: vi.fn(), - getAssetsByTag: vi.fn(), - getAssetDetails: vi.fn((id: string) => - Promise.resolve({ - id, - name: 'Test Model', - user_metadata: { - filename: 'Test Model' - } - }) - ) -})) +import { useAssetsStore } from '@/stores/assetsStore' vi.mock('@/i18n', () => ({ t: (key: string, params?: Record) => @@ -25,9 +12,15 @@ vi.mock('@/i18n', () => ({ d: (date: Date) => date.toLocaleDateString() })) -vi.mock('@/platform/assets/services/assetService', () => ({ - assetService: mockAssetService -})) +vi.mock('@/stores/assetsStore', () => { + const store = { + modelAssetsByNodeType: new Map(), + modelLoadingByNodeType: new Map(), + updateModelsForNodeType: vi.fn(), + updateModelsForTag: vi.fn() + } + return { useAssetsStore: () => store } +}) vi.mock('@/stores/modelToNodeStore', () => ({ useModelToNodeStore: () => ({ @@ -190,9 +183,12 @@ describe('AssetBrowserModal', () => { }) } + const mockStore = useAssetsStore() + beforeEach(() => { - mockAssetService.getAssetsForNodeType.mockReset() - mockAssetService.getAssetsByTag.mockReset() + vi.resetAllMocks() + mockStore.modelAssetsByNodeType.clear() + mockStore.modelLoadingByNodeType.clear() }) describe('Integration with useAssetBrowser', () => { @@ -201,7 +197,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -218,7 +214,7 @@ describe('AssetBrowserModal', () => { createTestAsset('c1', 'model.safetensors', 'checkpoints'), createTestAsset('l1', 'lora.pt', 'loras') ] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -234,31 +230,54 @@ describe('AssetBrowserModal', () => { }) describe('Data fetching', () => { - it('fetches assets for node type', async () => { - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([]) - + it('triggers store refresh for node type on mount', async () => { createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() - expect(mockAssetService.getAssetsForNodeType).toHaveBeenCalledWith( + expect(mockStore.updateModelsForNodeType).toHaveBeenCalledWith( 'CheckpointLoaderSimple' ) }) - it('fetches assets for tag when node type not provided', async () => { - mockAssetService.getAssetsByTag.mockResolvedValueOnce([]) + it('displays cached assets immediately from store', async () => { + const assets = [createTestAsset('asset1', 'Cached Model', 'checkpoints')] + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) - createWrapper({ assetType: 'loras' }) + const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) + + const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) + const gridAssets = assetGrid.props('assets') as AssetItem[] + + expect(gridAssets).toHaveLength(1) + expect(gridAssets[0].name).toBe('Cached Model') + }) + + it('triggers store refresh for asset type (tag) on mount', async () => { + createWrapper({ assetType: 'models' }) await flushPromises() - expect(mockAssetService.getAssetsByTag).toHaveBeenCalledWith('loras') + expect(mockStore.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) + + const wrapper = createWrapper({ assetType: 'models' }) + await flushPromises() + + const assetGrid = wrapper.findComponent({ name: 'AssetGrid' }) + const gridAssets = assetGrid.props('assets') as AssetItem[] + + expect(gridAssets).toHaveLength(1) + expect(gridAssets[0].name).toBe('Tagged Model') }) }) describe('Asset Selection', () => { it('emits asset-select event when asset is selected', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -271,7 +290,7 @@ describe('AssetBrowserModal', () => { it('executes onSelect callback when provided', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const onSelect = vi.fn() const wrapper = createWrapper({ @@ -289,8 +308,6 @@ describe('AssetBrowserModal', () => { describe('Left Panel Conditional Logic', () => { it('hides left panel by default when showLeftPanel is undefined', async () => { - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([]) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises() @@ -299,8 +316,6 @@ describe('AssetBrowserModal', () => { }) it('shows left panel when showLeftPanel prop is explicitly true', async () => { - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([]) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', showLeftPanel: true @@ -318,7 +333,7 @@ describe('AssetBrowserModal', () => { createTestAsset('asset1', 'Model A', 'checkpoints'), createTestAsset('asset2', 'Model B', 'loras') ] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', @@ -339,8 +354,6 @@ describe('AssetBrowserModal', () => { describe('Title Management', () => { it('passes custom title to BaseModalLayout when title prop provided', async () => { - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce([]) - const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple', title: 'Custom Title' @@ -353,7 +366,7 @@ describe('AssetBrowserModal', () => { it('passes computed contentTitle to BaseModalLayout when no title prop', async () => { const assets = [createTestAsset('asset1', 'Model A', 'checkpoints')] - mockAssetService.getAssetsForNodeType.mockResolvedValueOnce(assets) + mockStore.modelAssetsByNodeType.set('CheckpointLoaderSimple', assets) const wrapper = createWrapper({ nodeType: 'CheckpointLoaderSimple' }) await flushPromises()