Cancel an in-flight Recent updates read when the sheet closes
CI / ci (push) Successful in 9m24s
CI / ci (pull_request) Successful in 9m0s

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 <noreply@anthropic.com>
This commit is contained in:
2026-10-10 15:07:37 -04:00
co-authored by Claude Opus 5.5
parent 6956f6f049
commit d9b63bd08c
2 changed files with 56 additions and 3 deletions
+51
View File
@@ -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<Response>((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'],
+5 -3
View File
@@ -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<unknown>(UPDATES_PATH), window.location.origin),
queryFn: async ({ signal }) =>
parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch<unknown>(UPDATES_PATH, { signal }), window.location.origin),
refetchOnMount: 'always',
})
}