Files
biggus-dickus/docs/superpowers/plans/2026-06-07-toast-notifications.md
2026-06-07 12:30:30 +02:00

14 KiB
Raw Permalink Blame History

Toast Notifications + Consistent Mutation Feedback — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans. Steps use checkbox (- [ ]) syntax.

Goal: Add a Base UI toast system bridged to the out-of-React QueryClient, so every mutation gives consistent feedback — a per-mutation success toast (opt-in via meta.successMessage) and a catch-all error toast (unless meta.suppressErrorToast) — while keeping the existing inline 422/409 UX.

Architecture: A module-scope createToastManager() is passed to a <ToastRegion> (Toast.Provider + portaled viewport) mounted app-wide, and .add()-ed from a MutationCache on the QueryClient (onError/onSuccess read mutation.meta + i18n.t outside React). The 18 mutation hooks declare meta. meta is type-checked via a react-query Register augmentation.

Tech Stack: React 19 + TS + pnpm, @base-ui/react toast (already a dep), TanStack Query, react-i18next, Vitest+RTL+MSW, Storybook 10.

Conventions: pnpm; no any/eslint-disable/@ts-ignore; component source double-quote+semicolon, stories single-quote+no-semicolon; en/sv parity; no codename; portal queries via within(document.body); check:size ≤ 180 KB gz.

Spec: docs/superpowers/specs/2026-06-07-toast-notifications-design.md

Base UI Toast facts (validated from the d.ts): createToastManager(){ add(opts) => id, close, update, promise } (works outside React; add({ title?, description?, type?, timeout?, priority? }), type is a free-form string, re-add with same id updates in place). Toast.Provider accepts toastManager. Render: const { toasts } = Toast.useToastManager(); toasts.map(t => <Toast.Root toast={t}>…). Parts: Provider / Viewport / Portal / Positioner / Root(requires toast prop) / Title(<h2>) / Description(<p>) / Close(<button>) / Action / Arrow. Title/Description read the toast's title/description from ToastRootContext (no children needed — verify by running). The wrapper pattern to mirror is web/src/components/ui/alert-dialog.tsx.


Task 1: Toast infrastructure (manager, region, MutationCache wiring, meta typing, i18n, story)

Files: create web/src/toast/toast-manager.ts, web/src/components/ui/toast.tsx, web/src/components/ui/toast.stories.tsx, web/src/api/react-query.d.ts; modify web/src/main.tsx, web/src/i18n/{en,sv}.json.

  • Step 1: Module-scope manager web/src/toast/toast-manager.ts:
import { createToastManager } from "@base-ui/react/toast";

/** A toast manager created outside React so non-React code (the QueryClient
 *  MutationCache) can add toasts. Passed to <Toast.Provider toastManager=…>. */
export const toastManager = createToastManager();
  • Step 2: ui/toast.tsx — wrap the Base UI Toast parts (mirror ui/alert-dialog.tsx: data-slot, cn()), and export a <ToastRegion>:
import { Toast as ToastPrimitive } from "@base-ui/react/toast";

import { cn } from "@/lib/utils";
import { toastManager } from "@/toast/toast-manager";

function ToastList() {
  const { toasts } = ToastPrimitive.useToastManager();
  return toasts.map((toast) => (
    <ToastPrimitive.Root
      key={toast.id}
      toast={toast}
      data-slot="toast"
      className={cn(
        "flex items-start gap-2 rounded-md border bg-white p-3 text-sm shadow-md",
        toast.type === "error" && "border-red-300",
      )}
    >
      <div className="flex-1">
        {toast.title && <ToastPrimitive.Title data-slot="toast-title" className="font-medium" />}
        <ToastPrimitive.Description data-slot="toast-description" className="text-neutral-700" />
      </div>
      <ToastPrimitive.Close
        data-slot="toast-close"
        aria-label="Close"
        className="text-neutral-400 hover:text-neutral-700"
      >
        ×
      </ToastPrimitive.Close>
    </ToastPrimitive.Root>
  ));
}

/** App-wide toast region: provides the external manager + a portaled viewport. */
export function ToastRegion({ children }: { children: React.ReactNode }) {
  return (
    <ToastPrimitive.Provider toastManager={toastManager}>
      {children}
      <ToastPrimitive.Portal>
        <ToastPrimitive.Viewport className="fixed bottom-4 right-4 z-50 flex w-80 flex-col gap-2">
          <ToastList />
        </ToastPrimitive.Viewport>
      </ToastPrimitive.Portal>
    </ToastPrimitive.Provider>
  );
}

