Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
14 KiB
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 (mirrorui/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
MutationCacheinweb/src/main.tsx. Import the manager, the i18n instance (the configured singleton — confirm the default export ofweb/src/i18n; the app already doesimport "./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
toastnamespace to bothen.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, inplay, calltoastManager.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. Commitfeat(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(atoast.*key) on every discrete user action.meta.suppressErrorToast: trueon 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. SetsuppressErrorToastiff 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
metato 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 + therenderAppharness incl.<ToastRegion>— ensure the test render tree wraps withToastRegion+ the realqueryClientMutationCache; ifrenderAppdoesn't, add a variant that does, or test viamain-equivalent providers):- Success: perform a create-vocabulary action (or call a
meta.successMessagemutation) → 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
suppressErrorToastmutation 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
QueryClientwith the samemutationCacheconfig in the test wrapper, or export amakeQueryClient()factory from a shared module and use it in bothmain.tsxand tests. Prefer extracting the cache config into a smallweb/src/api/query-client.tsfactory to avoid duplicating it in tests — do this if it keeps the test honest.)
- Success: perform a create-vocabulary action (or call a
-
Step 4:
cd web && pnpm test && pnpm typecheck && pnpm lint. All green. Commitfeat(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 fortoast.*);git grep -in 'biggus\|dickus' -- web/src || echo CLEAN;git status --shortclean.- 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 S1–S2, 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/reactpresent); bundle grows only by the toast primitive in the always-loaded region — watchcheck:size(budget 180). - Re-
addwith the same id de-dupes/refreshes a toast — not used now, available if repeated errors get noisy. - Extracting a
makeQueryClient()factory (used bymain.tsx+ tests) keeps the toast wiring testable without duplicating the MutationCache config.