Skip to content

Commit 666f8dd

Browse files
mattmilleraiclaude
andcommitted
refactor(assets): derive flat-output loading flags from in-flight promises
Split fetchFlatOutputs into updateFlatOutputs / loadMoreFlatOutputs so each path is independently readable and the "do not call directly" footgun goes away. Replace the mirrored loading booleans with computeds over the in-flight promise slots, removing the manually-synced duplicate state. Swap flatOutputSeenIds on refresh success rather than before the request, so a failed refresh leaves the loaded-id check consistent with the list still on screen instead of re-appending the head page on the next loadMore. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 53fe706 commit 666f8dd

2 files changed

Lines changed: 167 additions & 101 deletions

File tree

src/stores/assetsStore.test.ts

Lines changed: 102 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1802,7 +1802,21 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18021802
})
18031803

18041804
describe('in-flight tracking: refresh vs loadMore', () => {
1805-
it('refresh during an in-flight loadMore runs its reset path and is not dropped', async () => {
1805+
const deferredPage = () => {
1806+
let resolve!: (page: AssetResponse) => void
1807+
let reject!: (err: unknown) => void
1808+
const promise = new Promise<AssetResponse>((res, rej) => {
1809+
resolve = res
1810+
reject = rej
1811+
})
1812+
return { promise, resolve, reject }
1813+
}
1814+
1815+
/**
1816+
* Loads a full first page so `hasMore` is true and a loadMore is legal,
1817+
* then clears the mock so each test's assertions see only its own calls.
1818+
*/
1819+
const setupStoreWithFirstPage = async () => {
18061820
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
18071821
makeAsset(`a${i}`, `f${i}.png`)
18081822
)
@@ -1811,21 +1825,18 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18111825
)
18121826
const store = useAssetsStore()
18131827
await store.updateFlatOutputs()
1814-
18151828
vi.mocked(assetService.getAssetsPageByTag).mockClear()
1829+
return { store, firstPageIds: firstPage.map((a) => a.id) }
1830+
}
18161831

1817-
let resolveLoadMore!: (page: AssetResponse) => void
1818-
const loadMorePromise = new Promise<AssetResponse>((res) => {
1819-
resolveLoadMore = res
1820-
})
1821-
let resolveRefresh!: (page: AssetResponse) => void
1822-
const refreshPromise = new Promise<AssetResponse>((res) => {
1823-
resolveRefresh = res
1824-
})
1832+
it('refresh during an in-flight loadMore runs its reset path and is not dropped', async () => {
1833+
const { store } = await setupStoreWithFirstPage()
1834+
const loadMorePage = deferredPage()
1835+
const refreshPage = deferredPage()
18251836

18261837
vi.mocked(assetService.getAssetsPageByTag)
1827-
.mockReturnValueOnce(loadMorePromise)
1828-
.mockReturnValueOnce(refreshPromise)
1838+
.mockReturnValueOnce(loadMorePage.promise)
1839+
.mockReturnValueOnce(refreshPage.promise)
18291840

18301841
const loadMoreResult = store.loadMoreFlatOutputs()
18311842
const refreshResult = store.updateFlatOutputs()
@@ -1834,46 +1845,29 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18341845
2
18351846
)
18361847

1837-
resolveRefresh(makePage([makeAsset('fresh-1', 'fresh.png')]))
1838-
resolveLoadMore(makePage([makeAsset('extra-1', 'extra.png')]))
1848+
refreshPage.resolve(makePage([makeAsset('fresh-1', 'fresh.png')]))
1849+
loadMorePage.resolve(makePage([makeAsset('extra-1', 'extra.png')]))
18391850
await Promise.all([loadMoreResult, refreshResult])
18401851

1841-
expect(store.flatOutputAssets.map((a) => a.id)).toContain('fresh-1')
1842-
expect(store.flatOutputAssets.map((a) => a.id)).not.toContain('extra-1')
1852+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh-1'])
18431853
})
18441854

18451855
it('a stale loadMore that resolves before the refresh does not corrupt seenIds', async () => {
1846-
const firstPage = Array.from({ length: FLAT_OUTPUT_PAGE_SIZE }, (_, i) =>
1847-
makeAsset(`a${i}`, `f${i}.png`)
1848-
)
1849-
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
1850-
makePage(firstPage, { hasMore: true })
1851-
)
1852-
const store = useAssetsStore()
1853-
await store.updateFlatOutputs()
1854-
1855-
vi.mocked(assetService.getAssetsPageByTag).mockClear()
1856-
1857-
let resolveLoadMore!: (page: AssetResponse) => void
1858-
const loadMorePromise = new Promise<AssetResponse>((res) => {
1859-
resolveLoadMore = res
1860-
})
1861-
let resolveRefresh!: (page: AssetResponse) => void
1862-
const refreshPromise = new Promise<AssetResponse>((res) => {
1863-
resolveRefresh = res
1864-
})
1856+
const { store } = await setupStoreWithFirstPage()
1857+
const loadMorePage = deferredPage()
1858+
const refreshPage = deferredPage()
18651859

18661860
vi.mocked(assetService.getAssetsPageByTag)
1867-
.mockReturnValueOnce(loadMorePromise)
1868-
.mockReturnValueOnce(refreshPromise)
1861+
.mockReturnValueOnce(loadMorePage.promise)
1862+
.mockReturnValueOnce(refreshPage.promise)
18691863

18701864
const loadMoreResult = store.loadMoreFlatOutputs()
18711865
const refreshResult = store.updateFlatOutputs()
18721866

18731867
// The stale loadMore settles first, before the refresh has replaced the
18741868
// list. Its page must be discarded rather than folded into seenIds.
1875-
resolveLoadMore(makePage([makeAsset('extra-1', 'extra.png')]))
1876-
resolveRefresh(
1869+
loadMorePage.resolve(makePage([makeAsset('extra-1', 'extra.png')]))
1870+
refreshPage.resolve(
18771871
makePage([makeAsset('fresh-1', 'fresh.png')], { hasMore: true })
18781872
)
18791873
await Promise.all([loadMoreResult, refreshResult])
@@ -1893,13 +1887,75 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
18931887
])
18941888
})
18951889