Validate by running (first toast in the repo): confirm Title/Description auto-render the toast's title/description from context (if they DON'T, pass {toast.title}/{toast.description} as children); confirm Viewport/Positioner nesting (Base UI may require a Toast.Positioner inside the viewport per toast — adjust to the real API when the story runs). Keep the styled-by-type distinction.

  • Step 3: meta typing web/src/api/react-query.d.ts:
import "@tanstack/react-query";

declare module "@tanstack/react-query" {
  interface Register {
    mutationMeta: {
      /** i18n key for a success toast (opt-in). */
      successMessage?: string;
      /** i18n key overriding the default error toast message. */
      errorMessage?: string;
      /** Skip the global error toast (the component shows the error inline). */
      suppressErrorToast?: boolean;
    };
  }
}
  • Step 4: Wire the MutationCache in web/src/main.tsx. Import the manager, the i18n instance (the configured singleton — confirm the default export of web/src/i18n; the app already does import "./i18n"), and the typed errors:
import { MutationCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
import i18n from "./i18n";
import { toastManager } from "./toast/toast-manager";
import { ToastRegion } from "./components/ui/toast";
import { InUseError, HttpError } from "./api/queries";
import type { MutationMeta } from "@tanstack/react-query"; // for the helper's param type

function mutationErrorMessage(error: unknown, meta: MutationMeta | undefined): string {
  if (meta?.errorMessage) return i18n.t(meta.errorMessage);
  if (error instanceof InUseError) return i18n.t("actions.inUse", { count: error.count });
  if (error instanceof HttpError && error.status === 503) return i18n.t("search.unavailable");
  return i18n.t("toast.error");
}

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: mutationErrorMessage(error, mutation.meta) });
    },
    onSuccess: (_data, _vars, _ctx, mutation) => {
      if (mutation.meta?.successMessage) {
        toastManager.add({ type: "success", description: i18n.t(mutation.meta.successMessage) });
      }
    },
  }),
});

And mount the region around <App/>:

<QueryClientProvider client={queryClient}>
  <ConfigProvider>
    <ToastRegion>
      <App />
    </ToastRegion>
  </ConfigProvider>
</QueryClientProvider>

