fix(plex): a reshuffle survives a refresh landing mid-deal; the ↻ spins for a deal only

The ↻ intent was spent as its fetch set out, so a watched toggle's resync cancelling that fetch re-ran it as a re-read of the old draw. It now lasts until the latest deal answers. The button spun on every re-read of the same titles; it now follows the deal. Comments that said the Surprise draw is never refetched are brought in line.
This commit is contained in:
2026-10-09 23:46:39 +02:00
parent fc53bc03bc
commit 92a17aba83
4 changed files with 78 additions and 22 deletions
@@ -274,6 +274,52 @@ describe("PlexDiscover", () => {
});
});
// A watched toggle's resync refreshes every rail, the Surprise draw included. Landing while a ↻
// deal was in flight, it cancelled that fetch and re-ran it as a re-read: the old titles came back
// and the ↻ did nothing. And the ↻ spins for a deal only, never for a re-read of the same titles.
it("a refresh landing mid-deal deals again; only a deal spins the ↻", async () => {
plexDiscover.mockResolvedValue(fullPayload);
const answers: ((v: unknown) => void)[] = [];
const rereads: ((v: unknown) => void)[] = [];
plexSurprise.mockImplementation(
(_s: string, _l: unknown, _f: unknown, _lang: string, o: { keep?: string[] }) =>
plexSurprise.mock.calls.length === 1
? Promise.resolve({ scope: "both", items: [card("sp1", "Old Pick")] })
: new Promise((r) => (o.keep ? rereads : answers).push(r)),
);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexDiscover
scope="both"
onOpenCard={noop}
onRestartCard={() => {}}
onInfoCard={noop}
onGenre={noop}
onSeeAllRecent={noop}
/>
</QueryClientProvider>,
);
await screen.findByText("Old Pick");
const button = () => screen.getByRole("button", { name: "plex.discover.reshuffle" });
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
await waitFor(() => expect(rereads).toHaveLength(1));
expect(button().getAttribute("aria-busy")).toBeNull();
await act(async () => rereads[0]?.({ scope: "both", items: [card("sp1", "Old Pick")] }));
fireEvent.click(button());
expect(button().getAttribute("aria-busy")).toBe("true");
act(() => void qc.invalidateQueries({ queryKey: qk.plexDiscover() }));
await waitFor(() => expect(answers).toHaveLength(2));
const last = plexSurprise.mock.calls[plexSurprise.mock.calls.length - 1];
expect(last?.[4]).toEqual({ exclude: ["sp1"] });
// The cancelled deal's late answer neither lands nor stops the spinner of the one replacing it.
await act(async () => answers[0]?.({ scope: "both", items: [card("sp9", "Stale Pick")] }));
expect(button().getAttribute("aria-busy")).toBe("true");
await act(async () => answers[1]?.({ scope: "both", items: [card("sp2", "New Pick")] }));
await screen.findByText("New Pick");
expect(button().getAttribute("aria-busy")).toBeNull();
});
it("shows the empty message when every rail is empty", async () => {
plexDiscover.mockResolvedValue({
scope: "both",
@@ -298,8 +344,8 @@ describe("PlexDiscover", () => {
});
it("puts a Surprise card back when its watched toggle fails", async () => {
// The resync refetches the rails but not the Surprise draw (that would reshuffle it), so a
// refused toggle stayed flipped there — the sibling of the collection page's fix.
// The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
// left flipped until that read lands — the sibling of the collection page's fix.
plexDiscover.mockResolvedValue({
...fullPayload,
continue_watching: [],
+25 -13
View File
@@ -1,4 +1,4 @@
import { useRef, type ReactNode } from "react";
import { useRef, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react";
@@ -125,22 +125,31 @@ export default function PlexDiscover({
// page. So a refetch reads the draw on screen again (`keep`: fresh watch state, same titles), and
// only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
const surpriseKey = qk.plexSurprise(scope, lang, filters);
// The ↻ stands until ITS deal answers, not until the fetch sets out: a refresh of the rails landing
// mid-deal (a watched toggle's resync) cancels that fetch and runs this one again, which read the
// spent flag and handed back the old draw. Only the latest deal ends it, so the cancelled one's late
// answer does not stop the spinner of the deal that replaced it.
const redraw = useRef(false);
const lastDeal = useRef<Promise<unknown> | null>(null);
const [dealing, setDealing] = useState(false);
const surpriseQ = useQuery({
queryKey: surpriseKey,
queryFn: () => {
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
(c) => c.id,
);
const deal = redraw.current || shown.length === 0;
redraw.current = false;
return api.plexSurprise(
scope,
undefined,
filters,
lang,
deal ? { exclude: shown } : { keep: shown },
);
if (!redraw.current && shown.length > 0) {
return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
}
const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
lastDeal.current = deal;
const done = () => {
if (lastDeal.current !== deal) return;
redraw.current = false;
setDealing(false);
};
void deal.then(done, done);
return deal;
},
// Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
// a collected draw is a new one.
@@ -148,6 +157,7 @@ export default function PlexDiscover({
});
const reshuffle = () => {
redraw.current = true;
setDealing(true);
void surpriseQ.refetch();
};
@@ -180,8 +190,8 @@ export default function PlexDiscover({
);
setSurpriseStatus(card, next);
},
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed
// toggle was left flipped there — put back in place instead.
// The resync below re-reads the Surprise draw too, but only once the write has answered: a
// refused toggle is put back at once rather than left flipped until that read lands.
revert: () => setSurpriseStatus(card, card.status),
resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() });
@@ -255,7 +265,9 @@ export default function PlexDiscover({
action={
<IconButton
onClick={reshuffle}
loading={surpriseQ.isFetching}
// A deal, not any fetch: a refresh re-reads the same titles, and a spinning ↻ promised a
// new draw that never came.
loading={dealing}
label={t("plex.discover.reshuffle")}
>
<RotateCcw />