From 12121255dd681b94d94a1710590c6cccd7f0025d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 12 Sep 2026 04:15:15 +0200 Subject: [PATCH] fix(playlists): a rejected reorder goes back to the order the server accepted MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deep-review round 5, over round 4's own fixes. - Round 4 made a failed reorder roll the rows back to the CACHED detail, because a resync cannot do it (the server's list is unchanged, so the refetch is deep-equal, react-query keeps the same `data` object and the effect keyed on it never runs). But nothing wrote an ACCEPTED order into that cache — only the refetch that follows a successful reorder did. So a second reorder rejected while that refetch was still in flight rolled the rows back past the first one, which the server had accepted; the membership guard cannot correct an order (its key is the sorted id set), so the wrong order stayed on screen for good and the next drag persisted it. `onApply` now writes the accepted order into the cache before invalidating. - `PlexPlaylistView.rename` had a `catch` no test could reach, and `commit` (reorder) was not gated by `busy` the way its siblings are — a reorder in flight while a removal completes is what makes a rollback ambiguous in the first place. The rename failure path is now covered; `commit` defers. - `putBack` read `q.data` from the render that bound the failing handler instead of the cache at rollback time, unlike the sibling `restoreFromCache`. Aligned. Stated plainly: I could NOT drive that staleness in jsdom — a mutation that restores the old shape leaves every candidate test green — so this one ships as an alignment with the proven sibling, not as a fix with a red test behind it. The scenario and the harness limit are on ROADMAP.md. Two regression tests, both mutation-checked red (the cache write, the rename catch). The drag-reorder failure path still has no test: dnd-kit's pointer and keyboard sensors do not activate under jsdom even with geometry and scrollIntoView stubbed, so a test there would have pinned the harness, not the code. Logged, not faked. vitest 1009. --- .../PlaylistEditFailures.dom.test.tsx | 17 +++++++++++++ .../src/components/Playlists.dom.test.tsx | 25 +++++++++++++++++++ frontend/src/components/Playlists.tsx | 10 +++++++- frontend/src/components/PlexPlaylistView.tsx | 9 ++++++- 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/PlaylistEditFailures.dom.test.tsx b/frontend/src/components/PlaylistEditFailures.dom.test.tsx index dec28ea6..304982b5 100644 --- a/frontend/src/components/PlaylistEditFailures.dom.test.tsx +++ b/frontend/src/components/PlaylistEditFailures.dom.test.tsx @@ -76,6 +76,23 @@ describe("the Plex playlist page", () => { expect(screen.getByText("Movie 1")).toBeTruthy(); }); + it("says so when a rename is refused, and keeps the field open", async () => { + vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] }); + vi.spyOn(api, "plexRenamePlaylist").mockRejectedValue(gone("playlist")); + render( + + {}} onPlay={() => {}} /> + , + ); + await screen.findByText("Films"); + fireEvent.click(screen.getByTitle("plex.playlist.rename")); + const field = screen.getByDisplayValue("Films"); + fireEvent.change(field, { target: { value: "Movies" } }); + fireEvent.keyDown(field, { key: "Enter" }); + await waitFor(() => expect(errors()).toEqual(["gone playlist"])); + expect(screen.getByDisplayValue("Movies")).toBeTruthy(); // still open to try again + }); + it("leaves a playlist that is already gone when asked to delete it", async () => { vi.spyOn(api, "plexPlaylist").mockResolvedValue({ id: 5, title: "Films", items: [] }); vi.spyOn(api, "plexDeletePlaylist").mockRejectedValue(gone("playlist")); diff --git a/frontend/src/components/Playlists.dom.test.tsx b/frontend/src/components/Playlists.dom.test.tsx index c3b7262d..38ec9004 100644 --- a/frontend/src/components/Playlists.dom.test.tsx +++ b/frontend/src/components/Playlists.dom.test.tsx @@ -343,6 +343,31 @@ describe("Playlists — a change the server refused is taken back on screen", () expect(rowsOnScreen()).toEqual(["Video V", "Video W"]); }); + it("puts back the order the server ACCEPTED, not the one it had two reorders ago", async () => { + // The rollback reads the cached detail, and nothing wrote the accepted order into it — only the + // refetch that follows a successful reorder did. So a second reorder rejected while that refetch + // was still in flight rolled the rows back past the FIRST one, which the server had accepted. + // The membership guard cannot correct an order (its key is the sorted id set), so the wrong + // order stayed on screen for good and the next drag persisted it (deep-review round 5). + const reorder = vi + .spyOn(api, "reorderPlaylist") + .mockResolvedValueOnce(undefined as never) + .mockRejectedValue(new HttpError(500, "boom")); + mount({ 1: [vid("V", 0), vid("W", 0), vid("X", 0)] }); + await screen.findByRole("button", { name: "card.play — Video V" }); + // From here no refetch ever lands, so the cache is whatever the page itself put there. + vi.mocked(api.playlist).mockReturnValue(new Promise(() => {}) as never); + + await rowMenu("Video V", "playlists.orderDown"); + await waitFor(() => expect(reorder).toHaveBeenCalledTimes(1)); + expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]); + + await rowMenu("Video X", "playlists.orderUp"); + await waitFor(() => expect(reorder).toHaveBeenCalledTimes(2)); + await act(async () => void (await new Promise((r) => setTimeout(r, 20)))); + expect(rowsOnScreen()).toEqual(["Video W", "Video V", "Video X"]); + }); + it("puts back a row whose removal failed, even when the resync fails too", async () => { // A removal during an API restart: the DELETE fails, and so does the refetch meant to put the row // back — the list kept showing it as removed while it was still in the playlist. diff --git a/frontend/src/components/Playlists.tsx b/frontend/src/components/Playlists.tsx index 583b3175..f30f7d98 100644 --- a/frontend/src/components/Playlists.tsx +++ b/frontend/src/components/Playlists.tsx @@ -191,11 +191,19 @@ export default function Playlists() { next.map((v) => v.id), ) .then(() => { + // Write the accepted order into the cache BEFORE the refetch. The cache is what + // `restoreFromCache` rolls back to, and until this refetch landed it still held the order + // from before this reorder — so a SECOND reorder failing in that window rolled the rows + // back to an order the server does not have, permanently (the membership guard cannot + // correct an order). One accepted write, one cache entry that matches it (round 5). + qc.setQueryData(qk.playlist(listId), (old) => + old ? { ...old, items: next } : old, + ); // Refresh the sidebar (cover may change) and the detail (so the now-dirty state, // and the Reset/Unsynced indicators, show without an F5). The membership guard // keeps the refetch from wiping the undo history on a pure reorder. qc.invalidateQueries({ queryKey: qk.playlists() }); - qc.invalidateQueries({ queryKey: qk.playlist(selectedId) }); + qc.invalidateQueries({ queryKey: qk.playlist(listId) }); }) .catch((e) => { // Was a bare .then(): a failed reorder left the UI showing an order the server had diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index ffc97290..9e3f33f3 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -408,12 +408,19 @@ export default function PlexPlaylistView({ // 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 = () => { - if (q.data) setItems(q.data.items); + 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(