Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 116 additions & 0 deletions src/stores/assetsStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1800,4 +1800,120 @@ 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 })
Comment thread
mattmillerai marked this conversation as resolved.
)
const store = useAssetsStore()
await store.updateFlatOutputs()

vi.mocked(assetService.getAssetsPageByTag).mockClear()

let resolveLoadMore!: (page: AssetResponse) => void
const loadMorePromise = new Promise<AssetResponse>((res) => {
resolveLoadMore = res
})
let resolveRefresh!: (page: AssetResponse) => void
const refreshPromise = new Promise<AssetResponse>((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')
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
expect(store.flatOutputAssets.map((a) => a.id)).not.toContain('extra-1')
})

it('a stale loadMore that resolves before the refresh does not corrupt seenIds', async () => {
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
makeAsset(`a${i}`, `f${i}.png`)
)
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
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<AssetResponse>((res) => {
resolveLoadMore = res
})
let resolveRefresh!: (page: AssetResponse) => void
const refreshPromise = new Promise<AssetResponse>((res) => {
resolveRefresh = res
})

vi.mocked(assetService.getAssetsPageByTag)
.mockReturnValueOnce(loadMorePromise)
.mockReturnValueOnce(refreshPromise)

const loadMoreResult = store.loadMoreFlatOutputs()
const refreshResult = store.updateFlatOutputs()

// The stale loadMore settles first, before the refresh has replaced the
// list. Its page must be discarded rather than folded into seenIds.
resolveLoadMore(makePage([makeAsset('extra-1', 'extra.png')]))
resolveRefresh(
makePage([makeAsset('fresh-1', 'fresh.png')], { hasMore: true })
)
await Promise.all([loadMoreResult, refreshResult])

expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh-1'])

// If the discarded loadMore had leaked 'extra-1' into seenIds, this
// legitimate next page would be filtered out and never shown.
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
makePage([makeAsset('extra-1', 'extra.png')])
Comment thread
mattmillerai marked this conversation as resolved.
)
await store.loadMoreFlatOutputs()

expect(store.flatOutputAssets.map((a) => a.id)).toEqual([
'fresh-1',
'extra-1'
])
})

it('a second concurrent refresh coalesces into the first refresh promise', async () => {
let resolvePage!: (page: AssetResponse) => void
const pagePromise = new Promise<AssetResponse>((res) => {
resolvePage = res
})
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
Comment thread
mattmillerai marked this conversation as resolved.
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'])
})
})
})
40 changes: 32 additions & 8 deletions src/stores/assetsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,15 +268,20 @@ export const useAssetsStore = defineStore('assets', () => {
const flatOutputIsLoadingMore = ref(false)
const flatOutputSeenIds = new Set<string>()
let flatOutputNextCursor: string | undefined
let flatOutputInFlight: Promise<AssetItem[]> | null = null
let flatOutputRefreshInFlight: Promise<AssetItem[]> | null = null
let flatOutputLoadMoreInFlight: Promise<AssetItem[]> | null = null
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
// Incremented on each refresh; loadMore results captured from a prior epoch
// are discarded so a stale page can't append onto a freshly-refreshed list.
let flatOutputRefreshEpoch = 0

async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
if (flatOutputInFlight) return flatOutputInFlight

if (loadMore) {
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
if (!flatOutputHasMore.value) return flatOutputAssets.value
if (flatOutputLoadMoreInFlight) return flatOutputLoadMoreInFlight
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
flatOutputIsLoadingMore.value = true
} else {
if (flatOutputRefreshInFlight) return flatOutputRefreshInFlight
flatOutputRefreshEpoch++
flatOutputLoading.value = true
flatOutputOffset.value = 0
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
flatOutputNextCursor = undefined
Expand All @@ -285,7 +290,9 @@ export const useAssetsStore = defineStore('assets', () => {
}
flatOutputError.value = null

flatOutputInFlight = (async () => {
const capturedEpoch = flatOutputRefreshEpoch

const inFlight = (async () => {
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
try {
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
Comment thread
mattmillerai marked this conversation as resolved.
Expand All @@ -294,6 +301,9 @@ export const useAssetsStore = defineStore('assets', () => {
? { after: requestedAfter }
: { offset: flatOutputOffset.value })
})
if (loadMore && capturedEpoch !== flatOutputRefreshEpoch) {
return flatOutputAssets.value
}
const batch = page.assets
const fresh = loadMore
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
Expand All @@ -315,13 +325,27 @@ 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
// The in-flight promise is assigned synchronously here, before any await
// in inFlight can settle, so the loading flag set above and the guard below
// stay in lockstep. This relies on single-entry via updateFlatOutputs /
// loadMoreFlatOutputs; do not call fetchFlatOutputs directly.
if (loadMore) {
flatOutputLoadMoreInFlight = inFlight
Comment thread
mattmillerai marked this conversation as resolved.
Outdated
} else {
flatOutputRefreshInFlight = inFlight
}

return inFlight
}

const updateFlatOutputs = () => fetchFlatOutputs(false)
Expand Down
Loading