diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index 98806b3330b..19a5b68d047 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -774,6 +774,78 @@ describe('assetsStore - Refactored (Option A)', () => { }) }) +describe('assetsStore - loadedJobIds (all-job dedup)', () => { + let store: ReturnType + + const createFailedJobItem = (id: string): JobListItem => ({ + id, + status: 'failed', + create_time: 1000, + update_time: 1000, + last_state_update: 1000, + priority: 1 + }) + + const createDisplayableJobItem = (id: string, index = 0): JobListItem => ({ + id, + status: 'completed', + create_time: 1000 + index, + update_time: 1000 + index, + last_state_update: 1000 + index, + priority: 1000 + index, + preview_output: { + filename: `output_${id}.png`, + subfolder: '', + type: 'output', + nodeId: 'node_1', + mediaType: 'images' + } + }) + + beforeEach(() => { + setActivePinia(createTestingPinia({ stubActions: false })) + store = useAssetsStore() + vi.clearAllMocks() + }) + + it('does not false-negative dedup when the first page is all non-displayable jobs', async () => { + const firstBatch = Array.from({ length: 200 }, (_, i) => + createFailedJobItem(`failed_${i}`) + ) + vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch) + await store.updateHistory() + + expect(store.historyAssets).toHaveLength(0) + + const secondBatch = [ + createFailedJobItem('failed_0'), + createDisplayableJobItem('new_job', 0) + ] + vi.mocked(api.getHistory).mockResolvedValueOnce(secondBatch) + await store.loadMoreHistory() + + const ids = store.historyAssets.map((a) => a.id) + expect(ids).toContain('new_job') + expect(ids).not.toContain('failed_0') + }) + + it('loadedJobIds is cleared on reset (updateHistory)', async () => { + const firstBatch = [createFailedJobItem('job_a')] + vi.mocked(api.getHistory).mockResolvedValueOnce(firstBatch) + await store.updateHistory() + + const secondBatch = [createDisplayableJobItem('job_a', 0)] + vi.mocked(api.getHistory) + .mockResolvedValueOnce(secondBatch) + .mockResolvedValueOnce([]) + + await store.updateHistory() + + expect(store.historyAssets).toHaveLength(1) + expect(store.historyAssets[0].id).toBe('job_a') + }) +}) + describe('assetsStore - Model Assets Cache (Cloud)', () => { beforeEach(() => { setActivePinia(createTestingPinia({ stubActions: false })) @@ -1800,4 +1872,69 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => { expect(store.flatOutputAssets.map((x) => x.id)).toEqual(['shared-1']) }) + + describe('in-flight tracking: refresh vs loadMore', () => { + it('refresh during an in-flight loadMore runs its reset path and is not dropped', async () => { + const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) => + makeAsset(`a${i}`, `f${i}.png`) + ) + vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce( + makePage(firstPage, { hasMore: true }) + ) + const store = useAssetsStore() + await store.updateFlatOutputs() + + vi.mocked(assetService.getAssetsPageByTag).mockClear() + + let resolveLoadMore!: (page: AssetResponse) => void + const loadMorePromise = new Promise((res) => { + resolveLoadMore = res + }) + let resolveRefresh!: (page: AssetResponse) => void + const refreshPromise = new Promise((res) => { + resolveRefresh = res + }) + + vi.mocked(assetService.getAssetsPageByTag) + .mockReturnValueOnce(loadMorePromise) + .mockReturnValueOnce(refreshPromise) + + const loadMoreResult = store.loadMoreFlatOutputs() + const refreshResult = store.updateFlatOutputs() + + expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes( + 2 + ) + + resolveRefresh(makePage([makeAsset('fresh-1', 'fresh.png')])) + resolveLoadMore(makePage([makeAsset('extra-1', 'extra.png')])) + await Promise.all([loadMoreResult, refreshResult]) + + expect(store.flatOutputAssets.map((a) => a.id)).toContain('fresh-1') + expect(store.flatOutputAssets.map((a) => a.id)).not.toContain('extra-1') + }) + + it('a second concurrent refresh coalesces into the first refresh promise', async () => { + let resolvePage!: (page: AssetResponse) => void + const pagePromise = new Promise((res) => { + resolvePage = res + }) + vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce( + pagePromise + ) + + const store = useAssetsStore() + const r1 = store.updateFlatOutputs() + const r2 = store.updateFlatOutputs() + + expect(vi.mocked(assetService.getAssetsPageByTag)).toHaveBeenCalledTimes( + 1 + ) + + resolvePage(makePage([makeAsset('only-1', 'only.png')])) + await Promise.all([r1, r2]) + + expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['only-1']) + }) + }) }) diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 0a8b20e4b5e..46d80fb82d0 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -151,7 +151,6 @@ export const useAssetsStore = defineStore('assets', () => { * @param loadMore - true for pagination (append), false for initial load (replace) */ const fetchHistoryAssets = async (loadMore = false): Promise => { - // Reset state for initial load if (!loadMore) { historyOffset.value = 0 hasMoreHistory.value = true @@ -159,38 +158,31 @@ export const useAssetsStore = defineStore('assets', () => { loadedIds.clear() } - // Fetch from server with offset const history = await api.getHistory(BATCH_SIZE, { offset: historyOffset.value }) - // Convert JobListItems to AssetItems const newAssets = mapHistoryToAssets(history) if (loadMore) { - // Filter out duplicates and insert in sorted order for (const asset of newAssets) { if (loadedIds.has(asset.id)) { - continue // Skip duplicates + continue } loadedIds.add(asset.id) - // Find insertion index to maintain sorted order (newest first) const assetTime = new Date(asset.created_at ?? 0).getTime() const insertIndex = allHistoryItems.value.findIndex( (item) => new Date(item.created_at ?? 0).getTime() < assetTime ) if (insertIndex === -1) { - // Asset is oldest, append to end allHistoryItems.value.push(asset) } else { - // Insert at the correct position allHistoryItems.value.splice(insertIndex, 0, asset) } } } else { - // Initial load: replace all allHistoryItems.value = newAssets newAssets.forEach((asset) => loadedIds.add(asset.id)) } @@ -268,15 +260,18 @@ export const useAssetsStore = defineStore('assets', () => { const flatOutputIsLoadingMore = ref(false) const flatOutputSeenIds = new Set() let flatOutputNextCursor: string | undefined - let flatOutputInFlight: Promise | null = null + let flatOutputRefreshInFlight: Promise | null = null + let flatOutputLoadMoreInFlight: Promise | null = null + let flatOutputGeneration = 0 async function fetchFlatOutputs(loadMore: boolean): Promise { - if (flatOutputInFlight) return flatOutputInFlight - if (loadMore) { if (!flatOutputHasMore.value) return flatOutputAssets.value + if (flatOutputLoadMoreInFlight) return flatOutputLoadMoreInFlight flatOutputIsLoadingMore.value = true } else { + if (flatOutputRefreshInFlight) return flatOutputRefreshInFlight + flatOutputGeneration++ flatOutputLoading.value = true flatOutputOffset.value = 0 flatOutputNextCursor = undefined @@ -285,7 +280,9 @@ export const useAssetsStore = defineStore('assets', () => { } flatOutputError.value = null - flatOutputInFlight = (async () => { + const generation = flatOutputGeneration + + const inFlight = (async () => { const requestedAfter = loadMore ? flatOutputNextCursor : undefined try { const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, { @@ -294,6 +291,9 @@ export const useAssetsStore = defineStore('assets', () => { ? { after: requestedAfter } : { offset: flatOutputOffset.value }) }) + if (loadMore && generation !== flatOutputGeneration) { + return flatOutputAssets.value + } const batch = page.assets const fresh = loadMore ? batch.filter((asset) => !flatOutputSeenIds.has(asset.id)) @@ -315,13 +315,23 @@ export const useAssetsStore = defineStore('assets', () => { console.error('Failed to fetch output assets:', err) return loadMore ? flatOutputAssets.value : [] } finally { - if (loadMore) flatOutputIsLoadingMore.value = false - else flatOutputLoading.value = false - flatOutputInFlight = null + if (loadMore) { + flatOutputIsLoadingMore.value = false + flatOutputLoadMoreInFlight = null + } else { + flatOutputLoading.value = false + flatOutputRefreshInFlight = null + } } })() - return flatOutputInFlight + if (loadMore) { + flatOutputLoadMoreInFlight = inFlight + } else { + flatOutputRefreshInFlight = inFlight + } + + return inFlight } const updateFlatOutputs = () => fetchFlatOutputs(false)