diff --git a/frontend/src/components/PlexPlaylistReorder.dom.test.tsx b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx index b085f2b6..768e5744 100644 --- a/frontend/src/components/PlexPlaylistReorder.dom.test.tsx +++ b/frontend/src/components/PlexPlaylistReorder.dom.test.tsx @@ -63,12 +63,17 @@ afterEach(() => { const movie = (id: string) => ({ id, type: "movie", title: `Movie ${id}`, year: 2020, thumb: "" }) as unknown as PlexCard; -it("writes the order the server accepted into the detail cache", async () => { - vi.spyOn(api, "plexPlaylist").mockResolvedValue({ - id: 5, - title: "Films", - items: [movie("1"), movie("2"), movie("3")], - }); +it("writes the order the server accepted into the detail cache, without waiting for a refetch", async () => { + // The refetch that same handler schedules (round 7) must not be what makes the cache right: the + // point of the write is that a re-entry inside the 30 s staleTime renders the accepted order + // immediately, with no request at all. So the refetch is HELD OPEN here — if this assertion + // needed it to land, deleting the write would still pass and round 6's fix would be unguarded. + let calls = 0; + vi.spyOn(api, "plexPlaylist").mockImplementation(() => + ++calls === 1 + ? Promise.resolve({ id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] }) + : new Promise(() => {}), + ); const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never); const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); render( @@ -88,3 +93,38 @@ it("writes the order the server accepted into the detail cache", async () => { ).toEqual(["2", "1", "3"]), ); }); + +it("refetches the detail too, so a membership change that landed meanwhile is not clobbered", async () => { + // `next` is the membership of the render that started the drag, and `reorder_playlist` only + // touches the rating_keys it is handed — it never ratifies that list. So writing `next` into the + // detail cache silently drops anything that landed in between (a row removed from another + // surface, an item added elsewhere), and with only the LIST key invalidated nothing corrected it: + // staleTime is 30 s and refetchOnWindowFocus is off, so the phantom row stayed on screen for as + // long as the view was mounted and the next drag persisted an order built from it. The YouTube + // sibling invalidates the detail key right after writing it; this half did not (round 7). + let calls = 0; + vi.spyOn(api, "plexPlaylist").mockImplementation(() => + Promise.resolve( + ++calls === 1 + ? { id: 5, title: "Films", items: [movie("1"), movie("2"), movie("3")] } + : { id: 5, title: "Films", items: [movie("2"), movie("1")] }, // "3" is gone server-side + ), + ); + const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never); + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + {}} onPlay={() => {}} /> + , + ); + await screen.findByText("Movie 3"); + act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } })); + await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"])); + // The refetch the accepted write schedules brings the real membership back. + await waitFor(() => expect(screen.queryByText("Movie 3")).toBeNull()); + await waitFor(() => + expect( + qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id), + ).toEqual(["2", "1"]), + ); +}); diff --git a/frontend/src/components/PlexPlaylistView.tsx b/frontend/src/components/PlexPlaylistView.tsx index a4cc0e58..6b3890e3 100644 --- a/frontend/src/components/PlexPlaylistView.tsx +++ b/frontend/src/components/PlexPlaylistView.tsx @@ -437,6 +437,13 @@ export default function PlexPlaylistView({ qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) => old ? { ...old, items: next } : old, ); + qc.invalidateQueries({ queryKey: qk.plexPlaylist(playlistId) }); + // …and then REFETCH it, exactly as the sibling does. `next` is this render's membership, and + // the server only reorders the keys it was given — so anything that landed meanwhile (a row + // removed from another surface, an item added elsewhere) is clobbered by that write. With + // only the list key invalidated nothing corrected it: staleTime is 30 s and + // refetchOnWindowFocus is off, so the wrong membership stayed on screen for as long as the + // view was mounted and the next drag persisted an order built from it (round 7). qc.invalidateQueries({ queryKey: qk.plexPlaylists() }); }) .catch((e: unknown) => {