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
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:
@@ -913,6 +913,14 @@ onUnmounted(() => {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chore-stamp {
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 1px;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
.due-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
|
||||
@@ -431,8 +431,7 @@ async function doExtendTime(item: ChildTask, e: MouseEvent) {
|
||||
alert(`Error: ${msg}`)
|
||||
return
|
||||
}
|
||||
childChoreListRef.value?.refresh()
|
||||
setTimeout(() => resetExpiryTimers(), 300)
|
||||
// SSE event 'chore_time_extended' triggers handleChoreTimeExtended which refreshes the list
|
||||
}
|
||||
|
||||
// ── Schedule state helpers (for item-slot use) ────────────────────────────────
|
||||
@@ -1353,6 +1352,14 @@ function goToAssignRewards() {
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.chore-stamp {
|
||||
font-size: 0.82rem;
|
||||
letter-spacing: 1px;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Due time sub-text */
|
||||
.due-label {
|
||||
font-size: 0.85rem;
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user