1896-
it('a second concurrent refresh coalesces into the first refresh promise', async () => {
1897-
let resolvePage!: (page: AssetResponse) => void
1898-
const pagePromise = new Promise<AssetResponse>((res) => {
1899-
resolvePage = res
1900-
})
1890+
it('discards a stale loadMore even when the refresh reports no further pages', async () => {
1891+
const { store } = await setupStoreWithFirstPage()
1892+
const loadMorePage = deferredPage()
1893+
const refreshPage = deferredPage()
1894+
1895+
vi.mocked(assetService.getAssetsPageByTag)
1896+
.mockReturnValueOnce(loadMorePage.promise)
1897+
.mockReturnValueOnce(refreshPage.promise)
1898+
1899+
const loadMoreResult = store.loadMoreFlatOutputs()
1900+
const refreshResult = store.updateFlatOutputs()
1901+
1902+
loadMorePage.resolve(
1903+
makePage([makeAsset('extra-1', 'extra.png')], { hasMore: true })
1904+
)
1905+
refreshPage.resolve(
1906+
makePage([makeAsset('fresh-1', 'fresh.png')], { hasMore: false })
1907+
)
1908+
await Promise.all([loadMoreResult, refreshResult])
1909+
1910+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(['fresh-1'])
1911+
expect(store.flatOutputHasMore).toBe(false)
1912+
})
1913+
1914+
it('keeps the list and the loaded-id check in step when a refresh fails mid-loadMore', async () => {
1915+
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
1916+
try {
1917+
const { store, firstPageIds } = await setupStoreWithFirstPage()
1918+
const loadMorePage = deferredPage()
1919+
const refreshPage = deferredPage()
1920+
1921+
vi.mocked(assetService.getAssetsPageByTag)
1922+
.mockReturnValueOnce(loadMorePage.promise)
1923+
.mockReturnValueOnce(refreshPage.promise)
1924+
1925+
const loadMoreResult = store.loadMoreFlatOutputs()
1926+
const refreshResult = store.updateFlatOutputs()
1927+
1928+
const err = new Error('network down')
1929+
refreshPage.reject(err)
1930+
loadMorePage.resolve(makePage([makeAsset('extra-1', 'extra.png')]))
1931+
await Promise.all([loadMoreResult, refreshResult])
1932+
1933+
expect(store.flatOutputError).toBe(err)
1934+
expect(store.flatOutputLoading).toBe(false)
1935+
expect(store.flatOutputIsLoadingMore).toBe(false)
1936+
// The failed refresh dropped nothing; the stale loadMore was discarded.
1937+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(firstPageIds)
1938+
1939+
// A retry restarts from the head. The re-served first page is already
1940+
// in seenIds, so it must not be appended a second time.
1941+
vi.mocked(assetService.getAssetsPageByTag).mockResolvedValueOnce(
1942+
makePage(
1943+
firstPageIds.map((id, i) => makeAsset(id, `f${i}.png`)),
1944+
{ hasMore: true }
1945+
)
1946+
)
1947+
await store.loadMoreFlatOutputs()
1948+
1949+
expect(store.flatOutputAssets.map((a) => a.id)).toEqual(firstPageIds)
1950+
} finally {
1951+
consoleSpy.mockRestore()
1952+
}
1953+
})
1954+
1955+
it('concurrent refreshes deduplicate the network call and produce consistent state', async () => {
1956+
const page = deferredPage()
19011957
vi.mocked(assetService.getAssetsPageByTag).mockReturnValueOnce(
1902-
pagePromise
1958+
page.promise
19031959
)
19041960

19051961
const store = useAssetsStore()
@@ -1910,7 +1966,7 @@ describe('assetsStore - Flat Output Assets (cloud-only)', () => {
19101966
1
19111967
)
19121968

1913-
resolvePage(makePage([makeAsset('only-1', 'only.png')]))
1969+
page.resolve(makePage([makeAsset('only-1', 'only.png')]))
19141970
await Promise.all([r1, r2])
19151971

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

src/stores/assetsStore.ts

Lines changed: 65 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { useAsyncState, whenever } from '@vueuse/core'
22
import { difference } from 'es-toolkit'
33
import { defineStore } from 'pinia'
4-
import { computed, reactive, ref, shallowReactive } from 'vue'
4+
import { computed, reactive, ref, shallowReactive, shallowRef } from 'vue'
55
import {
66
mapInputFileToAssetItem,
77
mapTaskOutputToAssetItem
@@ -261,97 +261,107 @@ export const useAssetsStore = defineStore('assets', () => {
261261
}
262262

263263
const flatOutputAssets = ref<AssetItem[]>([])
264-
const flatOutputLoading = ref(false)
265264
const flatOutputError = ref<unknown>(null)
266265
const flatOutputOffset = ref(0)
267266
const flatOutputHasMore = ref(true)
268-
const flatOutputIsLoadingMore = ref(false)
269267
const flatOutputSeenIds = new Set<string>()
270268
let flatOutputNextCursor: string | undefined
271-
let flatOutputRefreshInFlight: Promise<AssetItem[]> | null = null
272-
let flatOutputLoadMoreInFlight: Promise<AssetItem[]> | null = null
269+
const flatOutputRefreshInFlight = shallowRef<Promise<AssetItem[]> | null>(
270+
null
271+
)
272+
const flatOutputLoadMoreInFlight = shallowRef<Promise<void> | null>(null)
273+
const flatOutputLoading = computed(
274+
() => flatOutputRefreshInFlight.value !== null
275+
)
276+
const flatOutputIsLoadingMore = computed(
277+
() => flatOutputLoadMoreInFlight.value !== null
278+
)
273279
// Incremented on each refresh; loadMore results captured from a prior epoch
274280
// are discarded so a stale page can't append onto a freshly-refreshed list.
275281
let flatOutputRefreshEpoch = 0
276282

277-
async function fetchFlatOutputs(loadMore: boolean): Promise<AssetItem[]> {
278-
if (loadMore) {
279-
if (!flatOutputHasMore.value) return flatOutputAssets.value
280-
if (flatOutputLoadMoreInFlight) return flatOutputLoadMoreInFlight
281-
flatOutputIsLoadingMore.value = true
282-
} else {
283-
if (flatOutputRefreshInFlight) return flatOutputRefreshInFlight
284-
flatOutputRefreshEpoch++
285-
flatOutputLoading.value = true
286-
flatOutputOffset.value = 0
287-
flatOutputNextCursor = undefined
288-
flatOutputHasMore.value = true
289-
flatOutputSeenIds.clear()
283+
async function updateFlatOutputs(): Promise<AssetItem[]> {
284+
if (flatOutputRefreshInFlight.value) return flatOutputRefreshInFlight.value
285+
286+
flatOutputRefreshEpoch++
287+
flatOutputOffset.value = 0
288+
flatOutputNextCursor = undefined
289+
flatOutputHasMore.value = true
290+
flatOutputError.value = null
291+
292+
const inFlight = (async () => {
293+
try {
294+
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
295+
limit: FLAT_OUTPUT_PAGE_SIZE,
296+
offset: 0
297+
})
298+
// Swapped only on success, so a failed refresh leaves the loaded-id
299+
// check consistent with the list still on screen.
300+
flatOutputSeenIds.clear()
301+
for (const asset of page.assets) flatOutputSeenIds.add(asset.id)
302+
flatOutputAssets.value = page.assets
303+
flatOutputOffset.value = page.assets.length
304+
flatOutputNextCursor = page.next_cursor || undefined
305+
flatOutputHasMore.value = page.assets.length > 0 && page.has_more
306+
return flatOutputAssets.value
307+
} catch (err) {
308+
flatOutputError.value = err
309+
console.error('Failed to fetch output assets:', err)
310+
return []
311+
} finally {
312+
flatOutputRefreshInFlight.value = null
313+
}
314+
})()
315+
316+
// Assigned after the IIFE starts but before it can settle, so the `finally`
317+
// above never clears a slot that has not been filled yet.
318+
flatOutputRefreshInFlight.value = inFlight
319+
return inFlight
320+
}
321+
322+
async function loadMoreFlatOutputs(): Promise<void> {
323+
if (!flatOutputHasMore.value) return
324+
if (flatOutputLoadMoreInFlight.value) {
325+
await flatOutputLoadMoreInFlight.value
326+
return
290327
}
328+
291329
flatOutputError.value = null
292330

293-
const capturedEpoch = flatOutputRefreshEpoch
331+
const capturedRefreshEpoch = flatOutputRefreshEpoch
332+
const requestedAfter = flatOutputNextCursor
294333

295334
const inFlight = (async () => {
296-
const requestedAfter = loadMore ? flatOutputNextCursor : undefined
297335
try {
298336
const page = await assetService.getAssetsPageByTag(OUTPUT_TAG, true, {
299337
limit: FLAT_OUTPUT_PAGE_SIZE,
300338
...(requestedAfter
301339
? { after: requestedAfter }
302340
: { offset: flatOutputOffset.value })
303341
})
304-
if (loadMore && capturedEpoch !== flatOutputRefreshEpoch) {
305-
return flatOutputAssets.value
306-
}
342+
if (capturedRefreshEpoch !== flatOutputRefreshEpoch) return
343+
307344
const batch = page.assets
308-
const fresh = loadMore
309-
? batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
310-
: batch
345+
const fresh = batch.filter((asset) => !flatOutputSeenIds.has(asset.id))
311346
for (const asset of fresh) flatOutputSeenIds.add(asset.id)
312-
flatOutputAssets.value = loadMore
313-
? [...flatOutputAssets.value, ...fresh]
314-
: batch
347+
flatOutputAssets.value = [...flatOutputAssets.value, ...fresh]
315348
flatOutputOffset.value += batch.length
316349
const nextCursor = page.next_cursor || undefined
317350
const cursorStuck =
318351
nextCursor !== undefined && nextCursor === requestedAfter
319352
flatOutputNextCursor = cursorStuck ? undefined : nextCursor
320353
flatOutputHasMore.value =
321354
fresh.length > 0 && page.has_more && !cursorStuck
322-
return flatOutputAssets.value
323355
} catch (err) {
324356
flatOutputError.value = err
325357
console.error('Failed to fetch output assets:', err)
326-
return loadMore ? flatOutputAssets.value : []
327358
} finally {
328-
if (loadMore) {
329-
flatOutputIsLoadingMore.value = false
330-
flatOutputLoadMoreInFlight = null
331-
} else {
332-
flatOutputLoading.value = false
333-
flatOutputRefreshInFlight = null
334-
}
359+
flatOutputLoadMoreInFlight.value = null
335360
}
336361
})()
337362

338-
// The in-flight promise is assigned synchronously here, before any await
339-
// in inFlight can settle, so the loading flag set above and the guard below
340-
// stay in lockstep. This relies on single-entry via updateFlatOutputs /
341-
// loadMoreFlatOutputs; do not call fetchFlatOutputs directly.
342-
if (loadMore) {
343-
flatOutputLoadMoreInFlight = inFlight
344-
} else {
345-
flatOutputRefreshInFlight = inFlight
346-
}
347-
348-
return inFlight
349-
}
350-
351-
const updateFlatOutputs = () => fetchFlatOutputs(false)
352-
const loadMoreFlatOutputs = async () => {
353-
if (flatOutputIsLoadingMore.value) return
354-
await fetchFlatOutputs(true)
363+
flatOutputLoadMoreInFlight.value = inFlight
364+
await inFlight
355365
}
356366

357367
/**

0 commit comments

Comments
 (0)