ReviewOS

stacks/stx

toast cannot replace a hand-rolled toast: no title, no replace-by-id, no actions, no dedupe (16 call sites in one app, none migratable)

#1913
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Following #1843, I tried to actually delete an app's hand-rolled toast (193 lines) and adopt toast. It cannot be done without losing behaviour. Writing up the gap concretely, since #1843 counted call sites but did not check whether the primitive covers them.

The app has 16 toast call sites across 5 views. Here is what toast does not carry.

Neededtoast todayUsed at
Title distinct from messagenone — msgSpan.textContent = message and nothing else~all 16 sites ('Published', 'Could not save rule', 'Listening scan complete')
Replace by semantic iddismiss(id) takes the numeric return of addToast8 sites: id: 'publish', 'listening-save', 'timeline-load', 'listening-scan'
Action buttons / linknonethe publish-success toast offers "Open on Bluesky" + "Copy link"
Dedupenonesuppresses an identical toast within 2–6s; without it a retry loop stacks the same error
Loading statenonean in-progress publish shows a spinner and duration: 0, replaced on completion

The id one is the sharpest. The app publishes a post, shows a persistent "Publishing…" toast under id: 'publish', and on completion calls dismiss('publish') from a different function. With toast, the numeric id has to be threaded between those two call sites by hand — which is the state-management the primitive is supposed to remove.

title is the most common. Every site passes one, and collapsing title and message into a single string loses the visual hierarchy that makes a toast scannable.

Why this matters beyond one app

These are the same five things any toast library ships, so this is the shape of hand-rolled toast generally, not one app's taste. As it stands, an app that needs any of them keeps its own implementation, and toast is only reachable for the simplest notifications — which is a fair scope, but then #1843's "the primitive is delivered and unused" reads as an adoption problem when it is partly a capability one.

Suggested

title and a string id (replace-in-place) are the two that unblock the most, and neither changes the existing signature:

toast.success('Post published.', { title: 'Published', id: 'publish' })
toast.dismiss('publish')

Actions and dedupe are bigger; happy to file separately if useful. Also worth stating in the docs which of these toast is not meant to cover, so the next person hits the boundary at read time rather than mid-migration.

Related: #1912 (toast ignores the app theme) is a blocker for the same migration — a toast that renders dark over a light app is not shippable regardless of the API.

10 days ago

title and a string id are in — f4c8a51dbc. Both exactly as you specified, and neither changes the existing signature:

toast.success('Post published.', { title: 'Published', id: 'publish' })
toast.dismiss('publish')

dismiss takes either the string key or the numeric handle, so a caller does not have to know which kind it is holding.

One implementation detail worth surfacing, because it changes behaviour you would otherwise hit: a keyed toast replaces its predecessor by removing it outright, not through removeToast, which animates for 300ms first. This is a replacement rather than a dismissal — animating the old one out while the new one animates in shows both at once in the same slot, which is the flicker a keyed toast exists to avoid. Dismissal keeps its animation.

An untitled toast renders exactly as before; the wrapper only appears when there is a title, and that is pinned so existing call sites cannot shift.

7 tests, including that a different id does not disturb an existing toast and that unkeyed toasts are left alone.

On the other three

Not done, deliberately, and I would rather say where the boundary is than half-ship them:

  • Actions (buttons/links in a toast) and loading state are a real scope increase — they make a toast an interactive surface with focus and keyboard concerns, which is closer to what <Notification> is for.
  • Dedupe needs a policy (window length, what counts as identical) that should be decided rather than guessed.

Please do file those separately if you want them tracked; they are capability gaps rather than bugs, and your framing is the useful one — #1843's "delivered and unused" reads as an adoption problem when it is partly a capability one. That distinction is worth keeping visible.

Your point about documenting what toast is not meant to cover is well taken and not yet done.

10 days ago

Thanks — title and replace-by-string-id both landed and are confirmed working in the published 0.2.175. Checked against the tarball and the source, addToast now reads exactly:

duration    id    dark    title

Two of the five are still outstanding, so flagging before this drops off the radar:

actions — the one that still blocks the migration here. Grepping opts.actions across signals.ts returns three hits, but none are inside addToast; they belong to a different surface. In the app I was migrating, the publish-success toast is the highest-value notification in the product — you just published, and the toast is how you open or copy the resulting URL:

setStatus('Published to 2 accounts.', 'success', { title: 'Published', url: lastUrl })
// renders: [Open on Bluesky] [Copy link]

Two call sites need it. Every other one of the 16 is now migratable, which means the choice is to run two toast systems side by side or keep the hand-rolled one — and the first is worse than either.

dedupe — still absent (0 matches). Lower priority: nothing passes it explicitly, they all rely on the tone-based default, and it can be approximated by reusing id. Worth confirming that is the intended replacement, in which case it just needs saying in the docs.

Happy for this to stay closed and to open a focused issue for actions alone if that is tidier — say the word.

Also worth recording: _resolveDark being extracted as a shared helper is exactly what #1912 needed. The toast, the dialog and anything added later now cannot drift apart again.

Sign in to comment on this issue.