Settle a failed Discover decision on the deck's newest read
A failed decision still waits for the deck read it makes itself, but no longer carries that read's answer on its error. Whether its Side quest is still pending is now asked of the deck query's newest successful read (stillPending), both when the decision settles and when the page offers Retry. A read that lands later and has the Side quest decided keeps it off the deck and withdraws the Retry; a deck whose latest read failed offers no Retry. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -948,6 +948,60 @@ describe('DiscoverPage', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not put a failed decision back on the deck when a later read, landing before it settles, has it decided', async () => {
|
||||
let log: ReturnType<typeof deferred> | null = null
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||
'POST /api/v1/suggestions/s1/accept': () => {
|
||||
log = deferred()
|
||||
return errorResponse(500, 'internal')
|
||||
},
|
||||
[`GET ${QUEST_LOG}`]: () => log?.promise ?? jsonResponse(200, { dinners: [] }),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
// The decision fails; the deck read after it still has the Side quest
|
||||
// pending, and the Quest log read after it is slow.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||
await waitFor(() => expect(log).not.toBeNull())
|
||||
const slow = log!
|
||||
log = null
|
||||
await waitFor(() => expect(deckReads(server.fetchMock)).toBe(2))
|
||||
|
||||
// Another session decides it, and a read of the deck lands meanwhile.
|
||||
server.deck = [TIKKA, ORZO, PAELLA]
|
||||
await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY }))
|
||||
await act(async () => slow.resolve(jsonResponse(200, { dinners: [] })))
|
||||
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(screen.queryByRole('article', { name: 'Korean beef bowls' })).not.toBeInTheDocument()
|
||||
expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala')
|
||||
expect(alert).toHaveTextContent('That side quest has been decided already.')
|
||||
expect(within(alert).queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('withdraws the Retry for a failed decision once a later read of the deck has it decided', async () => {
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||
'POST /api/v1/suggestions/s1/accept': () => errorResponse(500, 'internal'),
|
||||
})
|
||||
const queryClient = createQueryClient()
|
||||
await renderPage(queryClient)
|
||||
await screen.findByRole('article', { name: 'Korean beef bowls' })
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Try it' }))
|
||||
const alert = await screen.findByRole('alert')
|
||||
expect(within(alert).getByRole('button', { name: 'Retry' })).toBeInTheDocument()
|
||||
expect(topCard()).toHaveAccessibleName('Korean beef bowls')
|
||||
|
||||
// Another session decides it, and the next read of the deck says so.
|
||||
server.deck = [TIKKA, ORZO, PAELLA]
|
||||
await act(async () => await queryClient.refetchQueries({ queryKey: DECK_KEY }))
|
||||
await waitFor(() => expect(topCard()).toHaveAccessibleName('Crock pot chicken tikka masala'))
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('That side quest has been decided already.')
|
||||
expect(screen.queryByRole('button', { name: 'Retry' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('reads the deck again after a 500 to a decision, rather than putting the card back', async () => {
|
||||
const decline = deferred()
|
||||
const server = stubServer([BOWLS, TIKKA, ORZO, PAELLA], {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
BATCH_JOB_KIND,
|
||||
DECK_KEY,
|
||||
parseBatchResult,
|
||||
stillPending,
|
||||
useDeck,
|
||||
useDecide,
|
||||
useQuestLog,
|
||||
@@ -90,8 +91,8 @@ function QuestLogPill({ dinners, ref }: { dinners: { id: string; name: string }[
|
||||
// at a time from the top card. A decision from the buttons, the ← / → keys
|
||||
// or a swipe of the top card throws it out (motion.ts) and sends the
|
||||
// decision once it is gone, taking the card off the deck at once (see
|
||||
// useDecide); Retry sends a failed one again while the deck it read after
|
||||
// failing still had its Side quest pending. Input is locked from the press
|
||||
// useDecide); Retry sends a failed one again while the deck's newest read
|
||||
// still has its Side quest pending. Input is locked from the press
|
||||
// until the decision settles, which is once the deck and the Quest log have
|
||||
// been read again after it, so no decision starts under those reads. The lock is a
|
||||
// ref, set before anything starts, so a second press before the next render
|
||||
@@ -322,10 +323,9 @@ export function DiscoverPage() {
|
||||
.then(() => (after ? queryClient.invalidateQueries({ queryKey: DECK_KEY }, { cancelRefetch: false }) : undefined))
|
||||
}, [queryClient, job.status, stored])
|
||||
|
||||
// A failed decision can be retried only while the deck it read after
|
||||
// failing still has its Side quest pending: anything else decided it
|
||||
// already.
|
||||
const pending = decision.isError ? decision.error.pending : undefined
|
||||
// A failed decision can be retried only while the deck's newest read
|
||||
// still has its Side quest pending: anything else decided it already.
|
||||
const pending = decision.isError ? stillPending(deck, decision.variables.quest.id) : undefined
|
||||
const retryable = pending !== undefined && pending !== null
|
||||
|
||||
let body
|
||||
|
||||
+24
-29
@@ -130,18 +130,13 @@ export function useStartBatch() {
|
||||
})
|
||||
}
|
||||
|
||||
// DecisionError is a failed decision, with what the deck read after it
|
||||
// found: pending is its Side quest while the server still has it pending,
|
||||
// null once it is decided, and undefined when that read failed too (the
|
||||
// deck query holds the read's error).
|
||||
export class DecisionError extends Error {
|
||||
readonly pending: SideQuest | null | undefined
|
||||
|
||||
constructor(cause: unknown, pending: SideQuest | null | undefined) {
|
||||
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
||||
this.name = 'DecisionError'
|
||||
this.pending = pending
|
||||
}
|
||||
// stillPending says whether the deck's newest read has the Side quest id
|
||||
// pending, after a failed decision on it: the Side quest when it does, null
|
||||
// when it does not, and undefined when that read failed, which says neither.
|
||||
// deck is the deck query's state, from the cache or useDeck.
|
||||
export function stillPending(deck: { status: string; data: SideQuest[] | undefined } | undefined, id: string): SideQuest | null | undefined {
|
||||
if (deck?.status !== 'success') return undefined
|
||||
return deck.data!.find((q) => q.id === id) ?? null
|
||||
}
|
||||
|
||||
// useDecide records Try it (POST /api/v1/suggestions/{id}/accept) or Pass
|
||||
@@ -158,9 +153,10 @@ export class DecisionError extends Error {
|
||||
// (which DiscoverPage locks out until then) cannot start under them. A read
|
||||
// under way when the decision ends is replaced by the read made after it,
|
||||
// and DiscoverPage's batch reads wait for that one rather than replace it.
|
||||
// A failed decision is a DecisionError, settled on the read it made and
|
||||
// waited for itself: a Side quest that read still has is still pending, and
|
||||
// goes back on top of the deck.
|
||||
// A failed decision waits for the deck read it makes itself. Whether its
|
||||
// Side quest is still pending is then the deck query's to say, from its
|
||||
// newest successful read: once the reads have landed, a Side quest the deck
|
||||
// still has goes back on top of it.
|
||||
export function useDecide() {
|
||||
const { apiFetch } = useApi()
|
||||
const queryClient = useQueryClient()
|
||||
@@ -184,21 +180,20 @@ export function useDecide() {
|
||||
return body.replenish_job_id
|
||||
}
|
||||
|
||||
return useMutation<string | null, DecisionError, { quest: SideQuest; choice: Choice }>({
|
||||
return useMutation<string | null, Error, { quest: SideQuest; choice: Choice }>({
|
||||
mutationFn: async ({ quest, choice }) => {
|
||||
try {
|
||||
return await send(quest.id, choice)
|
||||
} catch (error) {
|
||||
// The deck is read again as the decision's own fetch, replacing any
|
||||
// read already out; its answer, not the cache's, settles it.
|
||||
let read: SideQuest[]
|
||||
try {
|
||||
read = await queryClient.getQueryCache().build<SideQuest[]>(queryClient, { queryKey: DECK_KEY }).fetch(undefined, { cancelRefetch: true })
|
||||
} catch {
|
||||
// The deck query holds the read's error, shown with the deck.
|
||||
throw new DecisionError(error, undefined)
|
||||
}
|
||||
throw new DecisionError(error, read.find((q) => q.id === quest.id) ?? null)
|
||||
// read already out. A failed read is the deck query's error, shown
|
||||
// with the deck; the decision's own error is the one thrown.
|
||||
await queryClient
|
||||
.getQueryCache()
|
||||
.build<SideQuest[]>(queryClient, { queryKey: DECK_KEY })
|
||||
.fetch(undefined, { cancelRefetch: true })
|
||||
.catch(() => undefined)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
onMutate: ({ quest, choice }) => {
|
||||
@@ -209,16 +204,16 @@ export function useDecide() {
|
||||
dinners === undefined || dinners.some((d) => d.id === quest.dinner_id) ? dinners : dinners.concat({ id: quest.dinner_id, name: quest.name }),
|
||||
)
|
||||
},
|
||||
onSettled: async (_replenishJobId, error) => {
|
||||
onSettled: async (_replenishJobId, error, { quest }) => {
|
||||
if (error === null) {
|
||||
await Promise.all([queryClient.invalidateQueries({ queryKey: DECK_KEY }), queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })])
|
||||
return
|
||||
}
|
||||
// A failed decision has read the deck already.
|
||||
// A failed decision has read the deck already; any read since is newer.
|
||||
await queryClient.invalidateQueries({ queryKey: QUEST_LOG_KEY })
|
||||
const { pending } = error
|
||||
const pending = stillPending(queryClient.getQueryState<SideQuest[]>(DECK_KEY), quest.id)
|
||||
if (pending === null || pending === undefined) return
|
||||
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => [pending, ...(deck ?? []).filter((q) => q.id !== pending.id)])
|
||||
queryClient.setQueryData<SideQuest[]>(DECK_KEY, (deck) => [pending, ...deck!.filter((q) => q.id !== pending.id)])
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user