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:
@@ -2,17 +2,17 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "55lJfL_-PofI1V-NQ2E3Jlo4f_IE1aXqOF-wX54ZVSY",
|
||||
"value": "8p4HLWbALh9Qwg9Znl1q8Z0Ohteik28-6H8Z5qWnaZo",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1782155883.275106,
|
||||
"expires": 1782241430.50454,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiIzZGQ0YTI1Yy04ZDI1LTQwNGEtODU0NS1mYjM2ZGRkYTU5NmYiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3ODN9.b0ACVC63OobLzraiOcn4QDXipHXUVMW-Wn42XJFDOn0",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1jY0B0ZXN0LmNvbSIsInVzZXJfaWQiOiJiZWM5NTZiMi01MTZlLTQzZmQtYWUzYS1iZjNjN2RmNzA4M2QiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMzB9.5TdKuDgcuTmwvA7lQ4VOKXMwkviRNn24utyuWqD8IPA",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1774379882977}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1774465430181}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1774552683610}"
|
||||
"value": "{\"expiresAt\":1774638230733}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "us7D-X0NRlXld6bpCl4epHaruPIWbkI895jFn3o84LY",
|
||||
"value": "TagN0dCg26ZQ9TEQxpQARign6bxZQxrSTZmDtNi288U",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1782155883.187584,
|
||||
"expires": 1782241430.203752,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiNmU0MmNhNGQtMGNkNC00MmUzLWFkOGMtMWYyOTZmMzg5NTE5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0MzgwNzgzfQ.AthhvOE2qsaJo6x37QbGXbT9oIP2pnFks3Pj14MjmcI",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZS1kZWxldGVAdGVzdC5jb20iLCJ1c2VyX2lkIjoiZjI5NDNjZWMtYzNmNS00N2MyLTg0YTItZTI2ODI3NDZlNDY5IiwidG9rZW5fdmVyc2lvbiI6MCwiZXhwIjoxNzc0NDY2MzMwfQ.z0nN78IK3DqAepk20FS5xUzIIt7mZeGtdFhYePAOrxc",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1774379882932}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1774465429926}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1774552683575}"
|
||||
"value": "{\"expiresAt\":1774638230517}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
"cookies": [
|
||||
{
|
||||
"name": "refresh_token",
|
||||
"value": "VqeLgoC9z5NgU2AtA-9xjl4DaRt0hYKDkmAJMN3dcnw",
|
||||
"value": "GiDwb1pZ1oqvwzO16LokRkqIyCZ8nPJLzpzowwZRW-I",
|
||||
"domain": "localhost",
|
||||
"path": "/api/auth",
|
||||
"expires": 1782155879.453865,
|
||||
"expires": 1782241425.893418,
|
||||
"httpOnly": true,
|
||||
"secure": true,
|
||||
"sameSite": "Strict"
|
||||
},
|
||||
{
|
||||
"name": "access_token",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI3ZjA2Njc5OS03NjI1LTQ0ZGQtODIwYi0zNWRjZmFlYWVjNTMiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQzODA3Nzl9.o9urIT0cfexJY8OarcJ4gtJKqN-_DZOw50VToPl25LU",
|
||||
"value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJlbWFpbCI6ImUyZUB0ZXN0LmNvbSIsInVzZXJfaWQiOiI0MzEwZGI1MC04NDJjLTQ2OWQtOTg4YS0wMzFjMTliMWMwM2YiLCJ0b2tlbl92ZXJzaW9uIjowLCJleHAiOjE3NzQ0NjYzMjV9.5fn4UtlACnDCk2-O_n5f3HO9y1ksXDEnQjJl4TdJ-F4",
|
||||
"domain": "localhost",
|
||||
"path": "/",
|
||||
"expires": -1,
|
||||
@@ -27,11 +27,11 @@
|
||||
"localStorage": [
|
||||
{
|
||||
"name": "authSyncEvent",
|
||||
"value": "{\"type\":\"logout\",\"at\":1774379879245}"
|
||||
"value": "{\"type\":\"logout\",\"at\":1774465425688}"
|
||||
},
|
||||
{
|
||||
"name": "parentAuth",
|
||||
"value": "{\"expiresAt\":1774552679625}"
|
||||
"value": "{\"expiresAt\":1774638226066}"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -74,6 +74,15 @@ test.describe('Chore activation', () => {
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [kindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toISOString().slice(0, 10),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
|
||||
@@ -66,7 +66,7 @@ test.describe('Chore assignment', () => {
|
||||
await expect(page.getByRole('button', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
@@ -83,7 +83,7 @@ test.describe('Chore assignment', () => {
|
||||
test('Select chores and submit — items appear in ParentView chores section', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Select two chores
|
||||
@@ -126,7 +126,7 @@ test.describe('Chore assignment', () => {
|
||||
test('Uncheck all chores and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Uncheck the two previously assigned chores
|
||||
@@ -151,7 +151,7 @@ test.describe('Chore assignment', () => {
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Chores' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-chores(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Chores' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
|
||||
@@ -56,7 +56,7 @@ test.describe('Kindness act assignment', () => {
|
||||
await expect(page.getByRole('button', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
@@ -72,7 +72,7 @@ test.describe('Kindness act assignment', () => {
|
||||
test('Select a kindness act and submit — item appears in ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
await page.getByText(KIND1_NAME, { exact: true }).click()
|
||||
@@ -101,7 +101,7 @@ test.describe('Kindness act assignment', () => {
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned item
|
||||
@@ -120,7 +120,7 @@ test.describe('Kindness act assignment', () => {
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Kindness Acts' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-kindness(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Kindness Acts' })).toBeVisible()
|
||||
|
||||
// Check the item
|
||||
|
||||
@@ -66,7 +66,7 @@ test.describe('Penalty assignment', () => {
|
||||
await expect(page.getByRole('button', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe('Penalty assignment', () => {
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Select two penalties
|
||||
@@ -128,7 +128,7 @@ test.describe('Penalty assignment', () => {
|
||||
test('Uncheck all penalties and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned penalties
|
||||
@@ -153,7 +153,7 @@ test.describe('Penalty assignment', () => {
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Penalties' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-penalties(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Penalties' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
|
||||
@@ -62,7 +62,7 @@ test.describe('Reward assignment', () => {
|
||||
await expect(page.getByRole('button', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
@@ -81,7 +81,7 @@ test.describe('Reward assignment', () => {
|
||||
}) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Select two rewards
|
||||
@@ -124,7 +124,7 @@ test.describe('Reward assignment', () => {
|
||||
test('Uncheck all rewards and submit — items are removed from ParentView', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Uncheck the previously assigned rewards
|
||||
@@ -149,7 +149,7 @@ test.describe('Reward assignment', () => {
|
||||
test('Cancel does not persist selection changes', async ({ page }) => {
|
||||
await page.goto(`/parent/${childId}`)
|
||||
await page.getByRole('button', { name: 'Assign Rewards' }).click()
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards$`))
|
||||
await page.waitForURL(new RegExp(`/parent/${childId}/assign-rewards(\\?|$)`))
|
||||
await expect(page.getByRole('heading', { name: 'Assign Rewards' })).toBeVisible()
|
||||
|
||||
// Check an item
|
||||
|
||||
@@ -70,6 +70,15 @@ test.describe('Chore reset', () => {
|
||||
await request.put(`/api/child/${childId}/set-tasks`, {
|
||||
data: { task_ids: [helperKindId], type: 'kindness' },
|
||||
})
|
||||
// Add a schedule so COMPLETED status persists after trigger
|
||||
await request.put(`/api/child/${childId}/task/${choreId}/schedule`, {
|
||||
data: {
|
||||
mode: 'interval',
|
||||
interval_days: 1,
|
||||
anchor_date: new Date().toISOString().slice(0, 10),
|
||||
interval_has_deadline: false,
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
test.afterAll(async ({ request }) => {
|
||||
|
||||
@@ -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