From 92a17aba83f38ddeb2405ea29bd2c86eb33f1ed1 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 9 Oct 2026 23:46:39 +0200 Subject: [PATCH] =?UTF-8?q?fix(plex):=20a=20reshuffle=20survives=20a=20ref?= =?UTF-8?q?resh=20landing=20mid-deal;=20the=20=E2=86=BB=20spins=20for=20a?= =?UTF-8?q?=20deal=20only?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/components/PlexDiscover.dom.test.tsx | 50 ++++++++++++++++++- frontend/src/components/PlexDiscover.tsx | 38 +++++++++----- frontend/src/lib/plexWatch.ts | 7 ++- frontend/src/lib/queryKeys.ts | 5 +- 4 files changed, 78 insertions(+), 22 deletions(-) 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,