13 KiB
Consistent, Status-Aware Mutation Error Feedback — Design
Date: 2026-06-08 Status: Approved (brainstorming) — ready for implementation planning. Issue: #63 (silent update failures + dead/unreachable mutation error strings).
Context
A frontend deep audit flagged the TanStack Query mutation layer for inconsistent error feedback. Grounding in the code refined the picture:
- The update mutations are not silent — they're inconsistent.
useUpdateTerm(queries.ts:444) anduseUpdateAuthority(:521) are the only 2 of 18 mutations withoutmeta.suppressErrorToast, so on failure they fire the globalMutationCacheerror toast while the row stays in edit mode. The matching create and delete actions on the same screens suppress the toast and show an inline message instead. So within one screen, create-failure → inline, delete-failure → inline, edit-failure → a disconnected global toast. - Once the 2 update mutations also suppress (the consistency fix), all 18 mutations suppress → the
global error toast becomes vestigial. Therefore status differentiation only delivers value at the
inline error sites, which today all render a generic
t("form.rejected")("Could not be saved"). - The generic error strings are dead. 16 mutations
throw new Error("update failed" | …), butmutationErrorMessage(query-client.ts:11-23) never reads.message— it falls back tot("toast.error"). The bespoke strings are unreachable. object-edit-formmislabels a fetch failure.objects/object-edit-form.tsx:17destructures only{ data, isLoading }fromuseObject; a non-404 fetch error (network/500) falls through to the "object not found" branch. (The submit path already handlesFieldRejectionand a generic else correctly.)
Decisions (from brainstorming)
- Differentiate errors by HTTP status, surfaced via one shared mapping.
- Because all mutations suppress the toast, apply status-aware messages at all inline error sites
(via one shared
<MutationError>component), not the (now-vestigial) toast. Half-applying would create a new inline inconsistency. - Make
useUpdateTerm/useUpdateAuthorityconsistent with their create/delete siblings (suppress + inline). - Fix the
object-edit-formfetch mislabel.
Components
web/src/api/error-message.ts (new) — single source of truth
import { HttpError, InUseError } from "./queries";
/** Maps a caught mutation error to an i18n key (+ interpolation opts). Used by
* both the global toast fallback and every inline error display. */
export function errorMessageKey(error: unknown): { key: string; opts?: Record<string, unknown> } {
if (error instanceof InUseError) return { key: "actions.inUse", opts: { count: error.count } };
if (error instanceof HttpError) return { key: statusKey(error.status) };
return { key: "toast.error" };
}
function statusKey(status: number): string {
if (status === 403) return "errors.forbidden";
if (status === 404) return "errors.notFound";
if (status === 409) return "errors.conflict";
if (status === 422) return "errors.validation";
if (status >= 500) return "errors.server";
return "toast.error";
}
Returns a key+opts (not a resolved string) so callers render with their own t (reactive to language).
InUseError is checked first so 409-with-count keeps its richer message. (No circular import:
queries.ts does not import this module; query-client.ts imports both.)
web/src/components/mutation-error.tsx (new) — shared inline display
import { useTranslation } from "react-i18next";
import { errorMessageKey } from "../api/error-message";
/** Renders a caught mutation error as an inline alert, or nothing when error is falsy.
* Replaces the duplicated `<p role="alert" className="text-xs text-destructive">` markup. */
export function MutationError({ error }: { error: unknown }) {
const { t } = useTranslation();
if (!error) return null;
const { key, opts } = errorMessageKey(error);
return (
<p role="alert" className="text-xs text-destructive">
{t(key, opts)}
</p>
);
}
web/src/api/query-client.ts (rewire)
mutationErrorMessage becomes:
function mutationErrorMessage(error: unknown, meta: MutationMeta | undefined): string {
if (meta?.errorMessage) return i18n.t(meta.errorMessage);
const { key, opts } = errorMessageKey(error);
return i18n.t(key, opts);
}
This keeps the toast path as a one-source-of-truth fallback (for any future non-suppressed mutation) and
drops the old special-cases (InUseError and 503 → search.unavailable now flow through
errorMessageKey; no mutation throws 503, and the search query's own inline 503 handling in
search-panel.tsx is untouched). Import moves from { HttpError, InUseError } to { errorMessageKey }.
web/src/api/queries.ts (load-bearing errors)
Replace the 16 mutation throw new Error("…") with throw new HttpError(response.status), preserving
the existing InUseError (409) and FieldRejection (422 on setFields) branches. Two POSTs
(useCreateObject :184, useCreateVocabulary :279) destructure only { data, error } — add
response so the status is available. Query fetch errors (:38,73,90,105,152,169,264) and the login
"invalid"/"network" mapping (:121) are NOT changed (they're consumed by component isError/login
mapping, not the mutation toast). useUpdateObject's 422 maps to the generic errors.validation;
surfacing a structured core-field rejection there is uncertain backend behavior → out of scope.
Inline-site adoption — replace generic form.rejected with <MutationError>
web/src/components/delete-confirm-dialog.tsx:33-42— theconfirmcatch currently setserr instanceof InUseError ? t("actions.inUse", {count}) : t("form.rejected"). Replace withconst { key, opts } = errorMessageKey(err); setMessage(t(key, opts));—errorMessageKeyalready handlesInUseError, so the dialog simplifies and a 403/404 delete now reads a specific message. (The dialog keeps its ownmessagestate because it must stay open on failure; it does not render<MutationError>directly.)web/src/authorities/authorities-page.tsx:144-148— replace the{create.isError && <p…>form.rejected}with<MutationError error={create.error} />.web/src/vocab/vocabulary-terms.tsx:119-123—<MutationError error={addTerm.error} />.web/src/vocab/vocabulary-list.tsx:57-61(create) and:109-113(rename) —<MutationError error={create.error} />and<MutationError error={renameVocabulary.error} />.web/src/fields/field-form.tsx:202-204— replace the{failed && <p…>form.rejected}with<MutationError error={isEdit ? update.error : create.error} />.- Object create/edit:
object-new-page.tsxandobject-edit-form.tsxkeep theFieldRejectionfield-specific branch; in the non-FieldRejectionelse, replacesetError(t("form.rejected"))withconst { key, opts } = errorMessageKey(e); setError(t(key, opts));(these set a string passed toObjectForm'sformErrorprop, so they useerrorMessageKeydirectly, not<MutationError>).
Edit-row consistency — term-row.tsx, authority-row.tsx, queries.ts
- Add
suppressErrorToast: truetouseUpdateTerm(:444) anduseUpdateAuthority(:521) meta. - In each row's edit view, render
<MutationError error={updateTerm.error} />(resp.updateAuthority.error) below the save/cancel buttons. - Call
updateTerm.reset()(resp.updateAuthority.reset()) inside the Edit button'sonClick(alongside the existingsetLabels/setUri/setEditing) so a stale error from a prior failed save doesn't linger when re-entering edit mode. On a failed save the row already stays editable (theonSuccesssetEditing(false)doesn't fire), preserving the user's input.
web/src/objects/object-edit-form.tsx (fetch fix)
const { data: object, isLoading, isError } = useObject(id!);
if (isLoading) return <FormSkeleton />;
if (isError) return <p className="p-4 text-sm text-destructive">{t("objects.loadError")}</p>;
if (!object) return <p className="p-4 text-sm text-muted-foreground">{t("objects.notFound")}</p>;
objects.loadError ("Could not load objects") already exists in both locales.
i18n (en + sv parity — 5 new keys under errors)
| key | en | sv |
|---|---|---|
errors.forbidden |
You don't have permission to do that. | Du har inte behörighet att göra det. |
errors.notFound |
That item no longer exists. | Objektet finns inte längre. |
errors.conflict |
That conflicts with existing data. | Det står i konflikt med befintliga data. |
errors.validation |
Some values weren't accepted. | Vissa värden godtogs inte. |
errors.server |
The server had a problem. Please try again. | Servern hade ett problem. Försök igen. |
Data flow
mutation rejects → HttpError(status) (or InUseError/FieldRejection) → caught by the component (or
the MutationCache fallback) → errorMessageKey(error) → {key, opts} → t(key, opts) rendered inline
via <MutationError> (or as a formError string / dialog message). One mapping, one component, every
surface.
Error handling / edges
- All mutations suppress after this change → the
MutationCache.onErrortoast path is a dormant fallback; kept (not deleted) so a future non-suppressed mutation still gets a sensible message. - 401 isn't in the status map → falls to
toast.error; theclient.tsmiddleware still redirects to login (unchanged). A brief generic toast before redirect is pre-existing, not worsened. - Network error with no response: openapi-fetch may reject before a
responseexists; those paths keep throwing a genericError→errorMessageKeyreturnstoast.error. (Only status-bearing failures get differentiated.) - Language reactivity:
<MutationError>and the object-formformErrorre-render with the active locale because they calltat render (theformErrorstring is recomputed on the next failed submit; acceptable — error strings are transient). - Stale row error: cleared via
mutation.reset()on re-edit.
Testing
web/src/api/error-message.test.ts(unit): each status → expected key (403/404/409/422/500/502);InUseError(3)→{ key: "actions.inUse", opts: { count: 3 } }; a bareError/unknown →{ key: "toast.error" }.web/src/components/mutation-error.test.tsx: renders the mapped text for anHttpError(403); renders nothing fornull/undefined; renders the in-use count forInUseError.web/src/api/mutation-feedback.test.tsx(rework): the "non-suppressed → catch-all toast" test is obsolete (all mutations now suppress). Replace it with a test that a suppressed mutation failing adds no toast (keep the existing delete case), plus assert the success-toast case still works. The status→message behavior is covered byerror-message.test.ts.term-row/authority-row(extend existing or viamutation-feedback): a failed update renders the inlineMutationErrortext and the row stays editable; a successful update closes the editor; re-entering edit after a failure shows no stale error.object-edit-form: auseObjectfetch error (mock/api/admin/objects/{id}→ 500) rendersobjects.loadError, notobjects.notFound.- Inline adoption: at least one create-form test (e.g. authorities) asserts a failed create shows the
status-aware message via
MutationError(e.g. 403 →errors.forbidden). - Gate:
typecheck/lint/test/build/check:size/check:colorsgreen; en/sv parity (the #60 parity test guards the 5 new keys); no codename; no new dependency.
Acceptance criteria
- A shared
errorMessageKey(error)mapsInUseErrorandHttpError(by status: 403/404/409/422/≥500) to i18n keys, with atoast.errorfallback; unit-tested. - A shared
<MutationError error>component renders the mapped inline alert (or nothing) and replaces the duplicatedform.rejectedmarkup at every inline mutation-error site (delete dialog via the helper directly, create/rename forms, edit rows, object-formformErrorvia the helper). - The 16 mutation throws use
HttpError(status)(queries unchanged);query-client.tsroutes the toast fallback througherrorMessageKey. useUpdateTerm/useUpdateAuthoritysuppress the toast and show an inline error at the row, staying editable on failure and clearing stale errors on re-edit.object-edit-formdistinguishes a fetch error (objects.loadError) from "not found."typecheck/lint/test/build/check:colorsgreen;check:sizereported; en/sv parity (5 new keys); no codename; no new dependency.
Out of scope → follow-ups
- Structured field-level rejection on core-object update 422 (uncertain backend shape).
- The broader
queries.tssplit + relocating the error classes toapi/errors.ts(tracked in #65 —error-message.tsimports the classes fromqueries.tsfor now). - Toast-based error surfacing (deliberately superseded by inline; the toast path remains only as a fallback).
- Retry affordances / optimistic updates.