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:
@@ -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: [],
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user