diff --git a/web/src/discover/DiscoverPage.test.tsx b/web/src/discover/DiscoverPage.test.tsx index 193fc41..8e2e31e 100644 --- a/web/src/discover/DiscoverPage.test.tsx +++ b/web/src/discover/DiscoverPage.test.tsx @@ -736,6 +736,41 @@ describe('DiscoverPage', () => { expect(screen.getByRole('heading', { name: 'All caught up' })).toBeInTheDocument() }) + it('keeps a card off the deck when a read during its decision left it out and the answer is lost, and reads the deck again', async () => { + let lose!: (error: Error) => void + const accept = new Promise((_, reject) => (lose = reject)) + let late: ReturnType | null = null + const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], { + 'POST /api/v1/suggestions/s1/accept': () => accept, + [`GET ${DECK}`]: () => late?.promise ?? jsonResponse(200, { suggestions: server.deck }), + }) + const queryClient = createQueryClient() + await renderPage(queryClient) + await screen.findByRole('article', { name: 'Korean beef bowls' }) + + fireEvent.click(screen.getByRole('button', { name: 'Try it' })) + await waitFor(() => expect(sent(server.fetchMock)).toEqual(['POST /api/v1/suggestions/s1/accept'])) + + // The decision is saved, and a read of the deck started during it + // answers without the Side quest; then the answer to the decision is + // lost on the way back. + late = deferred() + const delayed = late + await act(async () => void queryClient.invalidateQueries({ queryKey: DECK_KEY })) + late = null + server.deck = [TIKKA, ORZO, PAELLA] + await act(async () => delayed.resolve(jsonResponse(200, { suggestions: server.deck }))) + const reads = deckReads(server.fetchMock) + await act(async () => lose(new TypeError('Failed to fetch'))) + + await screen.findByRole('alert') + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + // The decision may have been saved: the deck is read again. + await waitFor(() => expect(deckReads(server.fetchMock)).toBe(reads + 1)) + expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument() + expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala') + }) + it('reads the deck again once a saved decision leaves fewer than 3 cards', async () => { const server = stubServer([BOWLS, TIKKA, ORZO], { 'POST /api/v1/suggestions/s1/decline': () => { diff --git a/web/src/discover/api.ts b/web/src/discover/api.ts index 3bb846c..c169e7d 100644 --- a/web/src/discover/api.ts +++ b/web/src/discover/api.ts @@ -1,5 +1,5 @@ -import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useApi } from '../api/index.ts' +import { hashKey, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { ApiError, useApi } from '../api/index.ts' import { isRecord } from '../api/errors.ts' import { DIFFICULTIES, DINNERS_PATH, oneOf, photoId, type Difficulty } from '../dinners/api.ts' @@ -137,9 +137,14 @@ export function useStartBatch() { } // Decided is the context a decision's optimistic update leaves for its -// rollback: whether the Side quest was taken off the deck, and the mark of -// the Quest log entry it added, if it added one. -type Decided = { taken: boolean; adding: symbol | null } +// rollback: whether the Side quest was taken off the deck, the mark of the +// Quest log entry it added, if it added one, and what the reads of the deck +// that landed since have said of the Side quest. +type Decided = { taken: boolean; adding: symbol | null; reads: DeckReads } + +// DeckReads follows the reads of the deck that land while a decision is in +// flight: gone is whether the latest left the Side quest out. +type DeckReads = { gone: boolean; stop: () => void } // useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass // (POST /api/v1/suggestions/{id}/decline) on the Side quest the household @@ -150,11 +155,13 @@ type Decided = { taken: boolean; adding: symbol | null } // The decision is applied at once: the Side quest leaves the deck and, on // Try it, its dinner joins the Quest log unless it is there already. A // refused decision puts the Side quest back on top of the deck if it took -// it off, takes out of the Quest log only the entry it added, and reads the -// Quest log again, since the decision may have been saved all the same. A -// saved one reads the Quest log again, and the deck too once fewer than -// REFILL_BELOW Side quests are left in it or a read of the deck started -// before the decision was saved. +// it off and no read of the deck since has left it out, takes out of the +// Quest log only the entry it added, and reads the Quest log again, since +// the decision may have been saved all the same. A failure without an +// answer from the server, which may have saved it, reads the deck again +// too. A saved one reads the Quest log again, and the deck too once fewer +// than REFILL_BELOW Side quests are left in it or a read of the deck +// started before the decision was saved. export function useDecide() { const { apiFetch } = useApi() const queryClient = useQueryClient() @@ -183,17 +190,26 @@ export function useDecide() { const deck = queryClient.getQueryData(DECK_KEY) const taken = deck !== undefined && deck.some((q) => q.id === quest.id) if (taken) queryClient.setQueryData(DECK_KEY, deck.filter((q) => q.id !== quest.id)) + const reads: DeckReads = { gone: false, stop: () => {} } + const deckHash = hashKey(DECK_KEY) + reads.stop = queryClient.getQueryCache().subscribe((event) => { + if (event.type !== 'updated' || event.query.queryHash !== deckHash || event.action.type !== 'success' || event.action.manual) return + reads.gone = !(event.action.data as SideQuest[]).some((q) => q.id === quest.id) + }) // A stale deck can offer a Side quest whose dinner a read of the Quest // log already has. const dinners = queryClient.getQueryData(QUEST_LOG_KEY) - if (choice !== 'try' || dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)) return { taken, adding: null } + if (choice !== 'try' || dinners === undefined || dinners.some((d) => d.id === quest.dinner_id)) return { taken, adding: null, reads } const adding = Symbol(quest.dinner_id) queryClient.setQueryData(QUEST_LOG_KEY, dinners.concat({ id: quest.dinner_id, name: quest.name, adding })) - return { taken, adding } + return { taken, adding, reads } }, - onError: (_error, { quest }, context) => { - const { taken, adding } = context! - if (taken) queryClient.setQueryData(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)]) + onError: (error, { quest }, context) => { + const { taken, adding, reads } = context! + if (taken && !reads.gone) queryClient.setQueryData(DECK_KEY, (deck) => deck && [quest, ...deck.filter((q) => q.id !== quest.id)]) + // An API error answer is the server refusing the decision; anything + // else may have come after it saved it. + if (!(error instanceof ApiError)) void queryClient.invalidateQueries({ queryKey: DECK_KEY }) if (adding === null) return // A read of the Quest log that landed meanwhile has replaced the entry. queryClient.setQueryData(QUEST_LOG_KEY, (dinners) => dinners?.filter((d) => d.adding !== adding)) @@ -210,5 +226,6 @@ export function useDecide() { queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY }), ]) }, + onSettled: (_data, _error, _variables, context) => context?.reads.stop(), }) }