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 () => { it("shows the empty message when every rail is empty", async () => {
plexDiscover.mockResolvedValue({ plexDiscover.mockResolvedValue({
scope: "both", scope: "both",
@@ -298,8 +344,8 @@ describe("PlexDiscover", () => {
}); });
it("puts a Surprise card back when its watched toggle fails", async () => { 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 // The resync re-reads the Surprise draw too, but a refused toggle is put back at once rather than
// refused toggle stayed flipped there — the sibling of the collection page's fix. // left flipped until that read lands — the sibling of the collection page's fix.
plexDiscover.mockResolvedValue({ plexDiscover.mockResolvedValue({
...fullPayload, ...fullPayload,
continue_watching: [], 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 { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery, useQueryClient } from "@tanstack/react-query";
import { RotateCcw } from "lucide-react"; 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 // 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. // only the ↻ deals — handing over that draw, so a small filtered pool deals its other titles first.
const surpriseKey = qk.plexSurprise(scope, lang, filters); 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 redraw = useRef(false);
const lastDeal = useRef<Promise<unknown> | null>(null);
const [dealing, setDealing] = useState(false);
const surpriseQ = useQuery({ const surpriseQ = useQuery({
queryKey: surpriseKey, queryKey: surpriseKey,
queryFn: () => { queryFn: () => {
const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map( const shown = (qc.getQueryData<{ items: PlexCard[] }>(surpriseKey)?.items ?? []).map(
(c) => c.id, (c) => c.id,
); );
const deal = redraw.current || shown.length === 0; if (!redraw.current && shown.length > 0) {
redraw.current = false; return api.plexSurprise(scope, undefined, filters, lang, { keep: shown });
return api.plexSurprise( }
scope, const deal = api.plexSurprise(scope, undefined, filters, lang, { exclude: shown });
undefined, lastDeal.current = deal;
filters, const done = () => {
lang, if (lastDeal.current !== deal) return;
deal ? { exclude: shown } : { keep: shown }, 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 // Kept past the default five minutes unobserved: a film watched from the rail outlasts that, and
// a collected draw is a new one. // a collected draw is a new one.
@@ -148,6 +157,7 @@ export default function PlexDiscover({
}); });
const reshuffle = () => { const reshuffle = () => {
redraw.current = true; redraw.current = true;
setDealing(true);
void surpriseQ.refetch(); void surpriseQ.refetch();
}; };
@@ -180,8 +190,8 @@ export default function PlexDiscover({
); );
setSurpriseStatus(card, next); setSurpriseStatus(card, next);
}, },
// The resync below leaves the Surprise rail alone (a refetch would reshuffle it), so a failed // The resync below re-reads the Surprise draw too, but only once the write has answered: a
// toggle was left flipped there — put back in place instead. // refused toggle is put back at once rather than left flipped until that read lands.
revert: () => setSurpriseStatus(card, card.status), revert: () => setSurpriseStatus(card, card.status),
resync: () => { resync: () => {
qc.invalidateQueries({ queryKey: qk.plexDiscover() }); qc.invalidateQueries({ queryKey: qk.plexDiscover() });
@@ -255,7 +265,9 @@ export default function PlexDiscover({
action={ action={
<IconButton <IconButton
onClick={reshuffle} 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")} label={t("plex.discover.reshuffle")}
> >
<RotateCcw /> <RotateCcw />
+3 -4
View File
@@ -7,10 +7,9 @@ import { notifyActionError } from "./actionErrors";
// `resync` (invalidate) — which corrects the optimistic flip to truth either way. Shared by the // `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. // 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 // `revert` is called on a failure only, to put the card back where `resync` does not, or not at once:
// cache it must NOT refetch — a random draw (Discover's Surprise rail), which a refetch would // a component's local copy of the rows (a Plex playlist), which an identical refetch never re-seeds
// reshuffle under the viewer — or a component's local copy of the rows (a Plex playlist), which an // (react-query keeps the same `data`), or a cache whose refetch lands later than the click's answer.
// identical refetch never re-seeds (react-query keeps the same `data`).
export async function toggleWatchState( export async function toggleWatchState(
card: PlexCard, card: PlexCard,
t: TFunction, t: TFunction,
+2 -3
View File
@@ -118,9 +118,8 @@ export const qk = {
plex: () => ["plex"] as const, plex: () => ["plex"] as const,
plexWatchLink: () => ["plex-watch-link"] as const, plexWatchLink: () => ["plex-watch-link"] as const,
plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest], plexLibrary: (...rest: unknown[]) => ["plex-library", ...rest],
// Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches // Discover surface: the rail payload keyed by scope+lang; `plexDiscover()` (no args) matches it and
// both for a prefix invalidation after a watched-toggle. Surprise is its own key so the ↻ reshuffle // the Surprise draw below for a prefix invalidation after a watched-toggle.
// refetches it alone.
plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest], plexDiscover: (...rest: unknown[]) => ["plex-discover", ...rest],
// Variadic like plexDiscover so it can key by scope + lang + the active filters (lang because a genre // 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, // filter narrows against the language-specific genre column, like the grid). UNDER the discover root,