From 6956f6f049229173ae2ba0ad03a2ea495d22b627 Mon Sep 17 00:00:00 2001 From: dev_agent Date: Sat, 10 Oct 2026 14:17:07 -0400 Subject: [PATCH] Read Recent updates afresh on every opening before marking them seen gcTime 0 only schedules removal of the cached list, so an immediate reopen within the query client's staleTime reused it and marked the old newest time seen. useUpdates now refetches on every mount, and the sheet shows and marks seen only a list read after it opened. Closes MQ-176-01. Co-Authored-By: Claude Opus 5.5 --- web/src/home/UpdatesSheet.test.tsx | 56 ++++++++++++++++++++++++++++++ web/src/home/UpdatesSheet.tsx | 7 ++-- web/src/home/api.ts | 8 ++--- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/web/src/home/UpdatesSheet.test.tsx b/web/src/home/UpdatesSheet.test.tsx index e825ca9..cf06a14 100644 --- a/web/src/home/UpdatesSheet.test.tsx +++ b/web/src/home/UpdatesSheet.test.tsx @@ -84,6 +84,12 @@ function homeReads(fetchMock: Mock) { .length } +function updatesReads(fetchMock: Mock) { + return fetchMock.mock.calls.filter( + ([input, init]) => String(input) === UPDATES_PATH && (init?.method ?? 'GET') === 'GET', + ).length +} + // Where shows the route a row went to. function Where() { const location = useLocation() @@ -201,6 +207,56 @@ describe('Recent updates sheet', () => { expect(screen.queryByTestId('unread-dot')).not.toBeInTheDocument() }) + it('on an immediate reopen waits for that opening’s read, and marks the newer event seen, not the list held from before', async () => { + const stored = [...UPDATES.updates] + let reads = 0 + let answer: (() => void) | undefined + const fetchMock = stubServer({ + [`GET ${UPDATES_PATH}`]: () => { + const body = { updates: [...stored] } + reads++ + // Every reading after the first is held until the test answers it. + if (reads === 1) return jsonResponse(200, body) + return new Promise((resolve) => (answer = () => resolve(jsonResponse(200, body)))) + }, + }) + await renderHome() + await rows(await openSheet()) + await waitFor(() => expect(posts(fetchMock)).toHaveLength(1)) + + // Closed and opened again at once, before any cache-collection timer + // could run, with a new event stored in between. + 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 list held from the first opening is neither shown nor marked + // seen while this opening's read is in flight. + await waitFor(() => expect(updatesReads(fetchMock)).toBe(2)) + expect(within(sheet).getByLabelText('Updates loading')).toBeInTheDocument() + expect(within(sheet).queryByRole('list')).not.toBeInTheDocument() + expect(posts(fetchMock)).toHaveLength(1) + + answer!() + const listed = await rows(sheet) + expect(listed).toHaveLength(4) + expect(listed[0]).toHaveTextContent('Tacos tonight') + await waitFor(() => + expect(posts(fetchMock)).toEqual([ + JSON.stringify({ through: UPDATES.updates[0]!.created_at }), + JSON.stringify({ through: reminder.created_at }), + ]), + ) + }) + it.each([ [0, '/plan'], [1, '/discover'], diff --git a/web/src/home/UpdatesSheet.tsx b/web/src/home/UpdatesSheet.tsx index cb31bb9..2c000c2 100644 --- a/web/src/home/UpdatesSheet.tsx +++ b/web/src/home/UpdatesSheet.tsx @@ -44,7 +44,10 @@ function Updates({ onOpen }: { onOpen: (url: string) => void }) { const updates = useUpdates() const markSeen = useMarkUpdatesSeen() const { mutate } = markSeen - const newest = updates.data?.[0]?.created_at + // fresh says this opening's read has come back; until then a list still + // held from an earlier opening is neither shown nor marked seen. + const fresh = updates.isSuccess && updates.isFetchedAfterMount + const newest = fresh ? updates.data[0]?.created_at : undefined // The instant the relative times count from, fixed while the sheet is // open. const [now] = useState(() => new Date()) @@ -58,7 +61,7 @@ function Updates({ onOpen }: { onOpen: (url: string) => void }) { void updates.refetch()}>Couldn't load your updates. {updates.error.message} ) } - if (updates.isPending) { + if (!fresh) { return (
{Array.from({ length: 3 }, (_, i) => ( diff --git a/web/src/home/api.ts b/web/src/home/api.ts index 19f60a1..5e6f2f9 100644 --- a/web/src/home/api.ts +++ b/web/src/home/api.ts @@ -144,16 +144,16 @@ 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. The list is dropped as soon as the -// sheet showing it closes (gcTime 0), so each opening reads it afresh and -// never shows, or marks seen, a list read before an event stored since. +// 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. export function useUpdates() { const { apiFetch } = useApi() return useQuery({ queryKey: UPDATES_KEY, queryFn: async () => parseUpdates(`GET ${UPDATES_PATH}`, await apiFetch(UPDATES_PATH), window.location.origin), - gcTime: 0, + refetchOnMount: 'always', }) }