diff --git a/frontend/src/components/PlexCollectionView.dom.test.tsx b/frontend/src/components/PlexCollectionView.dom.test.tsx
index 5255e399..bffa6610 100644
--- a/frontend/src/components/PlexCollectionView.dom.test.tsx
+++ b/frontend/src/components/PlexCollectionView.dom.test.tsx
@@ -20,6 +20,8 @@ beforeAll(() => {
}
}
globalThis.ResizeObserver ??= Stub as unknown as typeof ResizeObserver;
+ // …and the Sort control's popover watches its anchor with an IntersectionObserver.
+ globalThis.IntersectionObserver ??= Stub as unknown as typeof IntersectionObserver;
});
vi.mock("react-i18next", async (importOriginal) => {
@@ -126,3 +128,60 @@ describe("PlexCollectionView — Play-all", () => {
expect(onPlay).not.toHaveBeenCalled();
});
});
+
+// Bug-hunt deep-review: the Sort control put the sort into the detail query's key, and two things
+// on this page did not follow it.
+describe("PlexCollectionView — after the sort joined the query key", () => {
+ it("flips a poster's watched state on the page it was clicked on", async () => {
+ // The optimistic write kept the key's OLD spelling — an entry nothing reads — and the resync
+ // only touches the library root, so the click did nothing visible until a reload.
+ vi.spyOn(api, "plexSetState").mockResolvedValue(undefined as never);
+ mount([card("movie1", "movie")]);
+ fireEvent.click(await screen.findByRole("button", { name: "plex.markWatched" }));
+ expect(await screen.findByRole("button", { name: "plex.markUnwatched" })).toBeTruthy();
+ });
+
+ it("keeps the page, and the open Sort panel, on screen while the new order loads", async () => {
+ // Every sort change was a key with no data yet: the whole page — the control and its open panel
+ // included — was swapped for a spinner, so key and direction could not be set in one visit.
+ vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
+ vi.spyOn(api, "plexCollection")
+ .mockResolvedValueOnce(detail([card("movie1", "movie"), card("movie2", "movie")]))
+ .mockReturnValueOnce(new Promise(() => {})); // the re-sorted page is still on its way
+ mount([card("movie1", "movie"), card("movie2", "movie")]);
+ fireEvent.click(await screen.findByRole("button", { name: /^plex\.filter\.sort:/ }));
+ // Popover rows are portalled and only exist while it is open, so `hidden: true`.
+ const other = (await screen.findAllByRole("menuitemradio", { hidden: true })).find(
+ (o) => o.getAttribute("aria-checked") !== "true",
+ );
+ fireEvent.click(other!);
+ await waitFor(() => expect(api.plexCollection).toHaveBeenCalledTimes(2));
+ expect(screen.queryByText("plex.loading")).toBeNull();
+ expect(screen.getAllByRole("menuitemradio", { hidden: true }).length).toBeGreaterThan(1);
+ expect(screen.getByText("plex.collection.playAll")).toBeTruthy();
+ });
+
+ it("does not show the previous collection while stepping to another one", async () => {
+ // Keeping the page on screen is for a re-sort of THIS collection only.
+ vi.spyOn(api, "plexCollection")
+ .mockResolvedValueOnce(detail([card("movie1", "movie")]))
+ .mockReturnValueOnce(new Promise(() => {}));
+ const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ const view = (id: string) => (
+
+ {}}
+ onPlay={() => {}}
+ onOpenCard={() => {}}
+ onInfoCard={() => {}}
+ />
+
+ );
+ const { rerender } = render(view("col1"));
+ await screen.findByText("plex.collection.playAll");
+ rerender(view("col2"));
+ expect(await screen.findByText("plex.loading")).toBeTruthy();
+ expect(screen.queryByText("plex.collection.playAll")).toBeNull();
+ });
+});
diff --git a/frontend/src/components/PlexCollectionView.tsx b/frontend/src/components/PlexCollectionView.tsx
index 7aabdaba..bb901c06 100644
--- a/frontend/src/components/PlexCollectionView.tsx
+++ b/frontend/src/components/PlexCollectionView.tsx
@@ -69,9 +69,19 @@ export default function PlexCollectionView({
"plexCollectionSort",
COLLECTION_SORT_DEFAULT,
);
+ // ONE key for the read and every write into it: when the sort joined the key, the watched toggle's
+ // optimistic write kept the old spelling and landed on an entry nothing reads (setQueryData matches
+ // exactly), so the click did nothing visible until a reload.
+ const detailKey = qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir);
const q = useQuery({
- queryKey: qk.plexCollections("detail", collectionId, sortPref.key, sortPref.dir),
+ queryKey: detailKey,
queryFn: () => api.plexCollection(collectionId, sortPref),
+ // A re-sort keeps the current page on screen while the new order loads. Without it every sort
+ // change was a key with no data yet, and the whole page — the Sort control and its open panel
+ // with it — was swapped for a spinner, so key and direction could not be set in one visit. Only
+ // within the SAME collection: stepping to another one must not show this one's hero meanwhile.
+ placeholderData: (prev, prevQuery) =>
+ prevQuery?.queryKey[2] === collectionId ? prev : undefined,
});
const d = q.data;
@@ -146,7 +156,7 @@ export default function PlexCollectionView({
const toggleWatched = (card: PlexCard) =>
toggleWatchState(card, t, {
optimistic: (next) => {
- qc.setQueryData(qk.plexCollections("detail", collectionId), (old) =>
+ qc.setQueryData(detailKey, (old) =>
old
? {
...old,