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