Files
biggus-dickus/docs/superpowers/specs/2026-06-07-toast-notifications-design.md
T
2026-06-07 12:09:09 +02:00

7.5 KiB

Toast Notifications + Consistent Mutation Feedback — Design

Date: 2026-06-07 Status: Approved (brainstorming) — ready for implementation planning. Issue: #47.

Context

Mutations across the app communicate success only by side effects (navigation, a dialog closing) — a curator doing bulk cataloguing can't tell whether a save landed, which invites double-submits and re-checking. Errors are inconsistent: inline <p role="alert"> in the forms, in-dialog for deletes, three message kinds in publish-control, and some mutation failures surface nowhere. There is no toast/notification system (no toast/sonner dep).

Facts established during exploration

  • The QueryClient is created at module scope in web/src/main.tsx (outside React), with defaultOptions.queries only — no mutation defaults yet.
  • Base UI ships a toast primitive (@base-ui/react/toast) — no new dependency. It exports createToastManager(), an out-of-React manager you pass to <Toast.Provider> and can .add() from anywhere — the clean bridge to the module-scope queryClient handlers.
  • The i18n singleton (web/src/i18n) exposes i18n.t(...), callable outside React.
  • Existing typed mutation errors (web/src/api/queries.ts): InUseError (409 + count), FieldRejection (422 field), HttpError (status), VisibilityError. The object form shows 422 as a field highlight; the delete dialogs catch errors and show them inline.

Decisions (from brainstorming)

  1. Base UI Toast, bridged via a module-scope createToastManager() (no new dep, consistent with the combobox/tooltip/drawer wrappers).
  2. Wiring: global MutationCache + per-mutation meta. A global onError is a catch-all safety net (no silent failures); a global onSuccess shows a toast only when a mutation declares meta.successMessage. Mutations that report errors inline opt out via meta.suppressErrorToast.
  3. Keep inline field/dialog errors (422 highlight, 409 "in use") — toasts add success confirmations + a catch-all for otherwise-silent failures, not a replacement.

Components

ui/toast.tsx (new) + the manager

  • Module scope (alongside the QueryClient — main.tsx or a small web/src/toast/ module): export const toastManager = Toast.createToastManager();
  • web/src/components/ui/toast.tsx — wrap the Base UI Toast parts (Provider / Viewport / Portal / Positioner / Root / Title / Description / Close) in the established ui/* style (data-slot, cn(), mirror ui/alert-dialog.tsx). Provide a <ToastRegion> that renders Toast.Provider (with toastManager) + a Toast.Viewport listing the active toasts (mapped from useToastManager().toasts), styled as stacked cards (success vs error variant via the toast's type/data). The viewport must be an aria-live region (Base UI handles the live semantics; confirm). Base UI Toast is novel in this repo → the exact part tree + manager API (createToastManager, manager.add({ title?, description, type }), useToastManager) must be validated by running (as the combobox/drawer/tooltip were).
  • Mount <ToastRegion> in main.tsx inside the provider tree (so it's on every screen).

Global mutation feedback (main.tsx / queryClient)

const queryClient = new QueryClient({
  defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
  mutationCache: new MutationCache({
    onError: (error, _vars, _ctx, mutation) => {
      if (mutation.meta?.suppressErrorToast) return;
      toastManager.add({ type: "error", description: errorMessage(error, mutation) });
    },
    onSuccess: (_data, _vars, _ctx, mutation) => {
      const key = mutation.meta?.successMessage;
      if (key) toastManager.add({ type: "success", description: i18n.t(key) });
    },
  }),
});
  • errorMessage(error, mutation): mutation.meta?.errorMessage ? i18n.t(...) else a type-aware default — InUseErrori18n.t("actions.inUse", { count }), HttpError 503 → i18n.t("search.unavailable"), otherwise i18n.t("toast.error").
  • meta is typed via a module augmentation of @tanstack/react-query's Register interface so meta.successMessage/meta.errorMessage/meta.suppressErrorToast are type-checked (no any).

Per-mutation meta (web/src/api/queries.ts)

  • meta.successMessage (a toast.* key) on the discrete actions: object create/update/ delete, set-visibility/publish, vocabulary create/rename/delete, term create/update/delete, authority create/update/delete, field-definition create/update/delete.
  • meta.suppressErrorToast: true on mutations that surface errors inline: the object form's create/update + useSetFields (422 field highlight), useLogin, and the delete hooks (the DeleteConfirmDialog/DeleteObjectDialog show the 409/rejection inline). These keep success toasts.

Data flow

A mutation runs → react-query's MutationCache fires the global onSuccess/onError → reads mutation.metatoastManager.add(...) (message via i18n.t) → <ToastRegion> (subscribed to the manager) renders the toast in its portal/viewport → auto-dismisses (Base UI default) or on Close. Inline field/dialog errors render as before (unaffected).

Error handling / edges

  • A mutation with neither meta nor inline handling → still gets the catch-all error toast on failure (the safety net); silent success (no successMessage) is intentional where navigation already signals it.
  • No double-report: inline-handling mutations set suppressErrorToast.
  • i18n.t outside React uses the singleton's current language (already synced via the locale hook); a missing key falls back to the generic toast.error.
  • Toasts stack + auto-dismiss; an error toast may stay longer / be dismissible (Base UI config).

Testing

  • Vitest + RTL + MSW: a mutation declaring meta.successMessage shows a success toast (query within(document.body)); a failing mutation (MSW 500) shows an error toast; a suppressErrorToast mutation does not add a toast on error (no double-report) while its inline error still renders; the toast region is present and aria-live. Drive these through a real screen action (e.g. create a vocabulary → "Created" toast; a 500 → error toast).
  • Storybook: a ui/toast story rendering a success + an error toast (via the manager), per the standing preference; verify the Base UI composition by running.
  • Reuse existing screens' MSW handlers (web/src/test/handlers.ts).

Acceptance criteria

  1. A Base UI toast region is mounted app-wide; toastManager (out-of-React) is .add-able from the queryClient handlers. No new npm dependency.
  2. Global MutationCache: onError toasts a catch-all (type-aware) message unless meta.suppressErrorToast; onSuccess toasts meta.successMessage when present.
  3. The discrete CRUD/publish mutations declare meta.successMessage; inline-error mutations (object form, login, deletes) set meta.suppressErrorToast and keep their inline UX.
  4. meta is type-checked (react-query Register augmentation); no any/eslint-disable.
  5. en/sv parity for the toast.* keys; a toast Storybook story; pnpm typecheck/lint/test/ build green; check:size within 180 KB gz; no codename.

Out of scope → follow-ups

  • Replacing the inline 422 field-highlight / 409 "in use" UX with toasts.
  • Undo/action toasts, queued/grouped toasts, per-account toast preferences.
  • Toasting query (read) errors — this milestone covers mutations (the silent-write problem).