fix(playlists): a refused edit is taken back on screen, and a gone list or tag is said, not swallowed

Deep-review round 4, the playlist surfaces.

- A failed move or reorder relied on the resync to put the rows back — but the server's list is
  unchanged, so the refetch comes back deep-equal, react-query keeps the same `data` object, and the
  re-seed effect keyed on it never ran: the moved row stayed gone, the rejected order stayed on
  screen. Both restore from the cached detail now.
- A failed removal puts its row back itself: when the server is unreachable the resync fails too,
  and round 3's "keep the list on a failed refetch" guard then kept showing it as removed.
- The Plex playlist page (remove / rename / delete / reorder), the add-to-playlist popover and the
  tag manager had no failure handling at all: a list or tag deleted elsewhere (a 404, now a
  structured `not_found` the request layer leaves to the caller) failed in silence and an
  optimistic removal stayed removed. Each says it and resyncs; deleting a Plex playlist that is
  already gone just leaves the page.
- Playlists' own loading/error branches use the shared `LoadingState` / `StateMessage` (the loading
  line had no live region).
- The "player stays mounted" tests count mounts: a remount ended with the same flag as staying up.
This commit is contained in:
2026-09-11 23:34:33 +02:00
parent 6d49da17fe
commit 267fd7f70f
12 changed files with 309 additions and 37 deletions
+33 -22
View File
@@ -20,7 +20,6 @@ import {
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import {
AlertTriangle,
ArrowDown,
ArrowDownToLine,
ArrowUp,
@@ -51,6 +50,7 @@ import { formatRuntime } from "../lib/format";
import { applyView, sameOrder } from "../lib/playlistView";
const PlayerModal = lazy(() => import("./PlayerModal"));
import { Button } from "./ui/Button";
import { LoadingState, StateMessage } from "./ui/QueryState";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { useComfortPin, pinVideo } from "./comfortPin";
import { ContextMenu } from "./ui/ContextMenu";
@@ -184,9 +184,10 @@ export default function Playlists() {
const order = useUndoable<Video[]>([], {
onApply: (next) => {
if (selectedId == null) return;
const listId = selectedId;
api
.reorderPlaylist(
selectedId,
listId,
next.map((v) => v.id),
)
.then(() => {
@@ -198,13 +199,11 @@ export default function Playlists() {
})
.catch((e) => {
// Was a bare .then(): a failed reorder left the UI showing an order the server had
// rejected, and produced an unhandled rejection. Say so and resync from the server.
// rejected, and produced an unhandled rejection. Say so, and put back the order the server
// still has — or the rejected one becomes the base for the next drag.
notifyYouTubeActionError(e, t("playlists.reorderFailed"));
// Clear the membership guard first: a reorder never changes the id SET, so without this
// the arriving detail is judged "same list" and the rejected order stays on screen — and
// becomes the base for the next drag.
lastSetRef.current = "";
qc.invalidateQueries({ queryKey: qk.playlist(selectedId) });
restoreFromCache(listId);
qc.invalidateQueries({ queryKey: qk.playlist(listId) });
});
},
});
@@ -220,6 +219,18 @@ export default function Playlists() {
// The selection as of NOW, for code that runs after an await or on behalf of the open player.
const selectedRef = useRef(selectedId);
selectedRef.current = selectedId;
// Put the rows back to what the CACHED detail says — the server's list as last fetched. A failed
// reorder or move must not wait for the resync to do it: the server's list is unchanged, so the
// refetch comes back deep-equal, react-query keeps the same `data` object, and the effect below
// (keyed on it) never runs. The moved row stayed gone and the rejected order stayed on screen
// (deep-review round 4). Only for the list on screen: the selection may have moved meanwhile.
function restoreFromCache(listId: number) {
if (listId !== selectedRef.current) return;
const cur = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
if (!cur) return;
lastSetRef.current = `${listId}:${idsKey(cur.items)}`;
order.reset(cur.items);
}
// What the player was LAUNCHED with, snapshotted when it opens: the video, the queue and the index
// inside it. `shown` is derived (sort / filter / view), and the player freezes its queue and owns
// its own cursor from mount — so deriving the mount from `shown` every render meant anything that
@@ -568,6 +579,7 @@ export default function Playlists() {
// player's own progress checkpoint does exactly that when a removal steps it to the next item —
// so the guard reset the order: the removed row came back and the undo history the rebase had
// kept went with it. (A failure resyncs below, which puts the row back where it belongs.)
const before = qc.getQueryData<PlaylistDetail>(qk.playlist(listId));
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
);
@@ -578,9 +590,13 @@ export default function Playlists() {
} catch (e) {
// Said here, as moveItemTo does, or a confirmed removal fails in silence (inside the
// full-screen player there is not even a row snapping back to show it) — unless `req` already
// said it (the error dialog, the connection notice), which `notifyActionError` checks.
// put it in its error dialog, which `notifyActionError` checks.
notifyActionError(e, t("playlists.removeFailed"));
refreshAll(listId); // …and resync, which restores any row the optimistic removal took out
// …and the row goes back NOW, not when the resync lands: a removal that failed because the
// server was unreachable has a resync that fails too, and the list kept showing the row as
// removed while it was still in the playlist.
if (before) qc.setQueryData(qk.playlist(listId), before);
refreshAll(listId);
return false;
}
}
@@ -611,9 +627,10 @@ export default function Playlists() {
// every move failure a user can actually reach is a 404 (the 400 `same_playlist` case is already
// impossible — the submenu never offers the current list). So the row used to snap back with no
// explanation and the shipped moveErrors strings were unreachable (E10 review round 1).
// A fault `req` already reported (the error dialog, the connection notice) is not toasted
// again — `notifyActionError` checks (E10 review round 2 found it reported twice).
// A fault `req` already put in its error dialog is not toasted again — `notifyActionError`
// checks (E10 review round 2 found it reported twice).
notifyActionError(e, t("playlists.moveFailed"));
restoreFromCache(selectedId); // the row is still in this list
}
// BOTH sides changed, and so did this video's membership. `qk.playlist(selectedId)` is an exact
// key, so it cannot cover the target — invalidate the whole `["playlist"]` prefix, exactly as
@@ -819,15 +836,9 @@ export default function Playlists() {
if (detailQuery.isError && !detail) {
return (
<>
<div role="alert" className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}
</span>
<Button variant="outline" size="xs" onClick={() => detailQuery.refetch()}>
{t("common.retry")}
</Button>
</div>
<StateMessage tone="error" onRetry={() => detailQuery.refetch()}>
{t("playlists.loadFailed")}
</StateMessage>
{player}
</>
);
@@ -835,7 +846,7 @@ export default function Playlists() {
if (!detail) {
return (
<>
<div className="text-muted text-sm p-4">{t("playlists.loading")}</div>
<LoadingState label={t("playlists.loading")} />
{player}
</>
);