Skip to content

Latest commit

 

History

History
39 lines (21 loc) · 5.55 KB

NOTIFICATIONS.md

File metadata and controls

39 lines (21 loc) · 5.55 KB

Toasts

TLDR: The toast contract. Error toasts come from onMutationError in the mutation hooks under packages/lib/src/core/[domain]/hooks/, so an app never toasts a mutation's error itself. Success toasts are rare and live in the hook too. A toast's action button needs pointer-events of its own, because it often appears over a modal. Persistent cross-user notifications are a different system, and it raises their toasts: NOTIFICATION-CENTER.md.

This doc governs every toast in the apps: who raises it and where its words come from. Read it before you write a mutation hook or handle an error in a component. The toasts are Sonner's, drawn by the Toaster that EigenApp mounts (LAYOUT.md). The idea behind the rules is that feedback lives next to the request: the hook that sends a mutation also says how it went, so every app says the same thing for the same outcome.

Error toasts come from the mutation hook

Every useMutation in packages/lib/src/core/[domain]/hooks/ hands its error to onMutationError (packages/lib/src/core/api-error.ts), which calls toast.error(getErrorMessage(error)). An optimistic mutation rolls its cache back first and then calls it. One place owns the wording, so every app says the same thing for the same failure.

AppError keeps the HTTP status of an Eden response, so the toast reads as a message and its status, like "Too large (413)". The message comes from the response body: a plain string (what every ApiError returns), else its message, else its summary, else the first errors entry. A body with none of those falls back to a phrase for the status, because Elysia strips a validation error down to { type, on, found } in production, and String() on that reads as [object Object]. A schema field with an error option answers that string instead, in production too, so a route whose 422 a person may read names the field and its range there (the backup settings' keep and hourUtc).

Apps never catch a mutation error to toast it

An app must not wrap a mutation in try/catch + toast.error(): the hook already toasted, and the user would see it twice. An app catches only when it has extra work to do on failure, such as resetting UI state, and then it shows no toast.

A caller that awaits mutateAsync and must still see other rejections asks wasToasted(error): onMutationError records every error it toasts, so the caller swallows exactly those (CardForm does this).

A dialog whose action fails stays open beside the toast, so the user can try again (useDialogPending, which ConfirmDialog, DeleteDialog, FormDialog and the pickers use). Only a yes/no confirm closes on a refusal (ConfirmDialog passes closeOnRefusal): an AppError with a 400, 403, 404 or 422 (isRefusal), such as the organization's default team that can't be deleted, gets the same answer on every try. A picker with input, such as DriveLocationPicker or ShareAndSendDialog, stays open on a refusal, because a 403 on a read-only folder or a 422 on a name is fixed by changing what the user typed or picked. A hook whose dialog should close on a refusal throws an AppError carrying the server's status, as useRemoveTeam does with better-auth's error. An error no toast has shown, such as a bug in a synchronous Docs submit, is not swallowed: useDialogPending rethrows it, so it reaches the console instead of leaving a button that does nothing.

A hook that recovers an expected error shows its own message instead and skips onMutationError. A contact write that meets a 412 reloads the list and says the contact changed elsewhere (handleStaleWrite in use-contacts.ts). Such a hook still passes the error to markToasted, so wasToasted is true for it and a dialog that awaits the write drops it instead of logging it.

A success toast is for a result the user can't see

Most mutations need no success toast: the UI update from cache invalidation is the feedback. Add one only when the result isn't visible: the user navigated away, the change is subtle, or the work runs in the background. useSendDraft says "Email sent" because the user left the draft. useUpdateACL says "Sharing updated" because an access change shows nowhere else. Grep toast.success under packages/lib/src/core for the rest.

The toast lives in the hook's onSuccess, next to the mutation, never in a component. One call site owns both the request and its feedback, and every app gets the same wording.

A toast that is not one mutation's outcome is raised where the action is: mail's Undo toast after an archive (apps/mail/src/components/mail/hooks/use-mail-actions.ts) spans several mutations, and a paste with nothing to place is no request at all.

A toast action works over a modal dialog

A toast with an action (the "Open folder" row after a save to Drive) is often raised from inside a modal. SaveToDrivePicker awaits its mutation, so the picker is still open when the hook toasts, and a convert stacks the progress dialog on top. A Radix modal parks pointer-events: none on <body> for as long as one is mounted, and the toaster lives under <body>. So Toaster gives every toast pointer-events: auto of its own (packages/ui/src/components/sonner.tsx). Without it the action draws and swallows every click.

See also