fix(playlists): an Undo puts a row back after its old neighbour, not at a stale index

A raw index recorded at removal drifted once anything changed before the Undo.
Both lists now read the list fresh and re-insert each row after the row that
preceded it (lib/reinsert). A failed watched toggle in a Plex playlist also
puts the row's state back: its local copy was never re-seeded.
This commit is contained in:
2026-09-27 14:02:15 +02:00
parent b456232d00
commit de62035889
9 changed files with 225 additions and 32 deletions
+20 -4
View File
@@ -449,9 +449,10 @@ describe("Playlists — removing a row, and undoing it", () => {
});
it("puts it back where it stood in the stored order, whatever the view on screen", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount({ 1: [vid("A", 0), vid("B", 0), vid("C", 0)] });
mount(lists);
await screen.findByRole("button", { name: "card.play — Video C" });
// Sort the VIEW by title, descending: C, B, A on screen — the stored order is still A, B, C.
fireEvent.click(screen.getByRole("button", { name: /playlists\.colTitle/ }));
@@ -463,10 +464,25 @@ describe("Playlists — removing a row, and undoing it", () => {
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "A", 0));
});
// E28 review C4: the stored index drifts when the list changes before the Undo (a row added at
// the top in another tab) — the row goes back after its old neighbour instead.
it("puts it back after its neighbour when the list changed before the Undo", async () => {
const lists = { 1: [vid("A", 0), vid("B", 0), vid("C", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockResolvedValue(undefined as never);
mount(lists);
await rowMenu("Video B", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
lists[1] = [vid("X", 0), ...lists[1]!];
act(() => lastToast().action!.onClick());
await waitFor(() => expect(api.addToPlaylist).toHaveBeenCalledWith(1, "B", 2));
});
it("says so when the Undo fails", async () => {
vi.spyOn(api, "removeFromPlaylist").mockResolvedValue(undefined as never);
const lists = { 1: [vid("A", 0), vid("B", 0)] };
serverRemoves(lists);
vi.spyOn(api, "addToPlaylist").mockRejectedValue(new TypeError("Failed to fetch"));
mount({ 1: [vid("A", 0), vid("B", 0)] });
mount(lists);
await rowMenu("Video A", "playlists.removeItem");
await waitFor(() => expect(lastToast()?.action).toBeTruthy());
act(() => lastToast().action!.onClick());
+21 -6
View File
@@ -24,6 +24,7 @@ import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { playlistName } from "../lib/playlistName";
import { reinsertAt } from "../lib/reinsert";
import { useUndoable } from "../lib/useUndoable";
import { cycleSort, type SortState } from "../lib/columnSort";
import { formatRuntime } from "../lib/format";
@@ -476,9 +477,10 @@ export default function Playlists() {
// 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));
// Where it stood in the STORED order — what the Undo puts it back at (the server's list, so a
// view sort on screen does not decide it).
const at = before?.items.findIndex((v) => v.id === video.id) ?? -1;
// The STORED order it stood in — the Undo puts it back after its neighbour there (the server's
// list, so a view sort on screen does not decide it; `reinsertAt`, so a change in between does
// not shift it off by the rows added or moved meanwhile).
const storedBefore = before?.items.map((v) => v.id) ?? [];
qc.setQueryData<PlaylistDetail>(qk.playlist(listId), (old) =>
old ? { ...old, items: old.items.filter((v) => v.id !== video.id) } : old,
);
@@ -503,10 +505,23 @@ export default function Playlists() {
action: {
label: t("common.undo"),
// ONE request that puts it back at its index (the server renumbers around it): an append
// plus a whole-list reorder would land it at the end whenever the second call failed.
// plus a whole-list reorder would land it at the end whenever the second call failed. The
// index is worked out against the list as it is NOW, fetched fresh.
onClick: () =>
void api
.addToPlaylist(listId, video.id, at < 0 ? undefined : at)
void qc
.fetchQuery({
queryKey: qk.playlist(listId),
queryFn: () => api.playlist(listId),
staleTime: 0,
})
.then((now) => {
const back = reinsertAt(
storedBefore,
[video.id],
now.items.map((v) => v.id),
);
if (back.keys.length) return api.addToPlaylist(listId, video.id, back.positions[0]);
})
.catch((e: unknown) => notifyActionError(e, t("playlists.undoRemoveFailed")))
.finally(() => {
refreshAll(listId);
@@ -152,14 +152,22 @@ describe("PlexPlaylistView · a row's menu", () => {
it("removes a title with no confirm, and the toast's Undo puts it back at its index", async () => {
mount([movie("1"), movie("2"), movie("3")]);
await screen.findByText("Movie 2");
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
// The server's side of it: the Undo reads the list fresh, and the row is gone from it.
vi.mocked(api.plexPlaylist).mockResolvedValue({
id: 5,
title: "Films",
items: [movie("1"), movie("3")],
} as never);
return {} as never;
});
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.playlist.removeFromPlaylist");
await waitFor(() => expect(api.plexPlaylistRemoveItem).toHaveBeenCalledWith(5, "2"));
expect(h.confirm).not.toHaveBeenCalled();
await waitFor(() => expect(toast()?.message).toBe("plex.playlist.removedToast:Movie 2"));
act(() => toast()!.action!.onClick());
expect(add).toHaveBeenCalledWith(5, ["2"], [1]);
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [1]));
});
it("plays and removes a whole show from its group's menu", async () => {
@@ -313,3 +321,24 @@ describe("PlexPlaylistView · the header's '…'", () => {
);
});
});
// E28 review E2: the watched toggle flips the view's LOCAL copy of the rows. On a failure the
// resync refetches an identical list, react-query keeps the same `data`, and the copy was never
// re-seeded — the row stayed "watched" on screen while Plex still had it unwatched.
describe("PlexPlaylistView · a failed watched toggle", () => {
it("puts the row's watched state back", async () => {
mount([movie("1"), movie("2")]);
await screen.findByText("Movie 2");
vi.spyOn(api, "plexSetState").mockRejectedValue(new Error("offline"));
await pick(() => fireEvent.contextMenu(rowOf("Movie 2")), "plex.markWatched");
await waitFor(() =>
expect(h.notify).toHaveBeenCalledWith(
expect.objectContaining({ level: "error", title: "plex.watchedFailed" }),
),
);
await act(async () => {});
fireEvent.keyDown(document.activeElement ?? document.body, { key: "Escape" });
await openMenu(() => fireEvent.contextMenu(rowOf("Movie 2")));
expect(menuRows()).toContain("plex.markWatched");
});
});
@@ -560,6 +560,9 @@ export default function PlexPlaylistView({
onToggleWatched: (c) =>
void toggleWatchState(c, t, {
optimistic: (next) => setStatus(c.id, next),
// The flip is on the view's LOCAL copy: a failed write's resync refetches an identical
// list, react-query keeps the same `data`, and the copy is never re-seeded from it.
revert: () => setStatus(c.id, c.status ?? "new"),
resync: invalidate,
}),
},
@@ -129,9 +129,12 @@ describe("usePlexPlaylistEdits · the removal", () => {
// E28 C1: no confirm — the toast's Undo is the way back, and it puts each row where it stood.
it("says what went, and its Undo puts the rows back at their own indices in one request", async () => {
const { hook } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
const { hook, detail } = setup([movie("1"), movie("2"), movie("3"), movie("4")]);
await waitFor(() => expect(hook.result.current.items).toHaveLength(4));
vi.spyOn(api, "plexPlaylistRemoveBulk").mockResolvedValue({} as never);
vi.spyOn(api, "plexPlaylistRemoveBulk").mockImplementation(async () => {
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("1"), movie("3")] } as never);
return {} as never;
});
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
// A season whose episodes do not sit together: rows 2 and 4.
@@ -140,14 +143,35 @@ describe("usePlexPlaylistEdits · the removal", () => {
expect(toast.message).toBe("plex.playlist.removedToast");
expect(toast.action!.label).toBe("common.undo");
act(() => toast.action!.onClick());
expect(add).toHaveBeenCalledTimes(1);
expect(add).toHaveBeenCalledWith(5, ["4", "2"], [3, 1]);
await waitFor(() => expect(add).toHaveBeenCalledTimes(1));
expect(add).toHaveBeenCalledWith(5, ["2", "4"], [1, 3]);
});
// E28 review C4: a raw index drifts when the list changes before the Undo — another tab added a
// row at the top — and the row came back one place off. It goes back after its old neighbour.
it("puts a row back after its neighbour when the list changed before the Undo", async () => {
const { hook, detail } = setup([movie("1"), movie("2"), movie("3")]);
await waitFor(() => expect(hook.result.current.items).toHaveLength(3));
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
const add = vi.spyOn(api, "plexPlaylistAddBulk").mockResolvedValue({} as never);
await act(() => hook.result.current.onRemove(["2"], "Movie 2"));
detail.mockResolvedValue({
id: 5,
title: "F",
items: [movie("x"), movie("1"), movie("3")],
} as never);
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
act(() => toast.action!.onClick());
await waitFor(() => expect(add).toHaveBeenCalledWith(5, ["2"], [2]));
});
it("says so when the Undo fails", async () => {
const { hook } = setup([movie("1"), movie("2")]);
const { hook, detail } = setup([movie("1"), movie("2")]);
await waitFor(() => expect(hook.result.current.items).toHaveLength(2));
vi.spyOn(api, "plexPlaylistRemoveItem").mockResolvedValue({} as never);
vi.spyOn(api, "plexPlaylistRemoveItem").mockImplementation(async () => {
detail.mockResolvedValue({ id: 5, title: "F", items: [movie("2")] } as never);
return {} as never;
});
vi.spyOn(api, "plexPlaylistAddBulk").mockRejectedValue(new Error("offline"));
await act(() => hook.result.current.onRemove(["1"], "Movie 1"));
const toast = notify.mock.calls.map(([n]) => n).find((n) => n.action)!;
+22 -12
View File
@@ -6,6 +6,7 @@ import { api, type PlexCard } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { reinsertAt } from "../lib/reinsert";
/** A Plex playlist's items and the two writes that edit them in place — a reorder and a removal.
* Split from `PlexPlaylistView` so the failure paths are reachable from a test: the reorder is
@@ -81,14 +82,15 @@ export function usePlexPlaylistEdits(playlistId: number) {
}
/** Take `keys` out — one row, a season or a whole show — with no confirm: the toast's Undo puts
* each row back at the index it held (E28 C1, the same bargain as a YouTube playlist's rows).
* `title` names what went in that toast. */
* each row back where it stood (E28 C1, the same bargain as a YouTube playlist's rows) — after
* its old neighbour in the list as it is at Undo time (`reinsertAt`), since a raw index drifts
* once anything changes in between. `title` names what went in that toast. */
async function onRemove(keys: string[], title: string) {
if (busy || !keys.length) return;
setBusy(true);
// Indices over the list as it stands now, which is the server's order: a season's episodes need
// not sit together (a reorder inside the show interleaves them), so each key keeps its own.
const positions = keys.map((k) => items.findIndex((i) => i.id === k));
// The list as it stands now, which is the server's order: a season's episodes need not sit
// together (a reorder inside the show interleaves them), so each key keeps its own neighbour.
const before = items.map((i) => i.id);
const set = new Set(keys);
setItems((prev) => prev.filter((i) => !set.has(i.id)));
try {
@@ -102,19 +104,27 @@ export function usePlexPlaylistEdits(playlistId: number) {
} finally {
setBusy(false);
}
const back = keys.filter((_, i) => positions[i]! >= 0);
notify({
level: "success",
message: t("plex.playlist.removedToast", { title }),
action: {
label: t("common.undo"),
onClick: () =>
void api
.plexPlaylistAddBulk(
playlistId,
back,
positions.filter((p) => p >= 0),
)
void qc
.fetchQuery({
queryKey: qk.plexPlaylist(playlistId),
queryFn: () => api.plexPlaylist(playlistId),
staleTime: 0,
})
.then((now) => {
const back = reinsertAt(
before,
keys,
now.items.map((i) => i.id),
);
if (back.keys.length)
return api.plexPlaylistAddBulk(playlistId, back.keys, back.positions);
})
.catch((e: unknown) => notifyActionError(e, t("plex.playlist.undoRemoveFailed")))
.finally(invalidate),
},
+4 -2
View File
@@ -7,8 +7,10 @@ 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 a cache `resync` must NOT refetch — a random draw (Discover's Surprise rail), which
// a refetch would reshuffle under the viewer. It is called on a failure only, to put the card back.
// `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`).
export async function toggleWatchState(
card: PlexCard,
t: TFunction,
+55
View File
@@ -0,0 +1,55 @@
import { describe, expect, it } from "vitest";
import { reinsertAt } from "./reinsert";
describe("reinsertAt", () => {
it("puts a row back where it was when nothing changed", () => {
expect(reinsertAt(["a", "b", "c"], ["b"], ["a", "c"])).toEqual({
keys: ["b"],
positions: [1],
});
});
it("follows its neighbour when a row was added above it meanwhile", () => {
// "x" landed at the top between the removal and the Undo: index 1 would now sit before "a".
expect(reinsertAt(["a", "b", "c"], ["b"], ["x", "a", "c"])).toEqual({
keys: ["b"],
positions: [2],
});
});
it("follows its neighbour through a reorder", () => {
expect(reinsertAt(["a", "b", "c"], ["b"], ["c", "a"])).toEqual({ keys: ["b"], positions: [2] });
});
it("walks further back when its neighbour is gone too", () => {
expect(reinsertAt(["a", "b", "c", "d"], ["c"], ["b", "d"])).toEqual({
keys: ["c"],
positions: [1],
});
});
it("goes to the start when it was first or no earlier row is left", () => {
expect(reinsertAt(["a", "b"], ["a"], ["x", "b"])).toEqual({ keys: ["a"], positions: [0] });
expect(reinsertAt(["a", "b", "c"], ["b"], ["c"])).toEqual({ keys: ["b"], positions: [0] });
});
it("keeps scattered and adjacent removed rows in their order, ascending", () => {
// A season's episodes interleaved with another's: b and d out, then "x" added at the top.
expect(reinsertAt(["a", "b", "c", "d", "e"], ["d", "b"], ["x", "a", "c", "e"])).toEqual({
keys: ["b", "d"],
positions: [2, 4],
});
expect(reinsertAt(["a", "b", "c", "d"], ["b", "c"], ["a", "d"])).toEqual({
keys: ["b", "c"],
positions: [1, 2],
});
});
it("skips a row that is already back", () => {
expect(reinsertAt(["a", "b", "c"], ["b", "c"], ["a", "c"])).toEqual({
keys: ["b"],
positions: [1],
});
});
});
+39
View File
@@ -0,0 +1,39 @@
/** Where removed rows go back when their Undo comes LATER than the removal.
*
* A raw index recorded at removal time drifts: an add or a reorder landing in between (another tab,
* the rail, the player) shifts everything after it, and the row came back one or more places off
* (E28 review C4). So each removed row goes back right after the row that preceded it in the list
* it was removed from — its neighbour, looked up in the list as it stands NOW — or at the start when
* it was first or no earlier neighbour is left. Rows already back in the list are skipped (the
* server's add is idempotent and would leave them where they are).
*
* @param before the list's ids when the rows were removed, in stored order
* @param removed the ids taken out
* @param now the list's ids at Undo time, in stored order
* @returns the ids to add back and, paired one to one, the index each lands at — ascending, the
* shape the server's insert takes (each index counts the rows put back before it). */
export function reinsertAt(
before: readonly string[],
removed: readonly string[],
now: readonly string[],
): { keys: string[]; positions: number[] } {
const out = new Set(removed);
const list = [...now];
const placed: string[] = [];
for (let i = 0; i < before.length; i++) {
const id = before[i]!;
if (!out.has(id) || now.includes(id)) continue;
let at = 0;
for (let j = i - 1; j >= 0; j--) {
const k = list.indexOf(before[j]!);
if (k >= 0) {
at = k + 1;
break;
}
}
list.splice(at, 0, id);
placed.push(id);
}
const keys = placed.map((id) => ({ id, at: list.indexOf(id) })).sort((a, b) => a.at - b.at);
return { keys: keys.map((k) => k.id), positions: keys.map((k) => k.at) };
}