Files
siftlode/frontend/src/lib/actionErrors.ts
T
peter 8a50538275 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.
2026-09-13 01:45:28 +02:00

63 lines
3.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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);
}