Skip to content

Commit 707e436

Browse files
viva-jinyiclaude
andcommitted
fix: resolve assets pagination accumulation bug in Virtual Grid View
Fixed pagination issue where new history items were replacing existing items instead of accumulating when scrolling. Changes: - Fix loadMore logic in fetchHistoryAssets to accumulate items via sorted insertion - Implement History V2 reconciliation pattern with Map-based deduplication - Add O(log n) binary search insertion (findInsertionIndex) for performance - Enhance type safety: any[] → TaskItem[], add type guards - Improve error handling: preserve existing data, prevent race conditions - Add comprehensive test suite (12 test cases covering pagination, deduplication, sorting, error handling) Virtual Grid now properly accumulates 200 items per batch during scroll pagination. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 15b5ba0 commit 707e436

File tree

3 files changed

+647
-20
lines changed

3 files changed

+647
-20
lines changed

src/components/sidebar/tabs/AssetsSidebarTab.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
<template #body>
4444
<div v-if="displayAssets.length" class="relative size-full">
4545
<VirtualGrid
46-
v-if="!loading"
46+
v-if="!loading && displayAssets.length"
4747
:items="mediaAssetsWithKey"
4848
:grid-style="{
4949
display: 'grid',

src/stores/assetsStore.ts

Lines changed: 157 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,42 @@ import {
99
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
1010
import { assetService } from '@/platform/assets/services/assetService'
1111
import { isCloud } from '@/platform/distribution/types'
12+
import { reconcileHistory } from '@/platform/remote/comfyui/history/reconciliation'
13+
import type { TaskItem } from '@/schemas/apiSchema'
1214
import { api } from '@/scripts/api'
1315

1416
import { TaskItemImpl } from './queueStore'
1517

18+
/**
19+
* Extract promptId from asset ID
20+
*/
21+
const extractPromptId = (assetId: string): string => {
22+
return assetId.split('_')[0]
23+
}
24+
25+
/**
26+
* Binary search to find insertion index in sorted array
27+
*/
28+
const findInsertionIndex = (array: AssetItem[], item: AssetItem): number => {
29+
let left = 0
30+
let right = array.length
31+
const itemTime = new Date(item.created_at).getTime()
32+
33+
while (left < right) {
34+
const mid = Math.floor((left + right) / 2)
35+
const midTime = new Date(array[mid].created_at).getTime()
36+
37+
// Sort by date descending (newest first)
38+
if (midTime < itemTime) {
39+
right = mid
40+
} else {
41+
left = mid + 1
42+
}
43+
}
44+
45+
return left
46+
}
47+
1648
/**
1749
* Fetch input files from the internal API (OSS version)
1850
*/
@@ -43,10 +75,15 @@ async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
4375
/**
4476
* Convert history task items to asset items
4577
*/
46-
function mapHistoryToAssets(historyItems: any[]): AssetItem[] {
78+
function mapHistoryToAssets(historyItems: TaskItem[]): AssetItem[] {
4779
const assetItems: AssetItem[] = []
4880

4981
for (const item of historyItems) {
82+
// Type guard for HistoryTaskItem which has status and outputs
83+
if (item.taskType !== 'History') {
84+
continue
85+
}
86+
5087
if (!item.outputs || !item.status || item.status?.status_str === 'error') {
5188
continue
5289
}
@@ -81,13 +118,25 @@ function mapHistoryToAssets(historyItems: any[]): AssetItem[] {
81118
}
82119

83120
const BATCH_SIZE = 200
121+
const MAX_HISTORY_ITEMS = 1000 // Maximum items to keep in memory
84122

85123
export const useAssetsStore = defineStore('assets', () => {
86124
const historyOffset = ref(0)
87125
const hasMoreHistory = ref(true)
88126
const isLoadingMore = ref(false)
89127
const allHistoryItems = ref<AssetItem[]>([])
90128

129+
// Map to track TaskItems for reconciliation
130+
const taskItemsMap = new Map<string, TaskItem>()
131+
// Map to track AssetItems by promptId for efficient reuse
132+
const assetItemsByPromptId = new Map<string, AssetItem>()
133+
134+
// Keep track of last known queue index for V1 reconciliation
135+
let lastKnownQueueIndex: number | undefined = undefined
136+
137+
// Promise-based guard to prevent race conditions
138+
let loadingPromise: Promise<void> | null = null
139+
91140
const fetchInputFiles = isCloud
92141
? fetchInputFilesFromCloud
93142
: fetchInputFilesFromAPI
@@ -110,25 +159,96 @@ export const useAssetsStore = defineStore('assets', () => {
110159
historyOffset.value = 0
111160
hasMoreHistory.value = true
112161
allHistoryItems.value = []
162+
taskItemsMap.clear()
163+
assetItemsByPromptId.clear()
164+
lastKnownQueueIndex = undefined
113165
}
114166

115167
const history = await api.getHistory(BATCH_SIZE, {
116168
offset: historyOffset.value
117169
})
118-
const newAssets = mapHistoryToAssets(history.History)
170+
171+
let itemsToProcess: TaskItem[]
119172

120173
if (loadMore) {
121-
const existingIds = new Set(allHistoryItems.value.map((item) => item.id))
122-
const uniqueNewAssets = newAssets.filter(
123-
(item) => !existingIds.has(item.id)
174+
// For pagination: just add new items, don't use reconcileHistory
175+
// Since we're fetching with offset, these should be different items
176+
itemsToProcess = history.History
177+
178+
// Add new items to taskItemsMap
179+
itemsToProcess.forEach((item) => {
180+
const promptId = item.prompt[1]
181+
// Only add if not already present (avoid duplicates)
182+
if (!taskItemsMap.has(promptId)) {
183+
taskItemsMap.set(promptId, item)
184+
}
185+
})
186+
} else {
187+
// Initial load - use reconcileHistory for deduplication
188+
itemsToProcess = reconcileHistory(
189+
history.History,
190+
[],
191+
MAX_HISTORY_ITEMS,
192+
lastKnownQueueIndex
193+
)
194+
195+
// Clear and rebuild taskItemsMap
196+
taskItemsMap.clear()
197+
itemsToProcess.forEach((item) => {
198+
taskItemsMap.set(item.prompt[1], item)
199+
})
200+
}
201+
202+
// Update last known queue index
203+
const allTaskItems = Array.from(taskItemsMap.values())
204+
if (allTaskItems.length > 0) {
205+
lastKnownQueueIndex = allTaskItems.reduce(
206+
(max, item) => Math.max(max, item.prompt[0]),
207+
-Infinity
124208
)
125-
allHistoryItems.value = [...allHistoryItems.value, ...uniqueNewAssets]
209+
}
210+
211+
// Convert new items to AssetItems
212+
const newAssets = mapHistoryToAssets(itemsToProcess)
213+
214+
if (loadMore) {
215+
// For pagination: insert new assets in sorted order
216+
newAssets.forEach((asset) => {
217+
const promptId = extractPromptId(asset.id)
218+
// Only add if not already present
219+
if (!assetItemsByPromptId.has(promptId)) {
220+
assetItemsByPromptId.set(promptId, asset)
221+
// Insert at correct position to maintain sort order
222+
const index = findInsertionIndex(allHistoryItems.value, asset)
223+
allHistoryItems.value.splice(index, 0, asset)
224+
}
225+
})
226+
} else {
227+
// Initial load: replace all
228+
assetItemsByPromptId.clear()
229+
allHistoryItems.value = []
230+
231+
newAssets.forEach((asset) => {
232+
const promptId = extractPromptId(asset.id)
233+
assetItemsByPromptId.set(promptId, asset)
234+
allHistoryItems.value.push(asset)
235+
})
236+
}
237+
238+
// Check if there are more items to load
239+
hasMoreHistory.value = history.History.length === BATCH_SIZE
240+
241+
// Use fixed batch size for offset to avoid pagination gaps
242+
if (loadMore) {
243+
historyOffset.value += BATCH_SIZE
126244
} else {
127-
allHistoryItems.value = newAssets
245+
historyOffset.value = BATCH_SIZE
128246
}
129247

130-
hasMoreHistory.value = newAssets.length === BATCH_SIZE
131-
historyOffset.value += newAssets.length
248+
// Ensure we don't exceed MAX_HISTORY_ITEMS
249+
if (allHistoryItems.value.length > MAX_HISTORY_ITEMS) {
250+
allHistoryItems.value = allHistoryItems.value.slice(0, MAX_HISTORY_ITEMS)
251+
}
132252

133253
return allHistoryItems.value
134254
}
@@ -141,28 +261,46 @@ export const useAssetsStore = defineStore('assets', () => {
141261
historyLoading.value = true
142262
historyError.value = null
143263
try {
144-
const assets = await fetchHistoryAssets(false)
145-
historyAssets.value = assets
264+
await fetchHistoryAssets(false)
265+
historyAssets.value = allHistoryItems.value
146266
} catch (err) {
147267
console.error('Error fetching history assets:', err)
148268
historyError.value = err
269+
// Keep existing data when error occurs
270+
if (!historyAssets.value.length) {
271+
historyAssets.value = []
272+
}
149273
} finally {
150274
historyLoading.value = false
151275
}
152276
}
153277

154278
const loadMoreHistory = async () => {
155-
if (!hasMoreHistory.value || isLoadingMore.value) return
279+
// Check if we should load more
280+
if (!hasMoreHistory.value) return
156281

157-
isLoadingMore.value = true
282+
// Prevent race conditions with promise-based guard
283+
if (loadingPromise) return loadingPromise
284+
285+
const doLoadMore = async () => {
286+
isLoadingMore.value = true
287+
historyError.value = null // Clear error before new attempt
288+
try {
289+
await fetchHistoryAssets(true)
290+
historyAssets.value = allHistoryItems.value
291+
} catch (err) {
292+
console.error('Error loading more history:', err)
293+
historyError.value = err
294+
} finally {
295+
isLoadingMore.value = false
296+
}
297+
}
298+
299+
loadingPromise = doLoadMore()
158300
try {
159-
const updatedAssets = await fetchHistoryAssets(true)
160-
historyAssets.value = updatedAssets
161-
} catch (err) {
162-
console.error('Error loading more history:', err)
163-
historyError.value = err
301+
await loadingPromise
164302
} finally {
165-
isLoadingMore.value = false
303+
loadingPromise = null
166304
}
167305
}
168306

0 commit comments

Comments
 (0)