From 785080f3da0064ca5c27ec604739b31f32ab0f03 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 11 Sep 2026 18:05:30 +0200 Subject: [PATCH] fix(plex): a Surprise card whose watched toggle failed is put back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review round 3 of bug hunt 2026-09 — the sibling of the collection page's fix (d349bc90). Discover's toggle flips the Surprise draw too, but its resync refetches only the rails (refetching the draw would reshuffle it under the viewer), so a refused toggle stayed flipped there. toggleWatchState takes an optional `revert`, called on a failure only; the queryKeys comment that claimed the watched toggle invalidates the Surprise key now says what the code does. --- .../src/components/PlexDiscover.dom.test.tsx | 27 +++++++++++++++++++ frontend/src/components/PlexDiscover.tsx | 16 ++++++++--- frontend/src/lib/plexWatch.ts | 6 ++++- frontend/src/lib/queryKeys.ts | 5 ++-- 4 files changed, 47 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/PlexDiscover.dom.test.tsx b/frontend/src/components/PlexDiscover.dom.test.tsx index 548f5314..df49dee5 100644 --- a/frontend/src/components/PlexDiscover.dom.test.tsx +++ b/frontend/src/components/PlexDiscover.dom.test.tsx @@ -235,4 +235,31 @@ describe("PlexDiscover", () => { ); await screen.findByText("plex.discover.empty"); }); + + 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. + plexDiscover.mockResolvedValue({ + ...fullPayload, + continue_watching: [], + recently_watched: [], + recently_added: [], + because_you_watched: null, + }); + plexSurprise.mockResolvedValue({ scope: "both", items: [card("sp1", "Random Pick")] }); + plexSetState.mockRejectedValueOnce(new Error("refused")); + wrap( + , + ); + fireEvent.click(await screen.findByLabelText("plex.markWatched")); + await waitFor(() => expect(plexSetState).toHaveBeenCalledWith("sp1", "watched")); + await waitFor(() => expect(screen.getByLabelText("plex.markWatched")).toBeTruthy()); + expect(screen.queryByLabelText("plex.markUnwatched")).toBeNull(); + }); }); diff --git a/frontend/src/components/PlexDiscover.tsx b/frontend/src/components/PlexDiscover.tsx index c7414ef5..435b06c9 100644 --- a/frontend/src/components/PlexDiscover.tsx +++ b/frontend/src/components/PlexDiscover.tsx @@ -105,6 +105,14 @@ export default function PlexDiscover({ // Watched toggle over BOTH this surface's cache (so a rail card flips instantly) and the surprise // list, then persist + resync via the shared helper — invalidating the grid too so it agrees. + const setSurpriseStatus = (card: PlexCard, status: PlexCard["status"]) => + qc.setQueryData<{ scope: string; items: PlexCard[] }>( + qk.plexSurprise(scope, lang, filters), + (old) => + old + ? { ...old, items: old.items.map((c) => (c.id === card.id ? { ...c, status } : c)) } + : old, + ); const toggleWatched = (card: PlexCard) => toggleWatchState(card, t, { optimistic: (next) => { @@ -122,11 +130,11 @@ export default function PlexDiscover({ } : old, ); - qc.setQueryData<{ scope: string; items: PlexCard[] }>( - qk.plexSurprise(scope, lang, filters), - (old) => (old ? { ...old, items: flip(old.items) } : old), - ); + 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. + revert: () => setSurpriseStatus(card, card.status), resync: () => { qc.invalidateQueries({ queryKey: qk.plexDiscover() }); qc.invalidateQueries({ queryKey: qk.plexLibrary() }); diff --git a/frontend/src/lib/plexWatch.ts b/frontend/src/lib/plexWatch.ts index 2ba8afd0..7749af7c 100644 --- a/frontend/src/lib/plexWatch.ts +++ b/frontend/src/lib/plexWatch.ts @@ -6,16 +6,20 @@ import { notifyActionError } from "./actionErrors"; // updater, call the API, surface a failure (the click used to look dead on 403/404/429), then // `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 a cache `resync` must NOT refetch — a random draw (Discover's Surprise rail), which +// a refetch would reshuffle under the viewer. It is called on a failure only, to put the card back. export async function toggleWatchState( card: PlexCard, t: TFunction, - handlers: { optimistic: (next: string) => void; resync: () => void }, + handlers: { optimistic: (next: string) => void; resync: () => void; revert?: () => void }, ) { const next = card.status === "watched" ? "new" : "watched"; handlers.optimistic(next); try { await api.plexSetState(card.id, next); } catch (e) { + handlers.revert?.(); notifyActionError(e, t("plex.watchedFailed")); } handlers.resync(); diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 423f9743..deee4b24 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -117,8 +117,9 @@ export const qk = { // refetches it alone. 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); `plexSurprise()` (no args) - // stays a prefix for the reshuffle/watched-toggle invalidations. + // filter narrows against the language-specific genre column, like the grid). The ↻ reshuffle is the + // query's own refetch, and nothing invalidates this key — deliberately: a watched toggle flips the + // card in place (and reverts it on a failure) rather than reshuffle the rail under the viewer. plexSurprise: (...rest: unknown[]) => ["plex-surprise", ...rest], plexSuggest: (...rest: unknown[]) => ["plex-suggest", ...rest], // Optional trailing `lang` (E5 S6): a language switch remounts the detail with a new key so HU/EN