fix(ui): a write that fails says what failed and why, wherever it fails

`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.
This commit is contained in:
2026-09-13 01:45:28 +02:00
parent b4aea7fa5b
commit 8a50538275
45 changed files with 851 additions and 132 deletions
+51 -17
View File
@@ -1,28 +1,62 @@
// From the core, not the `./api` barrel: these two are pure (no request), and a test that mocks the
// From the core, not the `./api` barrel: these are pure (no request), and a test that mocks the
// barrel wholesale to stub its requests must not take the error path's own plumbing with it.
import { alreadyReported, HttpError } from "./api/core";
import { alreadyReported, HttpError, isAborted, markReported } from "./api/core";
import { notify } from "./notifications";
import i18n from "../i18n";
const GATEWAY = new Set([502, 503, 504]);
/**
* Surface a failed write action as an error toast instead of the click silently doing nothing.
* Prefers the server's own reason over the caller's already-translated generic fallback — but only
* when the server sent a STRUCTURED detail (`{code, reason}`), which `req` has already run through
* `localizeDetail`. A plain-string `detail` is whatever English the route hand-wrote, and
* `reportFailure` deliberately leaves 403/404/429 to the caller, so exactly those statuses used to
* put the route's English sentence into an otherwise Hungarian UI — "No user with that email.",
* "Not subscribed to this channel" — where the caller's own translated fallback was right there
* (deep-review round 5). Domain-agnostic: used by the Plex watched-toggles and the saved-view ops,
* and delegated to by `notifyYouTubeActionError` for its non-403 case.
* WHY a write failed, in the reader's language — the line under the toast's title.
*
* A STRUCTURED server detail (`{code, reason}`) wins: `req` has already run it through
* `localizeDetail`, and it is the most specific thing there is to say (quota numbers, which row is
* gone). A plain-string `detail` never does — it is whatever English the route hand-wrote, and
* `reportFailure` leaves exactly 403/404/429 to the caller, so those used to put "No user with that
* email." into a Hungarian UI (deep-review round 5). Otherwise the status says it.
*/
export function failureReason(err: unknown): string {
const t = i18n.t.bind(i18n);
if (err instanceof HttpError) {
if (err.detailData != null && err.detail) return err.detail;
// 0: `uploadReq`'s network failure. 502–504: the gateway could not reach the app. Either way the
// change did not go through, which the connection notice alone does not say.
if (err.status === 0 || GATEWAY.has(err.status)) return t("errors.reason.unreachable");
if (err.status === 403) return t("errors.reason.forbidden");
if (err.status === 404) return t("errors.notFound.generic");
if (err.status === 429) return t("errors.reason.rateLimited");
return t("errors.generic");
}
// `fetch` rejects with a TypeError when the request never reached the server.
if (err instanceof TypeError) return t("errors.reason.unreachable");
return t("errors.reason.unexpected");
}
/**
* Surface a failed write as an error toast instead of the click silently doing nothing: the title
* names WHAT failed (the caller's translated "Couldn't hide the channel"), the line under it says
* WHY (`failureReason`). Domain-agnostic — the one way every action failure is reported, including
* the app-wide mutation reporter in lib/queryClient.
*/
export function notifyActionError(
err: unknown,
fallbackMessage: string,
title: string,
level: "error" | "warning" = "error",
): void {
// Already on screen (`req` opened the error dialog, or the tab is being signed out): a toast on
// top reported one fault twice. Playlists filtered this by status by hand; the watched toggles,
// the saved views and a playlist delete did not.
// Already on screen (`req` opened the error dialog, the tab is being signed out, or a caller's own
// `onError` got there first): a second toast reported one fault twice. A cancel is not a failure.
if (alreadyReported(err) || isAborted(err)) return;
notify({ level, title, message: failureReason(err) });
markReported(err);
}
/**
* A failure the caller has a SPECIFIC sentence for, because it read the error's own code ("this
* instance cannot send email", "someone registered on that address") — shown as it is, and marked,
* so the app-wide mutation reporter does not put a second, generic toast next to it.
*/
export function notifyKnownFailure(err: unknown, message: string): void {
if (alreadyReported(err)) return;
const localized = err instanceof HttpError && err.detailData != null ? err.detail : null;
notify({ level, message: localized || fallbackMessage });
notify({ level: "error", message });
markReported(err);
}