fix(plex): a Plex reorder the server accepted is the order the cache holds

The YouTube list got this rule in round 5 — one accepted write, one cache entry
that matches it — and the Plex list got only the rollback half of that fix. Its
success path invalidated `qk.plexPlaylists()`, a disjoint key root, and never
touched `qk.plexPlaylist(id)`: the key `putBack` rolls back to, and the one a
re-entry renders from without refetching.

So after an accepted drag the detail cache still held the pre-reorder order.
Leaving the playlist and coming back inside the 30 s staleTime showed the
reorder undone, and the next drag from there persisted that — silently undoing
an order the server had taken. Measured in jsdom: server 2,1,3, cache 1,2,3, no
refetch.

1 test, mutation-checked.
This commit is contained in:
2026-09-12 05:50:40 +02:00
parent a0114deff1
commit 3eb0f41962
2 changed files with 102 additions and 1 deletions
@@ -0,0 +1,90 @@
// @vitest-environment jsdom
//
// One accepted write, one cache entry that matches it — the Plex half.
//
// `Playlists.tsx` (the YouTube list) got this rule in round 5: the detail cache is what a rollback
// reads and what a re-entry inside the 30 s staleTime renders without refetching, so an accepted
// reorder that is not written there leaves the cache disagreeing with the server. The Plex list got
// the rollback half of that round's fix and not this half (round 6).
//
// Its own file: `@dnd-kit/core` is mocked module-wide so the test can fire `onDragEnd` directly —
// dnd-kit's pointer sensor does not activate under jsdom (no layout, no pointer capture).
import { it, expect, vi, beforeAll, afterEach } from "vitest";
import { render, cleanup, screen, act, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
};
});
const { dragEnd } = vi.hoisted(() => ({ dragEnd: { fire: (_e: unknown) => {} } }));
vi.mock("@dnd-kit/core", async (importOriginal) => {
const actual = await importOriginal<typeof import("@dnd-kit/core")>();
return {
...actual,
DndContext: ({
children,
onDragEnd,
}: {
children: React.ReactNode;
onDragEnd: (e: unknown) => void;
}) => {
dragEnd.fire = onDragEnd;
return <>{children}</>;
},
};
});
import PlexPlaylistView from "./PlexPlaylistView";
import { api, type PlexCard } from "../lib/api";
import { qk } from "../lib/queryKeys";
beforeAll(() => {
class Stub {
observe() {}
unobserve() {}
disconnect() {}
takeRecords() {
return [];
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
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")],
});
const reorder = vi.spyOn(api, "plexReorderPlaylist").mockResolvedValue(undefined as never);
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
render(
<QueryClientProvider client={qc}>
<PlexPlaylistView playlistId={5} onBack={() => {}} onPlay={() => {}} />
</QueryClientProvider>,
);
await screen.findByText("Movie 1");
act(() => dragEnd.fire({ active: { id: "1" }, over: { id: "2" } }));
await waitFor(() => expect(reorder).toHaveBeenCalledWith(5, ["2", "1", "3"]));
// The list key was always invalidated; the DETAIL key — the one `putBack` reads and the one a
// re-entry renders from — kept the pre-reorder order, so leaving and coming back inside the
// staleTime showed the reorder undone, and the next drag persisted that.
await waitFor(() =>
expect(
qc.getQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(5))?.items.map((i) => i.id),
).toEqual(["2", "1", "3"]),
);
});
+12 -1
View File
@@ -427,7 +427,18 @@ export default function PlexPlaylistView({
playlistId,
next.map((i) => i.id),
)
.then(() => qc.invalidateQueries({ queryKey: qk.plexPlaylists() }))
.then(() => {
// Write the accepted order into the DETAIL cache, not just the list. That cache is what
// `putBack` rolls back to and what a re-entry inside the 30 s staleTime renders without
// refetching — so after an accepted reorder it showed the pre-reorder order again, and the
// next drag from there persisted it, silently undoing the one the server took. The
// YouTube sibling (`Playlists.tsx`) got this in round 5; this half was left behind (round
// 6). One accepted write, one cache entry that matches it.
qc.setQueryData<{ items: PlexCard[] }>(qk.plexPlaylist(playlistId), (old) =>
old ? { ...old, items: next } : old,
);
qc.invalidateQueries({ queryKey: qk.plexPlaylists() });
})
.catch((e: unknown) => {
notifyActionError(e, t("plex.playlist.reorderFailed"));
putBack();