Refetch Rotation checks when a notification focuses Home already open (Feature 21)
A notification tap that focuses a window already at its url does not navigate, so Home does not remount and a recently read empty list stays within the query client's staleTime. The service worker now posts that window NotificationOpened, and useRotationReviews refetches on it, so the Keep it in rotation card shows without a remount. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
|
||||
import { createMemoryRouter, RouterProvider } from 'react-router'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { clickPath, parsePushPayload } from '../push/notification.ts'
|
||||
import { clickPath, openNotification, parsePushPayload, type ClickWindow } from '../push/notification.ts'
|
||||
import { QueryClientProvider } from '@tanstack/react-query'
|
||||
import { ApiProvider, createApiClient, createQueryClient, createVerifiedSession, ME_PATH } from '../api/index.ts'
|
||||
import { createNetworkStatus } from '../pwa/networkStatus.ts'
|
||||
@@ -19,6 +19,7 @@ const RAMEN = { dinner: { id: 'd8', name: 'Miso ramen', photo: null }, plan_date
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
Reflect.deleteProperty(navigator, 'serviceWorker')
|
||||
})
|
||||
|
||||
type Deferred = { resolve: (response: Response) => void; path: string; body: unknown }
|
||||
@@ -53,7 +54,7 @@ function stubApi(reviews: (typeof SHAKSHUKA | typeof RAMEN)[]) {
|
||||
throw new Error(`unexpected fetch: ${init?.method ?? 'GET'} ${path}`)
|
||||
})
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
return { fetchMock, decisions }
|
||||
return { fetchMock, decisions, server }
|
||||
}
|
||||
|
||||
// decided is the server's answer to a decision on review.
|
||||
@@ -244,4 +245,37 @@ describe('RotationCheck', () => {
|
||||
await renderHome(path)
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the card when a rotation_review notification focuses Home already open, without a remount', async () => {
|
||||
// navigator.serviceWorker is where the worker's postMessage to this
|
||||
// window arrives.
|
||||
const container = new EventTarget()
|
||||
Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: container })
|
||||
const { fetchMock, server } = stubApi([])
|
||||
const reads = () => fetchMock.mock.calls.filter(([path]) => path === ROTATION_REVIEWS_PATH).length
|
||||
await renderHome('/')
|
||||
const home = await screen.findByRole('heading', { name: 'Pizza night' })
|
||||
await waitFor(() => expect(reads()).toBe(1))
|
||||
expect(screen.queryByRole('region', { name: 'Keep it in rotation?' })).not.toBeInTheDocument()
|
||||
|
||||
// Within the cached list's staleTime the Rotation check comes due and
|
||||
// its notification is tapped while Home is open at /.
|
||||
server.push(SHAKSHUKA)
|
||||
const origin = 'https://mealquest.test'
|
||||
const win = {
|
||||
url: `${origin}/`,
|
||||
focused: true,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: (message) => container.dispatchEvent(new MessageEvent('message', { data: message })),
|
||||
} satisfies ClickWindow
|
||||
await act(() =>
|
||||
openNotification({ url: '/' }, { matchAll: async () => [win], openWindow: vi.fn(async () => null) }, origin),
|
||||
)
|
||||
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(within(await card()).getByText('Last night: Shakshuka')).toBeInTheDocument()
|
||||
expect(reads()).toBe(2)
|
||||
expect(screen.getByRole('heading', { name: 'Pizza night' })).toBe(home)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect } from 'react'
|
||||
import { useApi } from '../api/index.ts'
|
||||
import { isRecord } from '../api/errors.ts'
|
||||
import { photoId } from '../dinners/api.ts'
|
||||
import { isNotificationOpened } from '../push/notification.ts'
|
||||
|
||||
export const ROTATION_REVIEWS_PATH = '/api/v1/rotation-reviews'
|
||||
export const ROTATION_REVIEWS_KEY = ['rotation-reviews'] as const
|
||||
@@ -43,16 +45,29 @@ export function parseRotationReviews(where: string, body: unknown): RotationRevi
|
||||
|
||||
// useRotationReviews is the pending Rotation checks (GET
|
||||
// /api/v1/rotation-reviews), oldest plan date first. Each mount of Home
|
||||
// reads them afresh, so Home opened from a rotation_review notification
|
||||
// shows the check it is about.
|
||||
// reads them afresh, and so does a notification tap that focuses Home
|
||||
// already open (the service worker's NotificationOpened), so Home opened
|
||||
// from a rotation_review notification shows the check it is about.
|
||||
export function useRotationReviews() {
|
||||
const { apiFetch } = useApi()
|
||||
return useQuery({
|
||||
const query = useQuery({
|
||||
queryKey: ROTATION_REVIEWS_KEY,
|
||||
queryFn: async () =>
|
||||
parseRotationReviews(`GET ${ROTATION_REVIEWS_PATH}`, await apiFetch<unknown>(ROTATION_REVIEWS_PATH)),
|
||||
refetchOnMount: 'always',
|
||||
})
|
||||
const { refetch } = query
|
||||
useEffect(() => {
|
||||
// Without a service worker there are no notifications to tap.
|
||||
if (!('serviceWorker' in navigator)) return
|
||||
const container = navigator.serviceWorker
|
||||
const onMessage = (event: MessageEvent<unknown>) => {
|
||||
if (isNotificationOpened(event.data)) void refetch()
|
||||
}
|
||||
container.addEventListener('message', onMessage)
|
||||
return () => container.removeEventListener('message', onMessage)
|
||||
}, [refetch])
|
||||
return query
|
||||
}
|
||||
|
||||
// useDecideRotationReview answers "Keep it in rotation?" for a pending
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
GENERIC_NOTICE,
|
||||
appPath,
|
||||
clickPath,
|
||||
isNotificationOpened,
|
||||
openNotification,
|
||||
parsePushPayload,
|
||||
planClick,
|
||||
@@ -178,6 +179,7 @@ describe('openNotification', () => {
|
||||
focused,
|
||||
focus: vi.fn(async () => {}),
|
||||
navigate: vi.fn(async () => {}),
|
||||
postMessage: vi.fn(),
|
||||
} satisfies ClickWindow
|
||||
}
|
||||
|
||||
@@ -193,6 +195,18 @@ describe('openNotification', () => {
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).toHaveBeenCalledWith(`${origin}/plan/shopping`)
|
||||
expect(win.focus.mock.invocationCallOrder[0]).toBeLessThan(win.navigate.mock.invocationCallOrder[0]!)
|
||||
expect(win.postMessage).not.toHaveBeenCalled()
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('focuses a window already at the url and tells it NotificationOpened instead of navigating', async () => {
|
||||
const win = window('/', true)
|
||||
const all = clients([win])
|
||||
await openNotification({ url: '/' }, all, origin)
|
||||
expect(win.focus).toHaveBeenCalled()
|
||||
expect(win.navigate).not.toHaveBeenCalled()
|
||||
expect(win.postMessage).toHaveBeenCalledWith({ type: 'NOTIFICATION_OPENED', url: `${origin}/` })
|
||||
expect(isNotificationOpened(win.postMessage.mock.calls[0]![0])).toBe(true)
|
||||
expect(all.openWindow).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -150,6 +150,18 @@ export function planClick<W extends AppWindow>(windows: readonly W[], path: stri
|
||||
export type ClickWindow = AppWindow & {
|
||||
focus(): Promise<unknown>
|
||||
navigate(url: string): Promise<unknown>
|
||||
postMessage(message: NotificationOpened): void
|
||||
}
|
||||
|
||||
// NotificationOpened is the message a tap posts to an open window it focuses
|
||||
// without navigating, because the window is already at url: nothing remounts
|
||||
// there, so the screen reads afresh what the notification is about.
|
||||
export type NotificationOpened = { type: 'NOTIFICATION_OPENED'; url: string }
|
||||
|
||||
// isNotificationOpened reports whether a message from the service worker is
|
||||
// NotificationOpened.
|
||||
export function isNotificationOpened(message: unknown): message is NotificationOpened {
|
||||
return isRecord(message) && message.type === 'NOTIFICATION_OPENED' && typeof message.url === 'string'
|
||||
}
|
||||
|
||||
export type ClickClients<W extends ClickWindow> = {
|
||||
@@ -158,7 +170,8 @@ export type ClickClients<W extends ClickWindow> = {
|
||||
}
|
||||
|
||||
// openNotification carries out a tap on a notification with data: focus
|
||||
// first, while the tap still allows it, then navigate.
|
||||
// first, while the tap still allows it, then navigate; a window already at
|
||||
// the url is told NotificationOpened instead.
|
||||
export async function openNotification<W extends ClickWindow>(
|
||||
data: unknown,
|
||||
clients: ClickClients<W>,
|
||||
@@ -171,4 +184,5 @@ export async function openNotification<W extends ClickWindow>(
|
||||
}
|
||||
await plan.window.focus()
|
||||
if (plan.navigate) await plan.window.navigate(plan.url)
|
||||
else plan.window.postMessage({ type: 'NOTIFICATION_OPENED', url: plan.url })
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user