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