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