Transient feedback in CogniPrep goes through sonner, and there is a small module whose job is to be the only caller:
/**
* App-wide toast helpers.
*
* These wrap sonner so transient action feedback (API failures, successful
* mutations, milestones) is shown as a toast rather than inline UI. Page-level
* failures should continue to use the route `error.tsx` boundaries instead.
*/
It exports two functions. notifySuccess(title, description?) is thin. notifyError(error, options?) is the one that earns its place:
function toErrorMessage(error: unknown, fallback = 'Something went wrong. Please try again.'): string {
if (error instanceof Error && error.message) {
// fetchJson throws `HTTP 500: <body>` style messages; keep them readable but
// strip the noisy status prefix when there is a message after it.
const match = error.message.match(/^HTTP \d+:\s*([\s\S]+)$/);
const message = (match?.[1] ?? error.message).trim();
return message.length > 0 && message.length <= 200 ? message : fallback;
}
if (typeof error === 'string' && error.trim().length > 0) {
return error.trim();
}
return fallback;
}
Three decisions in nine lines: strip the status prefix our fetch wrapper adds, refuse anything over 200 characters, and accept unknown so a catch block can hand it whatever it caught. That is a policy, and it is the right one.
Then there is the other path.
$ grep -rn "notifyError(\|notifySuccess(" app components lib | grep -v lib/utils/toast.ts | wc -l
65
$ grep -rn "toast\.\(error\|success\)(" app components lib | grep -v lib/utils/toast.ts | wc -l
36
Sixty-five calls through the helper across 18 files, and 36 calls straight to sonner across 15 more. Every destructive dialog in the settings area, both auth forms, the support ticket forms, the help page contact form, two report pages and the interview download button import toast from sonner and do their own formatting.
It is not simply legacy code
The helper landed on 13 August 2026, in a commit called "Add game milestones, provider persistence, and toast notifications". Most of the direct callers are older than that: the delete-session dialog dates to 3 April, the login form and the account deletion dialog to 11 May. A helper arriving after its call sites is the ordinary way a codebase ends up with two of something.
What is not ordinary is this:
$ git log --diff-filter=A --format='%ad' --date=short -- components/support/SupportChat.tsx
2026-08-28
The support chat and the ticket reply form were created on 28 August, a fortnight after the helper, and they account for 8 of the 36. New code picked up the old habit, because the old habit is what the neighbouring files do. A convention that is not enforced is a suggestion, and a suggestion loses to copy and paste every time.
What the difference actually costs
Here is the account deletion dialog:
toast.error('Deletion failed', {
description: error instanceof Error ? error.message : 'Failed to delete account',
});
And here is a settings card doing the same job through the helper:
notifyError(error, { title: 'Deletion failed', fallback: 'Please try again.' });
They look identical on screen in the happy failure case. They differ when the error is ugly. The first renders error.message as it arrives, so an HTTP 500: {"error":"..."} string from the fetch wrapper goes on screen with the status prefix intact, and a 2 KB message goes on screen in full. The second strips the prefix and, past 200 characters, replaces the whole thing with a sentence a human wrote. The first path is also where a server-action string lands directly in the UI, in both auth forms:
const result = await loginUser({ message: '' }, formDataObj);
if (result?.message) {
toast.error(result.message);
}
That works because the server actions are careful about what they put in message. It works by agreement, not by construction.
Four copies of the same download
While counting, the duplicate literals fell out:
4 'PDF downloaded'
4 'Failed to download PDF. Please try again.'
4 'Deletion failed'
2 'Please fix the errors before submitting'
The PDF pair appears in four files: two report pages, the interview download button and the exercise results screen. Each one is the same shape, a fetch, a blob, an anchor click, a success toast, a failure toast. Four copies of a block whose only variable is the URL. The toast strings are not the problem there, they are the fingerprint of the problem, which is that nobody extracted downloadPdf().
The part the audit made me look at
Sonner's container is mounted once, in the root layout, with one option:
<Toaster position="top-right" />
No duration, no closeButton, no richColors. So every toast in the product is a default-duration toast, roughly four seconds, with no way to hold it open. On every public page there is an empty live region waiting for it, which you can see without triggering anything:
Object.fromEntries([...document.querySelector('section[aria-live]').attributes].map(a => [a.name, a.value]))
// {
// "aria-label": "Notifications alt+T",
// "tabindex": "-1",
// "aria-live": "polite",
// "aria-relevant": "additions text",
// "aria-atomic": "false"
// }
polite for everything, which is sonner's design and is right for "Session deleted" and arguable for "your account was not deleted". A polite announcement waits for the screen reader to finish whatever it is saying, by which point a four-second toast may be gone. Several of our destructive dialogs use that toast as the only report that the destruction did not happen.
One thing is already handled, and it is the only place in this codebase where a library's motion got a second thought:
@media (prefers-reduced-motion: reduce) {
[data-sonner-toast][data-mounted='true'],
[data-sonner-toast][data-removed='true'] {
animation: none !important;
}
}
That works because sonner animates with CSS, which the stylesheet can reach.
The call
Two paths is the finding; picking one is the work. The helper wins on merit, so the 36 direct calls want migrating and the lint rule that forbids importing toast from sonner outside lib/utils/toast.ts is what stops the count going back up. The four download blocks want one function. And the failures that must not be missed, deleting an account, losing a reply, want something that outlives four seconds, because a toast is a notification, not a record.
See it
Open cogniprep.app/help and run that attributes line in the console. You get sonner's live region, empty, with its alt+T shortcut, on a page where no toast has fired yet. The contact form on that page is a real support form, so please do not submit one just to watch an animation. The practice catalogue and the pricing page carry the same empty region, because it is mounted in the root layout for all 375 routes.
Top comments (0)