fix: implement fetch generation counter to handle concurrent refreshes in ScrollingList
Some checks failed
Chore App Build, Test, and Push Docker Images / build-and-push (push) Has been cancelled

- Added a fetch generation counter to the fetchItems method to discard stale results from concurrent fetches.
- Updated the loading state management to ensure it only updates if the fetch is the latest.
- Enhanced the refresh method to prevent stale data from being rendered when multiple refreshes occur.
- Added a test case to verify that stale fetches are discarded correctly when concurrent refreshes happen.
- Fixed CSS for mobile banners to prevent overflow and adjusted font sizes for better visibility.
- Resolved issues with task icons disappearing when extending time on overdue chores by ensuring only one fetch is active at a time.
This commit is contained in:
2026-03-25 15:30:00 -04:00
parent f64311689b
commit 359c170b27
15 changed files with 166 additions and 35 deletions

View File

@@ -39,14 +39,19 @@ const scrollWrapper = ref<HTMLDivElement | null>(null)
const itemRefs = ref<Record<string, HTMLElement | Element | null>>({})
const lastCenteredItemId = ref<string | null>(null)
let fetchGeneration = 0
const fetchItems = async () => {
const thisGeneration = ++fetchGeneration
loading.value = true
error.value = null
try {
const resp = await fetch(fetchUrl.value)
if (thisGeneration !== fetchGeneration) return
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
const data = await resp.json()
if (thisGeneration !== fetchGeneration) return
// Try to use 'tasks', 'reward_status', or 'items' as fallback
let itemList = data[props.itemKey || 'items'] || []
if (props.filterFn) itemList = itemList.filter(props.filterFn)
@@ -75,18 +80,23 @@ const fetchItems = async () => {
}
}),
)
if (thisGeneration !== fetchGeneration) return
// Re-assign to trigger Vue reactivity for image URLs set on raw objects
items.value = [...itemList]
} catch (err) {
if (thisGeneration !== fetchGeneration) return
error.value = err instanceof Error ? err.message : 'Failed to fetch items'
items.value = []
console.error('Error fetching items:', err)
} finally {
loading.value = false
if (thisGeneration === fetchGeneration) {
loading.value = false
}
}
}
async function refresh() {
await fetchItems()
loading.value = false
}
const scrollToItem = async (itemId: string) => {

View File

@@ -344,6 +344,56 @@ describe('ScrollingList', () => {
expect(global.fetch).toHaveBeenCalledWith('/api/test?ids=item-1,item-2')
})
it('discards stale fetch when concurrent refreshes occur', async () => {
const { getCachedImageUrl } = await import('@/common/imageCache')
const staleItems = [{ id: 'stale-1', name: 'Stale', points: 1, image_id: 'stale-img' }]
const freshItems = [{ id: 'fresh-1', name: 'Fresh', points: 2, image_id: 'fresh-img' }]
wrapper = mount(ScrollingList, { props: defaultProps })
await nextTick()
await new Promise((resolve) => setTimeout(resolve, 50))
// Set up two concurrent fetches: first one is slow, second one is fast
let resolveFirst: (v: any) => void
const firstFetchPromise = new Promise((r) => {
resolveFirst = r
})
vi.clearAllMocks()
;(global.fetch as any)
.mockReturnValueOnce(firstFetchPromise) // slow call
.mockResolvedValueOnce({
// fast call
ok: true,
json: () => Promise.resolve({ items: freshItems }),
})
// Start first refresh (slow)
const first = wrapper.vm.refresh()
// Start second refresh (fast) — should supersede the first
const second = wrapper.vm.refresh()
// Resolve the fast call first (already resolved via mockResolvedValueOnce)
await second
await nextTick()
// Items should be from the fast (second) call
expect(wrapper.vm.items).toHaveLength(1)
expect(wrapper.vm.items[0].name).toBe('Fresh')
// Now resolve the slow (stale) call
resolveFirst!({ ok: true, json: () => Promise.resolve({ items: staleItems }) })
await first
await nextTick()
// Items should still be from the fast (second) call — stale result discarded
expect(wrapper.vm.items).toHaveLength(1)
expect(wrapper.vm.items[0].name).toBe('Fresh')
// Image was loaded for fresh item, not stale
expect(getCachedImageUrl).toHaveBeenCalledWith('fresh-img')
})
})
describe('Image Loading', () => {