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:
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user