`req` opens its blocking dialog for 400/409/422/5xx only. A 403/404/429 was left to the caller and a 502-504 or lost connection got just the self-clearing connection notice, so 34 mutations without a reporting onError and 15 direct api writes with a bare or missing catch failed in silence: a hidden channel that snapped back, a share link that never revoked, a confirmed account delete that ended with nothing said. - lib/queryClient: one MutationCache reporter. react-query awaits it BEFORE the mutation's own onError, so it waits a task and skips a failure the site already reported; the toast title comes from `meta.failTitle`, `meta.errorShownInline` opts out. - notifyActionError: title names WHAT failed, the line under it says WHY (`failureReason`: a structured localized detail, else the status). It marks the error reported (`markReported`), so nothing reports one failure twice; `notifyKnownFailure` for a site's own specific sentence. - Every silent site named (45 new HU/EN titles, 4 reasons). Stale "the global dialog shows it" comments corrected on the job actions, channel block, Plex sync, cookies, download enqueue, account delete and usePref. - Channels passed the tag hook's channel id to the reporter as the error; the tag and priority hooks now report themselves, with the real error. - A contract test keeps every useMutation naming its failure; CLAUDE.md gains the pattern row. - history.dom.test: the "navigates only once the entries are back" assertion raced an async act's macrotask flush (1 in 5 runs); it now asserts from a later popstate listener.
302 lines
11 KiB
TypeScript
302 lines
11 KiB
TypeScript
// @vitest-environment jsdom
|
||
//
|
||
// The overlay history stack in history.ts, driven through its observable effects: which close/exit
|
||
// callback fires, how many programmatic back()s go out, and when a command finally runs.
|
||
//
|
||
// A GENUINE user Back is simulated by dispatching `popstate` directly: the real-Back branch does NOT
|
||
// call history.back() (it only decrements its own historyDepth to match the entry the browser already
|
||
// dropped), so a raw popstate reproduces it deterministically. A PROGRAMMATIC back() — a reconcile
|
||
// reclaiming a closed overlay's entry — is stubbed to land its own popstate, so no real jsdom back()
|
||
// is ever left in flight. The bookkeeping is module-global: every test must leave the stack, the
|
||
// depth and the in-flight count at zero for the next one.
|
||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||
import { renderHook, act } from "@testing-library/react";
|
||
import {
|
||
closeOverlaysThenNavigate,
|
||
closeTopOverlayThenRun,
|
||
useBackToClose,
|
||
useBackToExit,
|
||
} from "./history";
|
||
|
||
// Let the mount effect's coalesced reconcile microtask settle.
|
||
const settle = () =>
|
||
act(async () => {
|
||
await Promise.resolve();
|
||
});
|
||
// Past `afterClosing`'s hop onto a fresh task (a setTimeout 0).
|
||
const tick = () =>
|
||
act(async () => {
|
||
await new Promise((r) => setTimeout(r, 0));
|
||
});
|
||
// A genuine user Back (browser already dropped the entry; popstate arrives with no pending back()).
|
||
const userBack = () =>
|
||
act(() => {
|
||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||
});
|
||
// A programmatic back() that lands its popstate at once — the browser does it 1–13 ms later.
|
||
const stubBack = () =>
|
||
vi.spyOn(window.history, "back").mockImplementation(() => {
|
||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||
});
|
||
/** A Back-closable layer whose close really unmounts it, as a closing dialog does. */
|
||
function mountLayer(onClose: () => void) {
|
||
const holder: { unmount?: () => void } = {};
|
||
const r = renderHook(() =>
|
||
useBackToClose(() => {
|
||
onClose();
|
||
holder.unmount?.();
|
||
}),
|
||
);
|
||
holder.unmount = r.unmount;
|
||
return r;
|
||
}
|
||
|
||
afterEach(() => {
|
||
vi.clearAllMocks();
|
||
});
|
||
|
||
describe("useBackToExit back-ladder", () => {
|
||
it("nested active layers peel off one genuine Back at a time, topmost first (LIFO)", async () => {
|
||
const outer = vi.fn();
|
||
const inner = vi.fn();
|
||
|
||
const ro = renderHook(() => useBackToExit(true, outer));
|
||
await settle();
|
||
const ri = renderHook(() => useBackToExit(true, inner));
|
||
await settle();
|
||
|
||
await userBack(); // peels the topmost (inner) only
|
||
expect(inner).toHaveBeenCalledTimes(1);
|
||
expect(outer).not.toHaveBeenCalled();
|
||
|
||
await userBack(); // now peels the outer
|
||
expect(outer).toHaveBeenCalledTimes(1);
|
||
expect(inner).toHaveBeenCalledTimes(1);
|
||
|
||
ro.unmount();
|
||
ri.unmount();
|
||
});
|
||
|
||
it("stacks above a useBackToClose layer — Back closes the exit layer before the close layer", async () => {
|
||
const close = vi.fn();
|
||
const exit = vi.fn();
|
||
|
||
const rc = renderHook(() => useBackToClose(close));
|
||
await settle();
|
||
const re = renderHook(() => useBackToExit(true, exit));
|
||
await settle();
|
||
|
||
await userBack();
|
||
expect(exit).toHaveBeenCalledTimes(1);
|
||
expect(close).not.toHaveBeenCalled();
|
||
|
||
await userBack();
|
||
expect(close).toHaveBeenCalledTimes(1);
|
||
|
||
rc.unmount();
|
||
re.unmount();
|
||
});
|
||
|
||
it("an inactive layer registers nothing — Back falls straight through to the layer below it", async () => {
|
||
const below = vi.fn();
|
||
const gated = vi.fn();
|
||
|
||
const rb = renderHook(() => useBackToExit(true, below));
|
||
await settle();
|
||
const rg = renderHook(() => useBackToExit(false, gated)); // gated: not active → no layer
|
||
await settle();
|
||
|
||
await userBack();
|
||
expect(gated).not.toHaveBeenCalled();
|
||
expect(below).toHaveBeenCalledTimes(1);
|
||
|
||
rb.unmount();
|
||
rg.unmount();
|
||
});
|
||
});
|
||
|
||
describe("closeOverlaysThenNavigate", () => {
|
||
// Every in-app navigation goes through it (setPage, openChannel). The trap: closing an overlay
|
||
// reclaims its Back-to-close entry with a programmatic back(), and a pushState made before that
|
||
// lands is undone by it — so the command would do nothing but close.
|
||
it("navigates synchronously when no overlay is open", () => {
|
||
const navigate = vi.fn();
|
||
closeOverlaysThenNavigate(navigate);
|
||
expect(navigate).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it("closes every open overlay, topmost first, and navigates only once all their entries are back", async () => {
|
||
const back = stubBack();
|
||
try {
|
||
const closed: string[] = [];
|
||
mountLayer(() => closed.push("player"));
|
||
await settle();
|
||
mountLayer(() => closed.push("palette"));
|
||
await settle();
|
||
|
||
const navigate = vi.fn(() => window.history.pushState({ sfPage: "channels" }, ""));
|
||
act(() => closeOverlaysThenNavigate(navigate));
|
||
expect(closed).toEqual(["palette", "player"]);
|
||
expect(navigate).not.toHaveBeenCalled();
|
||
|
||
// Not inside the popstate dispatch that settled it — a later listener would still apply the
|
||
// OLD entry's page over the push. Asserted FROM such a later listener (registered after the
|
||
// helper's own), not after `settle()`: that async act can flush the unmounts on a macrotask,
|
||
// and the navigate task sometimes ran first — a 1-in-5 false failure (E11 S2b gate).
|
||
const navigatedByPopstate: number[] = [];
|
||
const laterListener = () => navigatedByPopstate.push(navigate.mock.calls.length);
|
||
window.addEventListener("popstate", laterListener);
|
||
try {
|
||
await settle(); // the reconcile reclaims both entries in one go
|
||
expect(back).toHaveBeenCalledTimes(2);
|
||
await tick();
|
||
} finally {
|
||
window.removeEventListener("popstate", laterListener);
|
||
}
|
||
expect(navigatedByPopstate).toEqual([0, 0]);
|
||
expect(navigate).toHaveBeenCalledTimes(1);
|
||
await tick();
|
||
expect(back).toHaveBeenCalledTimes(2); // nothing reclaims over the entry navigate pushed
|
||
expect(window.history.state).toMatchObject({ sfPage: "channels" });
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
|
||
// The old helper ran after "a popstate" OR a 250 ms fallback, whichever came first — so a reclaim
|
||
// slower than the fallback undid the navigation. It must wait for the history, not the clock.
|
||
it("waits for a back() still in flight instead of pushing under it", async () => {
|
||
let land: (() => void) | undefined;
|
||
const back = vi.spyOn(window.history, "back").mockImplementation(() => {
|
||
land = () =>
|
||
window.dispatchEvent(new PopStateEvent("popstate", { state: window.history.state }));
|
||
});
|
||
try {
|
||
mountLayer(() => {});
|
||
await settle();
|
||
const navigate = vi.fn();
|
||
act(() => closeOverlaysThenNavigate(navigate));
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
|
||
await act(async () => {
|
||
await new Promise((r) => setTimeout(r, 300)); // past where the old fallback fired
|
||
});
|
||
expect(navigate).not.toHaveBeenCalled();
|
||
|
||
act(() => land?.());
|
||
await tick();
|
||
expect(navigate).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
|
||
// No such overlay exists today, but a future "discard your changes?" guard must not have the page
|
||
// switched out from under it: dropping the command is the honest outcome.
|
||
it("drops the command when an overlay does not actually close", async () => {
|
||
const back = stubBack();
|
||
const stubborn = renderHook(() => useBackToClose(() => {}));
|
||
await settle();
|
||
vi.useFakeTimers();
|
||
try {
|
||
const navigate = vi.fn();
|
||
act(() => closeOverlaysThenNavigate(navigate));
|
||
act(() => {
|
||
vi.advanceTimersByTime(2000);
|
||
});
|
||
expect(navigate).not.toHaveBeenCalled();
|
||
} finally {
|
||
vi.useRealTimers();
|
||
stubborn.unmount();
|
||
await settle();
|
||
expect(back).toHaveBeenCalledTimes(1); // its entry is still reclaimed on the way out
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
|
||
// Shift+W pressed on Home with the player open: setPage finds nothing to push. The player must
|
||
// still close (owner decision 2026-09-09), and its entry must be reclaimed rather than orphaned —
|
||
// an orphan is a later browser Back that changes nothing on screen.
|
||
it("closes the overlay and reclaims its entry when the navigation turns out to go nowhere", async () => {
|
||
const back = stubBack();
|
||
try {
|
||
const closed = vi.fn();
|
||
mountLayer(closed);
|
||
await settle();
|
||
const navigate = vi.fn(); // setPage's early return: already on that page
|
||
act(() => closeOverlaysThenNavigate(navigate));
|
||
await settle();
|
||
await tick();
|
||
expect(closed).toHaveBeenCalledTimes(1);
|
||
expect(navigate).toHaveBeenCalledTimes(1);
|
||
expect(back).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
|
||
// An overlay closed in the tick it opened never pushed an entry, so no popstate is coming at all.
|
||
it("navigates when the overlay closes before its entry was ever pushed", async () => {
|
||
const back = stubBack();
|
||
try {
|
||
mountLayer(() => {}); // no settle: its reconcile is still queued
|
||
const navigate = vi.fn();
|
||
act(() => closeOverlaysThenNavigate(navigate));
|
||
await settle();
|
||
await tick();
|
||
expect(navigate).toHaveBeenCalledTimes(1);
|
||
expect(back).not.toHaveBeenCalled();
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
});
|
||
|
||
describe("closeTopOverlayThenRun", () => {
|
||
// The palette's rows. Only the palette closes: a theme picked over the player must leave it open.
|
||
it("closes only the top overlay and runs once its entry is back, leaving the one below", async () => {
|
||
const back = stubBack();
|
||
try {
|
||
const playerClosed = vi.fn();
|
||
mountLayer(playerClosed);
|
||
await settle();
|
||
const palette = renderHook(() => useBackToClose(() => {}));
|
||
await settle();
|
||
|
||
const run = vi.fn();
|
||
act(() => closeTopOverlayThenRun(() => palette.unmount(), run));
|
||
expect(run).not.toHaveBeenCalled();
|
||
await settle();
|
||
await tick();
|
||
expect(run).toHaveBeenCalledTimes(1);
|
||
expect(back).toHaveBeenCalledTimes(1); // the palette's entry, and only that
|
||
expect(playerClosed).not.toHaveBeenCalled();
|
||
|
||
await userBack(); // teardown: the player closes by a genuine Back
|
||
expect(playerClosed).toHaveBeenCalledTimes(1);
|
||
await settle();
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
|
||
it("runs exactly once, however many popstates follow", async () => {
|
||
const back = stubBack();
|
||
try {
|
||
const palette = renderHook(() => useBackToClose(() => {}));
|
||
await settle();
|
||
const run = vi.fn();
|
||
act(() => closeTopOverlayThenRun(() => palette.unmount(), run));
|
||
await settle();
|
||
await tick();
|
||
await userBack();
|
||
await userBack();
|
||
await tick();
|
||
expect(run).toHaveBeenCalledTimes(1);
|
||
} finally {
|
||
back.mockRestore();
|
||
}
|
||
});
|
||
});
|