fix(playlists): a failed background refetch keeps the list on screen, and the player outlives every branch

Deep-review round 3 of bug hunt 2026-09.

- The detail's error branch applies only when there is nothing to show (`isError && <no data>`, the
  QueryState convention): one failed refetch — on focus, after a move or a removal — replaced a
  loaded list with "couldn't load". The error line is announced (role=alert).
- Tests for the two branches of "the player survives a change of selection" that round 2 left
  untested (a list that fails to load, no selection).
This commit is contained in:
2026-09-11 18:05:19 +02:00
parent 1cfe6a7f59
commit 76b5f7c93e
2 changed files with 43 additions and 3 deletions
@@ -221,6 +221,23 @@ describe("Playlists — removing a video from inside the player", () => {
});
});
describe("Playlists — a failed refetch of a list that is on screen", () => {
it("keeps the list, instead of replacing it with the error", async () => {
// A background refetch — on focus, after a move or a removal — failing during an API restart
// replaced a loaded list with "couldn't load" (the `isError && <no data>` convention, ignored).
mount({ 1: [vid("V", 0)] });
await screen.findByRole("button", { name: "card.play — Video V" });
vi.mocked(api.playlist).mockRejectedValue(new HttpError(502, "gateway"));
await act(async () => {
await qc.invalidateQueries({ queryKey: qk.playlist(1) });
await new Promise((r) => setTimeout(r, 20)); // react-query notifies its observers on a timer
});
expect(qc.getQueryState(qk.playlist(1))?.status).toBe("error");
expect(screen.queryByText("playlists.loadFailed")).toBeNull();
expect(screen.getByRole("button", { name: "card.play — Video V" })).toBeTruthy();
});
});
describe("Playlists — the player outlives a change of selection", () => {
it("stays mounted while a newly selected list is still loading", async () => {
// The page swaps itself for "Loading…" until the new list arrives; with the player rendered only
@@ -235,6 +252,27 @@ describe("Playlists — the player outlives a change of selection", () => {
expect(h.mounted).toBe(true);
});
it("stays mounted when the newly selected list fails to load — a list deleted elsewhere", async () => {
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
vi.mocked(api.playlist).mockRejectedValue(new HttpError(404, "gone"));
h.selectedId = 2;
rerender();
await screen.findByText("playlists.loadFailed");
expect(h.mounted).toBe(true);
});
it("stays mounted when nothing is selected any more", async () => {
const { rerender } = mount({ 1: [vid("V", 0)] });
await play("Video V");
await waitFor(() => expect(h.mounted).toBe(true));
h.selectedId = null;
rerender();
await screen.findByText("playlists.pickOne");
expect(h.mounted).toBe(true);
});
it("refreshes the list it was launched from when the viewer marks the video in it", async () => {
// Invalidating the SELECTED list left the launch list's row showing the old status.
vi.spyOn(api, "setState").mockResolvedValue(undefined as never);
+5 -3
View File
@@ -813,11 +813,13 @@ export default function Playlists() {
);
}
// The page used to render "Loading…" forever when the detail query failed — an error read as a
// slow network that never arrived.
if (detailQuery.isError) {
// slow network that never arrived. Only with nothing to show, though (the `isError && <no data>`
// convention, see ui/QueryState): a background refetch that failed — on focus, after a move or a
// removal — replaced a list that was on screen with this message.
if (detailQuery.isError && !detail) {
return (
<>
<div className="text-sm p-4 flex flex-col items-start gap-3">
<div role="alert" className="text-sm p-4 flex flex-col items-start gap-3">
<span className="inline-flex items-center gap-2 text-muted">
<AlertTriangle className="w-4 h-4 text-danger" />
{t("playlists.loadFailed")}