fix(assets): harden history epoch guards and cursor consistency

- Guard loadMoreHistory's success assignment against a superseding
  reload so a stale continuation can't blank historyAssets mid-reload
- Re-sync the epoch after replaceHistoryWithHeadPage so post-replace
  errors surface instead of being swallowed by the staleness guard
- Drop historyNextCursor once paging terminates, so state never carries
  a live cursor alongside hasMoreHistory === false
- Reset historyError before the empty-list early return in
  doRefreshHistoryHead; document replaceHistoryWithHeadPage's epoch bump
This commit is contained in:
Matt Miller
2026-07-17 17:48:05 -07:00
parent 8e9baaad71
commit 4d9f89a40f
2 changed files with 64 additions and 3 deletions

View File

@@ -907,6 +907,56 @@ describe('assetsStore - Refactored (Option A)', () => {
)
})
it('does not blank historyAssets when a stale loadMore resolves mid-reload', async () => {
vi.mocked(fetchHistoryPage).mockResolvedValueOnce(
mockHistoryPage(
Array.from({ length: 10 }, (_, i) => createMockJobItem(i)),
{ hasMore: true, nextCursor: 'cursor-1' }
)
)
await store.updateHistory()
expect(store.historyAssets).toHaveLength(10)
let resolveStale: (page: FetchHistoryPageResult) => void
vi.mocked(fetchHistoryPage).mockReturnValueOnce(
new Promise<FetchHistoryPageResult>((resolve) => {
resolveStale = resolve
})
)
const staleLoad = store.loadMoreHistory()
// Second reload is still in flight: it has reset allHistoryItems to []
// and bumped the epoch, but has not repopulated historyAssets yet.
let resolveFresh: (page: FetchHistoryPageResult) => void
vi.mocked(fetchHistoryPage).mockReturnValueOnce(
new Promise<FetchHistoryPageResult>((resolve) => {
resolveFresh = resolve
})
)
const freshUpdate = store.updateHistory()
resolveStale!(
mockHistoryPage([createMockJobItem(50)], {
hasMore: true,
nextCursor: 'cursor-stale'
})
)
await staleLoad
// The superseded loadMore must not overwrite the visible list with the
// transient empty snapshot of the in-flight reload.
expect(store.historyAssets).toHaveLength(10)
resolveFresh!(
mockHistoryPage(
Array.from({ length: 5 }, (_, i) => createMockJobItem(100 + i)),
{ hasMore: true, nextCursor: 'cursor-fresh' }
)
)
await freshUpdate
expect(store.historyAssets).toHaveLength(5)
})
it('terminates the walk when the backend returns the same cursor it was given (stuck cursor)', async () => {
// Page 1: initial load mints cursor-1
const firstBatch = Array.from({ length: 10 }, (_, i) =>

View File

@@ -284,11 +284,15 @@ export const useAssetsStore = defineStore('assets', () => {
// advance it while still in offset mode; otherwise the offset used by the
// recovery fallback would drift past valid rows.
if (!historyCursorMode) historyOffset.value += page.jobs.length
historyNextCursor.value = cursorStuck ? null : (page.nextCursor ?? null)
hasMoreHistory.value =
page.hasMore &&
!cursorStuck &&
(page.jobs.length > 0 || page.nextCursor != null)
// Drop the cursor once paging terminates so state never carries a live
// cursor alongside `hasMoreHistory === false`.
historyNextCursor.value = hasMoreHistory.value
? (page.nextCursor ?? null)
: null
trimHistoryToLimit()
@@ -333,6 +337,7 @@ export const useAssetsStore = defineStore('assets', () => {
const epoch = historyFetchEpoch
try {
await fetchHistoryAssets(true)
if (epoch !== historyFetchEpoch) return
historyAssets.value = allHistoryItems.value
} catch (err) {
if (epoch !== historyFetchEpoch) return
@@ -350,6 +355,9 @@ export const useAssetsStore = defineStore('assets', () => {
* A head page with no further rows spans the whole timeline, so replacing
* local state with it also prunes jobs deleted server-side (e.g. after the
* queue history is cleared from another surface).
*
* Bumps `historyFetchEpoch`, which cancels any concurrent
* `loadMoreHistory`/`fetchHistoryAssets` continuation.
*/
const replaceHistoryWithHeadPage = (page: FetchHistoryPageResult) => {
historyFetchEpoch += 1
@@ -393,13 +401,13 @@ export const useAssetsStore = defineStore('assets', () => {
}
const doRefreshHistoryHead = async () => {
historyError.value = null
if (!allHistoryItems.value.length) {
await updateHistory()
return
}
historyError.value = null
const epoch = historyFetchEpoch
let epoch = historyFetchEpoch
try {
const page = await fetchHistoryJobsPage({ offset: 0 })
if (epoch !== historyFetchEpoch) return
@@ -425,6 +433,9 @@ export const useAssetsStore = defineStore('assets', () => {
trimHistoryToLimit()
} else {
replaceHistoryWithHeadPage(page)
// replaceHistoryWithHeadPage bumps the epoch; re-sync so the catch
// guard below suppresses stale continuations, not genuine errors.
epoch = historyFetchEpoch
}
historyAssets.value = allHistoryItems.value
} catch (err) {