Toasts
Dispatch transient notifications to the parent portal instead of rendering them inside your iframe.
A portal block runs in an <iframe>. A toast shown inside it renders against the
iframe, not the page: it's clipped to the iframe rectangle and stacks in the wrong
place relative to everything else on the portal.
toast moves the rendering up. Your block calls it, the parent portal renders the
toast in its own toaster over the whole page, and interaction (action clicks,
dismissals) comes back to your block. Only data crosses the boundary, never markup;
the callbacks stay in the block.
The API mirrors sonner for the serializable subset, so
a block can swap import { toast } from 'sonner' for import { toast } from '@epilot/spark-sdk'
and keep its calls.
Try it
These fire against the same sonner-based toaster the portal renders with, so this is
what your block's calls produce. In a block you'd import toast from
@epilot/spark-sdk; here the demo uses the UI package directly.
toast(title, options?)
import { toast } from '@epilot/spark-sdk'
toast.success('Wallbox switched to manual mode')Returns the toast id synchronously. toast(title) shows a default toast;
toast.success / .info / .warning / .error / .loading set the type;
toast.message is an alias of toast.
Calling any of them again with an existing id upserts: it updates that toast in
place instead of stacking a new one.
type ToastOptions = {
id?: string
description?: string
duration?: number // ms; Infinity = sticky
closeButton?: boolean
dismissible?: boolean
action?: { label: string; onClick?: () => void; dismissOnClick?: boolean }
cancel?: { label: string; onClick?: () => void }
onDismiss?: (toast: ActiveToast) => void
onAutoClose?: (toast: ActiveToast) => void
}Every visible string comes from the block; the host owns no translations (same as dialogs, see Localization).
Actions
An action renders a button on the toast. sonner's event.preventDefault() can't cross
the postMessage boundary, so keep-open intent is declared up front with dismissOnClick
(default true):
toast('Wallbox switched to manual mode', {
action: {
label: 'Undo',
onClick: () => switchBackToSmart(),
dismissOnClick: false, // keep the toast open after the click
},
})Promises
toast.promise is sugar over upsert: the promise is awaited inside your block, and
each phase (loading → success/error) is sent as an ordinary toast under one id. The
host never sees a promise.
const { unwrap } = toast.promise(switchToManual(device), {
loading: 'Switching to manual mode…',
success: (d) => `${d.name} switched to manual mode`,
error: (e) => `Couldn't switch: ${String(e)}`,
})
await unwrap() // settles like the original promise; rejects if it didThe success/error message can be a string or a (sync or async) function of the resolved
value, and may return the extended { message, ...options } form (the message key
matches sonner). If a phase's option is omitted, that phase dismisses the toast instead.
The return is a boxed string (sonner's shape) whose value is the id and which carries
unwrap().
Dismissing
toast.dismiss(id) // dismiss one
toast.dismiss() // dismiss all of this block's toaststoast.dismiss returns the dismissed id. It fires nothing locally; the host echoes the
removal, which is what runs your onDismiss. getToasts() returns the block's currently
active toasts.
Not supported
Everything non-serializable or presentational is excluded at the type level, so using it is a compile error, not a silent no-op:
toast.custom, JSX / ReactNode anywhere (titles, descriptions, and labels are
string), icon, position, richColors, invert, unstyled,
style / className / classNames, useSonner, toast.getHistory. These are
host-owned presentation or can't cross the postMessage boundary. Two ergonomic
deviations from sonner: ids are string-only (sonner allows string | number), and
toast.promise's loading and options argument are required.
Wire protocol
You don't need this to use toast; it's here for understanding and debugging. Four
messages flow over the postMessage bridge:
// block → parent, on toast() / toast.success() / … (upsert by id)
{ "source": "spark-bridge", "event": "spark-block:show-toast", "id": "…", "payload": { "type": "success", "title": "Wallbox switched to manual mode" } }
// block → parent, on toast.dismiss(id?); no id dismisses all of this block's toasts
{ "source": "spark-bridge", "event": "spark-block:dismiss-toast", "id": "…" }
// parent → block, when the user clicks the action or cancel button
{ "source": "portal", "event": "spark-bridge:toast-control", "id": "…", "control": "action" }
// parent → block, on every removal (runs onDismiss / onAutoClose)
{ "source": "portal", "event": "spark-bridge:toast-dismissal", "id": "…", "reason": "user" }show-toast is an upsert: an unknown id shows a new toast, a known id updates it in
place. toast-dismissal is sent exactly once per removal with a reason: user
(close button / swipe), auto (duration elapsed), action (removed by a control
click), or programmatic (the block dismissed it). The SDK routes auto to
onAutoClose and every other reason to onDismiss.
The host
You don't build a host. The parent portal (end-customer-portal) runs one toaster and a
handler that listens for spark-block:show-toast / spark-block:dismiss-toast, checks
the origin, scopes every toast to the block that sent it (a block can only update or
dismiss its own), renders over the page, and echoes control and dismissal back to the
block that asked. Rendering with sonner maps 1:1, but the host owns presentation
(position, theming, icons) either way.
The bridge is inert until the portal ships support: a toast dispatched to a host without it is a silent no-op, with no local fallback and no capability handshake.
toast.promisestill runs andunwrap()still settles; only the visible toast is absent. This mirrors how dialogs fail safe until the host catches up.