From d9b63bd08c7882f37ffa9fbd53d2b0aff45254d3 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 15:07:37 -0400 Subject: [PATCH] Cancel an in-flight Recent updates read when the sheet closes The updates query now passes TanStack Query's abort signal to apiFetch, so closing the sheet while its read is pending aborts that GET. An immediate reopen then sends its own read instead of adopting the one sent before a newer event was stored, and marks seen only what that read returned. Closes MQ-176-01 (task 96e2dc3f-1f8a-4114-bcb5-e0f93c349165). Co-Authored-By: Claude Opus 5.5 --- web/src/home/UpdatesSheet.test.tsx | 51 ++++++++++++++++++++++++++++++ web/src/home/api.ts | 8 +++-- 2 files changed, 56 insertions(+), 3 deletions(-) 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', }) }