diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx
index 16dc58ef..1e4fe2f4 100644
--- a/frontend/src/components/PlexDiscover.dom.test.tsx
+++ b/frontend/src/components/PlexDiscover.dom.test.tsx
@@ -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(
+
+ {}}
+ onInfoCard={noop}
+ onGenre={noop}
+ onSeeAllRecent={noop}
+ />
+ ,
+ );
+ 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: [],
diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx
index 9278f657..520abc4d 100644
--- a/frontend/src/components/PlexDiscover.tsx
+++ b/frontend/src/components/PlexDiscover.tsx
@@ -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 | 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={
diff --git a/frontend/src/lib/plexWatch.ts b/frontend/src/lib/plexWatch.ts
index ed3b2139..a7511e6c 100644
--- a/frontend/src/lib/plexWatch.ts
+++ b/frontend/src/lib/plexWatch.ts
@@ -7,10 +7,9 @@ import { notifyActionError } from "./actionErrors";
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the
// browse grid and the person page, each of which owns a different cache shape.
//
-// `revert` is for state `resync` cannot correct, called on a failure only to put the card back: a
-// cache it must NOT refetch — a random draw (Discover's Surprise rail), which a refetch would
-// reshuffle under the viewer — or a component's local copy of the rows (a Plex playlist), which an
-// identical refetch never re-seeds (react-query keeps the same `data`).
+// `revert` is called on a failure only, to put the card back where `resync` does not, or not at once:
+// a component's local copy of the rows (a Plex playlist), which an identical refetch never re-seeds
+// (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
export async function toggleWatchState(
card: PlexCard,
t: TFunction,
diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts
index 78c0edb1..a3d9db4f 100644
--- a/frontend/src/lib/queryKeys.ts
+++ b/frontend/src/lib/queryKeys.ts
@@ -118,9 +118,8 @@ export const qk = {
plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
- // Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches
- // both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle
- // refetches it alone.
+ // Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches it and
+ // the Surprise draw below for a prefix invalidation after a watched-toggle.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre
// filter narrows against the language-specific genre column, like the grid). UNDER the discover root,