`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.
63 lines
3.1 KiB
TypeScript
63 lines
3.1 KiB
TypeScript
// 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, isAborted, markReported } from "./api/core";
|
||
import { notify } from "./notifications";
|
||
import i18n from "../i18n";
|
||
|
||
const GATEWAY = new Set([502, 503, 504]);
|
||
|
||
/**
|
||
* 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,
|
||
title: string,
|
||
level: "error" | "warning" = "error",
|
||
): void {
|
||
// 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;
|
||
notify({ level: "error", message });
|
||
markReported(err);
|
||
}
|