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 <noreply@anthropic.com>
This commit is contained in:
@@ -84,6 +84,12 @@ function homeReads(fetchMock: Mock<typeof fetch>) {
|
||||
.length
|
||||
}
|
||||
|
||||
function updatesReads(fetchMock: Mock<typeof fetch>) {
|
||||
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<Response>((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'],
|
||||
|
||||
@@ -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 }) {
|
||||
<ErrorBanner onRetry={() => void updates.refetch()}>Couldn't load your updates. {updates.error.message}</ErrorBanner>
|
||||
)
|
||||
}
|
||||
if (updates.isPending) {
|
||||
if (!fresh) {
|
||||
return (
|
||||
<div aria-busy="true" aria-label="Updates loading" className={styles.rows}>
|
||||
{Array.from({ length: 3 }, (_, i) => (
|
||||
|
||||
+4
-4
@@ -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<unknown>(UPDATES_PATH), window.location.origin),
|
||||
gcTime: 0,
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user