From 4d9f89a40f49e68e64faae7db40aaabaa5dfaa71 Mon Sep 17 00:00:00 2001 From: Matt Miller Date: Fri, 17 Jul 2026 17:48:05 -0700 Subject: [PATCH] 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 --- src/stores/assetsStore.test.ts | 50 ++++++++++++++++++++++++++++++++++ src/stores/assetsStore.ts | 17 ++++++++++-- 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/src/stores/assetsStore.test.ts b/src/stores/assetsStore.test.ts index baa79661eb..70a278a52d 100644 --- a/src/stores/assetsStore.test.ts +++ b/src/stores/assetsStore.test.ts @@ -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((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((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) => diff --git a/src/stores/assetsStore.ts b/src/stores/assetsStore.ts index 49606a29a8..4b831dc08f 100644 --- a/src/stores/assetsStore.ts +++ b/src/stores/assetsStore.ts @@ -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) {