diff --git a/web/src/home/UpdatesSheet.test.tsx b/web/src/home/UpdatesSheet.test.tsx index cf06a14..aba8a02 100644 --- a/web/src/home/UpdatesSheet.test.tsx +++ b/web/src/home/UpdatesSheet.test.tsx @@ -257,6 +257,57 @@ describe('Recent updates sheet', () => { ) }) + it('closing the sheet while its first read is in flight cancels that read, and a reopen reads afresh', async () => { + const stored = [...UPDATES.updates] + let firstAborted = false + let answerFirst: (() => void) | undefined + let reads = 0 + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: (init) => { + const body = { updates: [...stored] } + reads++ + if (reads > 1) return jsonResponse(200, body) + // The first reading is held open, and fails as fetch does if it is + // aborted. + return new Promise((resolve, reject) => { + answerFirst = () => resolve(jsonResponse(200, body)) + init?.signal?.addEventListener('abort', () => { + firstAborted = true + reject(init.signal!.reason as Error) + }) + }) + }, + }) + await renderHome() + await openSheet() + await waitFor(() => expect(updatesReads(fetchMock)).toBe(1)) + + // Closed before the first read answers, a new event stored, and opened + // again at once. + fireEvent.keyDown(screen.getByRole('dialog'), { key: 'Escape' }) + const reminder = { + type: 'day_of_reminder', + title: 'Tacos tonight', + body: 'Dinner at 6:00.', + url: '/plan', + created_at: new Date().toISOString(), + } + stored.unshift(reminder) + fireEvent.click(screen.getByRole('button', { name: /^Recent updates/ })) + const sheet = screen.getByRole('dialog', { name: 'Recent updates' }) + + // The first read was cancelled, and the reopen sent its own. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(firstAborted).toBe(true) + // Answering the cancelled read changes nothing. + answerFirst!() + + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => expect(posts(fetchMock)).toEqual([JSON.stringify({ through: reminder.created_at })])) + }) + it.each([ [0, '/plan'], [1, '/discover'], diff --git a/web/src/home/api.ts b/web/src/home/api.ts index 5e6f2f9..71625f5 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -146,13 +146,15 @@ export function parseUpdates(where: string, body: unknown, origin: string): Upda // useUpdates is the Recent updates (GET /api/v1/updates): the last ten // notification events, newest first. Each opening of the sheet reads it // afresh (refetchOnMount 'always'), whatever the query client still holds; -// the sheet shows, and marks seen, only the list that opening read. +// the sheet shows, and marks seen, only the list that opening read. Closing +// the sheet aborts a read still in flight, so a reopen sends its own rather +// than adopt one sent before it. export function useUpdates() { const { apiFetch } = useApi() return useQuery({ queryKey: UPDATES_KEY, - queryFn: async () => - parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH), window.location.origin), + queryFn: async ({ signal }) => + parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH, { signal }), window.location.origin), refetchOnMount: 'always', }) }