(If i18n has no default export, import the instance it does export, or import i18n from "i18next" only if that's the configured instance — use whatever web/src/i18n exports; the goal is the configured instance so t resolves the app's keys/language.)

  • Step 5: i18n — add a toast namespace to both en.json + sv.json: { "created": "Created"/"Skapat", "saved": "Saved"/"Sparat", "updated": "Updated"/"Uppdaterat", "deleted": "Deleted"/"Borttaget", "renamed": "Renamed"/"Namn ändrat", "published": "Visibility updated"/"Synlighet uppdaterad", "error": "Something went wrong"/"Något gick fel" }.

  • Step 6: Story web/src/components/ui/toast.stories.tsx — render <ToastRegion> and, in play, call toastManager.add({ type: "success", description: "Saved" }) (and an error one), asserting the toast text appears (portal → within(document.body)). Mirror the established story format. This is the validation that the Base UI composition is correct — iterate until green.

  • Step 7: cd web && pnpm test -- toast && pnpm typecheck && pnpm lint. The toast must actually render. Commit feat(web): Base UI toast region + global mutation feedback wiring (#47).


Task 2: Declare meta on the mutation hooks + integration tests

Files: web/src/api/queries.ts; a test (e.g. web/src/objects/publish-control.test.tsx or a new web/src/api/mutation-feedback.test.tsx).

Add a meta option to each useMutation({...}) per the rule:

  • meta.successMessage (a toast.* key) on every discrete user action.
  • meta.suppressErrorToast: true on mutations whose consuming component already renders the error inline (so no double-report).
Hook successMessage suppressErrorToast Why suppress
useCreateObject toast.created yes object form shows form.rejected inline
useUpdateObject toast.saved yes object form inline
useSetFields — (the create/update toast covers the save) yes 422 field-highlight inline; no own success toast to avoid a double "saved"
useDeleteObject toast.deleted yes DeleteObjectDialog shows error inline
useSetVisibility toast.published yes publish-control shows error inline
useLogin yes login page shows error inline
useLogout fire-and-forget
useCreateVocabulary toast.created yes vocab create form shows form.rejected
useRenameVocabulary toast.renamed yes vocab rename shows form.rejected
useDeleteVocabulary toast.deleted yes delete dialog inline (409)
useAddTerm toast.created yes add-term form inline
useUpdateTerm toast.saved no TermRow has no inline error → let the toast be the feedback
useDeleteTerm toast.deleted yes delete dialog inline
useCreateAuthority toast.created yes authority create form inline
useUpdateAuthority toast.saved no AuthorityRow has no inline error
useDeleteAuthority toast.deleted yes delete dialog inline
useCreateFieldDefinition toast.created yes field form inline
useUpdateFieldDefinition toast.saved no field-form edit may lack inline error
useDeleteFieldDefinition toast.deleted yes delete dialog inline
  • Step 1: VERIFY the suppress column per component. For each hook, open its consumer and check whether it visibly renders isError/catches+shows the error. Set suppressErrorToast iff it does. (The table is the expected mapping; correct any row that doesn't match the actual component — the principle governs: suppress only when the error is already shown inline. Update the "Why" if you change a row.)

  • Step 2: Add meta to each hook. E.g.:

return useMutation({
  mutationFn: async (body: NewVocabularyRequest) => { ... },
  onSuccess: () => qc.invalidateQueries({ queryKey: ["vocabularies"] }),
  meta: { successMessage: "toast.created", suppressErrorToast: true },
});

Leave mutationFn/onSuccess unchanged; only add the meta line.

  • Step 3: Integration test (mutation-feedback.test.tsx, RTL + MSW + the renderApp harness incl. <ToastRegion> — ensure the test render tree wraps with ToastRegion + the real queryClient MutationCache; if renderApp doesn't, add a variant that does, or test via main-equivalent providers):

    • Success: perform a create-vocabulary action (or call a meta.successMessage mutation) → a "Created" toast appears (within(document.body)).
    • Error (catch-all): MSW returns 500 for a non-suppressed mutation (e.g. useUpdateTerm) → an error toast appears.
    • Suppressed: a suppressErrorToast mutation failing → no toast added (and its inline error still shows). Assert the toast region has no error toast.
    • (Testing the MutationCache requires the real cache — construct a QueryClient with the same mutationCache config in the test wrapper, or export a makeQueryClient() factory from a shared module and use it in both main.tsx and tests. Prefer extracting the cache config into a small web/src/api/query-client.ts factory to avoid duplicating it in tests — do this if it keeps the test honest.)
  • Step 4: cd web && pnpm test && pnpm typecheck && pnpm lint. All green. Commit feat(web): per-mutation success/error toast metadata (#47).


Task 3: Final verification

  • cd web && pnpm typecheck && pnpm lint && pnpm test && pnpm build && pnpm check:size — all green; index ≤ 180 KB gz (Base UI toast adds to the always-loaded shell; report the number — if it pushes over, lazy-load is hard for a global region, so flag for a budget decision).
  • pnpm test -- i18n (en/sv parity for toast.*); git grep -in 'biggus\|dickus' -- web/src || echo CLEAN; git status --short clean.
  • Manual smoke (recommended): with the stack up, create a vocabulary → "Created" toast; trigger a failure (e.g. duplicate key) → error toast or the existing inline message (no double); delete a term in use → the dialog's "used by N" (no extra toast).

Self-Review (completed)

Spec coverage: Base UI toast region + external manager (T1 S1S2, S6); global MutationCache onError catch-all + onSuccess meta-driven (T1 S4); meta typing (T1 S3); per-mutation meta (T2); inline 422/409 kept (suppress flags, T2); toast i18n + parity (T1 S5, T3); story (T1 S6); verification/bundle (T3). ✓ Out of scope (replace inline UX, undo/queued, read-error toasts) not included. ✓ Placeholder scan: concrete code for manager/region/cache/typing; the Base UI Title/Description auto-render + viewport nesting carry an explicit "validate by running" (novel primitive); the suppress mapping is a concrete table with a governing principle + a per-component verification step (not vague). Type consistency: meta shape declared once (react-query.d.ts) and consumed in the MutationCache (T1) + set on hooks (T2); mutationErrorMessage uses the exported InUseError/HttpError; toast.* keys used in both the cache helper and the hook meta.

Notes

  • No new dependency (@base-ui/react present); bundle grows only by the toast primitive in the always-loaded region — watch check:size (budget 180).
  • Re-add with the same id de-dupes/refreshes a toast — not used now, available if repeated errors get noisy.
  • Extracting a makeQueryClient() factory (used by main.tsx + tests) keeps the toast wiring testable without duplicating the MutationCache config.