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