test(plex): drive the playlist's reorder and rollback through a hook
The Plex playlist's reorder is a dnd-kit drag, and neither of its sensors activates under jsdom, so `commit()`'s failure path could not be reached from a test. The query, the optimistic copy and the two writes (reorder, remove) move into `usePlexPlaylistEdits`, unchanged, and are driven directly: a failed reorder is said and the rows go back; an accepted order lands in the detail cache; a failed removal comes back. It also settles the `putBack` question that could not be driven before: with the refreshed list taken in BEFORE the reorder fails, a rollback bound to the render that started the reorder puts a removed row back, and the deep-equal refetch never clears it. The live cache read the code has now is what keeps it out; the test fails if it goes back to the closure's `q.data`.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
@@ -40,6 +40,7 @@ import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
|
||||
// A playlist's items, grouped for readability: movies stay standalone, while a run of episodes from the
|
||||
// same show collapses into a season/show group so a whole series doesn't sprawl into an endless flat
|
||||
@@ -381,7 +382,6 @@ export default function PlexPlaylistView({
|
||||
const confirm = useConfirm();
|
||||
const [renaming, setRenaming] = useState(false);
|
||||
const [name, setName] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [layout, setLayout] = useState<Layout>(() =>
|
||||
getAccountRaw(LS.plexPlaylistLayout) === "tree" ? "tree" : "accordion",
|
||||
);
|
||||
@@ -393,15 +393,10 @@ export default function PlexPlaylistView({
|
||||
const [expandedShows, setExpandedShows] = useState<Set<string>>(new Set());
|
||||
const [collapsedSeasons, setCollapsedSeasons] = useState<Set<string>>(new Set());
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: qk.plexPlaylist(playlistId),
|
||||
queryFn: () => api.plexPlaylist(playlistId),
|
||||
});
|
||||
// Local optimistic copy so drag/remove are instant; re-synced whenever the query returns.
|
||||
const [items, setItems] = useState<PlexCard[]>([]);
|
||||
useEffect(() => {
|
||||
if (q.data) setItems(q.data.items);
|
||||
}, [q.data]);
|
||||
// The query, the optimistic copy and the two writes that edit it (reorder, remove) live in
|
||||
// `usePlexPlaylistEdits`, where their failure paths can be driven directly: the drag that reaches
|
||||
// `commit` cannot be, since dnd-kit's sensors do not activate under jsdom.
|
||||
const { q, items, busy, invalidate, commit, onRemove } = usePlexPlaylistEdits(playlistId);
|
||||
const blocks = useMemo(() => buildBlocks(items), [items]);
|
||||
const order = useMemo(() => items.map((i) => i.id), [items]);
|
||||
// Total playtime of the whole playlist, mirroring the YouTube playlist header (E9 12D): items with
|
||||
@@ -411,78 +406,6 @@ export default function PlexPlaylistView({
|
||||
[items],
|
||||
);
|
||||
|
||||
const invalidate = () => {
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
};
|
||||
|
||||
// Every edit here is optimistic on the local copy, and none of them used to handle a failure: a
|
||||
// removal that failed stayed removed on screen (the resync never ran), and a 404 — the playlist
|
||||
// deleted on another device, now a structured `not_found` — was an unhandled rejection that said
|
||||
// nothing at all (deep-review round 4; the YouTube side was fixed in round 3). A failure is said,
|
||||
// and the rows are put back from what the server last sent — explicitly, because a resync that
|
||||
// returns the same list keeps the same `q.data` and the re-sync effect above never runs.
|
||||
// Read the cache AT ROLLBACK TIME, not the `q.data` of the render that bound the failing handler:
|
||||
// a removal completing while a reorder was in flight refreshed the list, and the reorder's stale
|
||||
// closure then put the removed row back on screen — where the deep-equal refetch could not clear
|
||||
// it, and the next drag persisted an order containing it (deep-review round 5). `Playlists.tsx`'s
|
||||
// sibling `restoreFromCache` reads it live for the same reason.
|
||||
const putBack = () => {
|
||||
const cur = qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId));
|
||||
if (cur) setItems(cur.items);
|
||||
invalidate();
|
||||
};
|
||||
|
||||
function commit(next: PlexCard[]) {
|
||||
if (busy) return; // a reorder racing a removal is what makes a rollback ambiguous
|
||||
setItems(next);
|
||||
api
|
||||
.plexReorderPlaylist(
|
||||
playlistId,
|
||||
next.map((i) => i.id),
|
||||
)
|
||||
.then(() => {
|
||||
// Write the accepted order into the DETAIL cache, not just the list. That cache is what
|
||||
// `putBack` rolls back to and what a re-entry inside the 30 s staleTime renders without
|
||||
// refetching — so after an accepted reorder it showed the pre-reorder order again, and the
|
||||
// next drag from there persisted it, silently undoing the one the server took. The
|
||||
// YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round
|
||||
// 6). One accepted write, one cache entry that matches it.
|
||||
qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) =>
|
||||
old ? { ...old, items: next } : old,
|
||||
);
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) });
|
||||
// …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and
|
||||
// the server only reorders the keys it was given — so anything that landed meanwhile (a row
|
||||
// removed from another surface, an item added elsewhere) is clobbered by that write. With
|
||||
// only the list key invalidated nothing corrected it: staleTime is 30 s and
|
||||
// refetchOnWindowFocus is off, so the wrong membership stayed on screen for as long as the
|
||||
// view was mounted and the next drag persisted an order built from it (round 7).
|
||||
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
|
||||
})
|
||||
.catch((e: unknown) => {
|
||||
notifyActionError(e, t("plex.playlist.reorderFailed"));
|
||||
putBack();
|
||||
});
|
||||
}
|
||||
|
||||
async function onRemove(keys: string[]) {
|
||||
if (busy || !keys.length) return;
|
||||
setBusy(true);
|
||||
const set = new Set(keys);
|
||||
setItems((prev) => prev.filter((i) => !set.has(i.id)));
|
||||
try {
|
||||
if (keys.length === 1) await api.plexPlaylistRemoveItem(playlistId, keys[0]!);
|
||||
else await api.plexPlaylistRemoveBulk(playlistId, keys);
|
||||
invalidate();
|
||||
} catch (e) {
|
||||
notifyActionError(e, t("plex.playlist.removeFailed"));
|
||||
putBack();
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function rename() {
|
||||
const title = name.trim();
|
||||
if (!title) return;
|
||||
|
||||
Reference in New Issue
Block a user