Optimistic Discover decisions and deck refill #108
@@ -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
@@ -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(),
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user