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', }) }