Optimistic Discover decisions and deck refill #108

2 changed files with 67 additions and 15 deletions
Showing only changes of commit 1c59e5d9d7 - Show all commits
+35
View File
@@ -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<Response>((_, reject) => (lose = reject))
let late: ReturnType<typeof deferred> | 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': () => {
+32 -15
View File
@@ -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<SideQuest[]>(DECK_KEY)
const taken = deck !== undefined && deck.some((q) => q.id === quest.id)
if (taken) queryClient.setQueryData<SideQuest[]>(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<QuestLogDinner[]>(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<QuestLogDinner[]>(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<SideQuest[]>(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<SideQuest[]>(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<QuestLogDinner[]>(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(),
})
}