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
+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) };
}