Controller

Every method of the controller, what it accepts and the exact result it returns.

On this page

The controller is what createCloudlet() and useCloudlet() return. Its type is ReactCloudletController, that is CloudletController<ReactNode, AbortSignal>. Every method has a stable reference, so you can pass cloudlet.show around or hand subscribe to React directly. Expected refusals are returned as results, never thrown: check status instead of wrapping calls in try.

MethodReturnsDescription
show(spec)AdmissionResultAdds a notification to the stack.
update(handle, patch)MutationResultChanges a live notification; never creates one.
getHandle(id)NotificationHandle | undefinedFinds the live notification with this ID.
track(source, options)OperationHandle<T>Follows an operation in one notification.
dismiss(handle)MutationResultCloses a notification.
clear()ClearResultCloses every notification.
requestFocus(target?)FocusResultMoves keyboard focus into the notifications.
acknowledgeOverflow()MutationResultResets the missed-notifications count.
getSnapshot()ControllerSnapshotThe current state, cached until it changes.
getServerSnapshot()ControllerSnapshotThe stable empty state for server rendering.
subscribe(listener)UnsubscribeCalls listener after every state change.
onEvent(listener)UnsubscribeCalls listener with each SemanticEvent.
dispose()voidEnds the controller for good.

show

show(spec: ReactNotificationSpec): AdmissionResult

ReactNotificationSpec is NotificationSpec<ReactNode>: the content of a notification plus its identity. Only title is required. A new notification goes on top of the stack; Notifications & time shows each field in use.

PropTypeDefaultDescription
titlestring—Required, non-empty. Always plain text, never markup.
idstringgeneratedYour own ID, unique among the live notifications of this controller. A taken ID is rejected with id-conflict.
kindNotificationKind'info''info', 'success', 'loading', 'warning' or 'error': the icon, the colour and the default time.
descriptionDescription<ReactNode>—Text under the title: { kind: 'text', text } or { kind: 'rich', content, announcement }. See Types.
iconReactNodethe glyph of kindA decorative icon that replaces the status glyph.
progressProgress—A ring around the icon, indeterminate or with a value from 0 to 1. See Types.
actionPrimaryAction—One command or link button. See Types.
priorityPriority'normal''normal' or 'high'. Stored with the record; it does not reorder the stack.
announcementPriorityAnnouncementPriority'polite''polite', 'assertive' or 'off'. An error does not become assertive on its own.
durationDurationby kindReadable milliseconds ≥ 0, or null to stay until closed. By default passive info and success get 6000 ms; loading, warning, error and any notification with an action stay.
attentionAttention'auto'Whether it opens the Island: 'auto' for an action, a failed retryable operation or a description; 'open' always; 'stack' never.
dedupeKeystring—Another live notification with the same key is not added again: its occurrences grows instead.
onClose(notice: CloseNotice) => void—Called once when the notification closes, with the reason.

With exactOptionalPropertyTypes, leave a field out rather than passing undefined.

AdmissionResult

StatusFieldsMeaning
'created'handleThe notification was added.
'deduplicated'handle, occurrencesA live notification with the same dedupeKey exists; its handle is returned and its count grew. Nothing else about it changed.
'rejected'reason, issues?Nothing was added. issues is present only for invalid-input.

AdmissionRejection, checked in this order:

ReasonWhen
'disposed'The controller was disposed.
'invalid-input'A field is invalid. issues lists each problem as an InputIssue with a path and a message; nothing is partially applied.
'id-conflict'The explicit id belongs to a live notification, even if the dedupeKey also matches.
'capacity'100 notifications are already open. The missed count grows and onOverflow is called; no older notification is evicted.

Deduplication happens before the capacity check, so a duplicate is still counted when the stack is full.

update

update(handle: NotificationHandle, patch: ReactNotificationPatch): MutationResult

ReactNotificationPatch is NotificationPatch<ReactNode>. A field you leave out keeps its value; null removes optional content. id and dedupeKey cannot change.

PropTypeDefaultDescription
titlestringunchangedA new title. Does not restart the timer.
kindNotificationKindunchangedA new kind; recalculates the default time. Rejected with operation-owned on a tracked operation.
descriptionDescription<ReactNode> | nullunchangedNew text, or null to remove it.
iconReactNode | nullunchangedA new icon, or null for the glyph of the kind.
progressProgress | nullunchangedNew progress, or null to remove the ring. Progress updates are not announced.
actionPrimaryAction | nullunchangedA new action, or null to remove it. Rejected with action-pending while the current command runs.
priorityPriorityunchangedNew record metadata.
announcementPriorityAnnouncementPriorityunchangedNew urgency for later announcements.
durationDurationunchangedSets a new time budget, even with the same number; null makes it stay. Rejected with operation-owned while an operation is pending.
attentionAttentionunchangedA new auto-open rule. null is invalid.

A rejected patch changes nothing, not even its valid fields. A patch that changes nothing returns unchanged.

MutationResult

update, dismiss and acknowledgeOverflow return the same union.

StatusFieldsMeaning
'applied'—The change was made.
'unchanged'—There was nothing to change.
'rejected'reason, issues?Nothing changed. issues is present only for invalid-input.

MutationRejection. A handle is checked in this order: controller disposed, a real handle, this controller's handle, still alive.

ReasonWhen
'disposed'The controller was disposed.
'invalid-handle'The value is not a handle minted by Cloudlet.
'foreign-controller'The handle belongs to another controller.
'stale-handle'The notification has closed. A closed notification is never reported as success.
'invalid-input'The patch has an invalid field.
'action-pending'The patch replaces an action while its command is running.
'operation-owned'The patch changes kind of a tracked operation, or duration while it is pending.

The first three handle reasons are also exported together as HandleRejection.

getHandle

getHandle(id: string): NotificationHandle | undefined

Returns the handle of the live notification with this ID, or undefined: for an unknown, empty or non-string ID, and always after dispose(). It never creates or opens anything. A handle is tied to one appearance of a notification: after it closes, the same ID can be used again and gets a new handle.

track

track<T>(source: PromiseLike<T> | ReactOperationFactory<T>, options: ReactTrackOptions<T>): OperationHandle<T>

Shows a loading notification with the pending content, then replaces it with the result. The source is a promise you already have, or a factory (context) => T | PromiseLike<T> that Cloudlet calls once per attempt with a ReactOperationContext: signal (an AbortSignal), token and attempt (1, 2, …). Only a factory can be retried. A source that is neither a function nor a thenable throws a TypeError. Operations & cancellation walks through it.

ReactTrackOptions<T> is TrackOptions<T, ReactNode, AbortSignal>:

OptionTypeDefaultDescription
pendingNotificationContent without duration—Required. Content while the operation runs; always loading and without a timer.
successNotificationContent | ((value: T) => NotificationContent)—Required. Content, or a function of the value, shown as success.
errorNotificationContent | ((error: unknown) => NotificationContent)—Required. Content, or a function of the error, shown as error.
cancelledNotificationContent | ((reason: unknown) => NotificationContent)messages.operationCancelledContent of a confirmed cancellation, shown as warning.
cancel(reason: unknown) => void—Makes an already-created promise cancellable. Not allowed with a factory, which cancels through its signal.
isCancellation(error: unknown, context: ReactOperationContext) => booleanAbortError after a requestDecides whether a rejection is a cancellation. true counts as cancelled even without a local request.
idstringgeneratedThe notification's ID.
priorityPriority'normal'Record metadata, as in show.
onClose(notice: CloseNotice) => void—Called when the notification closes. Closing never cancels the operation.

NotificationContent is the part of a spec that describes what is shown: title, description, icon, progress, action, announcementPriority, duration and attention, with the meaning they have in show. Tracked operations cannot have a dedupeKey.

A mapping function that throws or returns invalid content gives a safe fallback title from messages and a mapper-error diagnostic. The business operation runs even when the notification is rejected, and nothing about the notification ever changes its result.

OperationHandle

The handle is itself the first attempt: its token, attempt and result never change.

MemberTypeDescription
admissionOperationAdmissionResultThe notification's admission: created or rejected, as in show (tracked operations never deduplicate). Fixed at creation.
resultPromise<T>The business promise of the first attempt: its value, or its original rejection, cancellation included.
tokenOperationAttemptTokenThe first attempt's token.
attemptnumberAlways 1.
getSnapshot() => OperationSnapshotThe state of the current attempt.
subscribe(listener: () => void) => UnsubscribeCalls listener when the current attempt's state changes.
cancel(reason?: unknown) => CancelResultRequests cooperative cancellation of the current attempt. The default reason is an Error named AbortError.
retry() => AttemptStartResult<T>Starts a new attempt after the current one settled. Factory only.
restart() => AttemptStartResult<T>Like retry, and while pending it abandons the current attempt and requests its abort. Factory only.

Each new attempt returns its own OperationAttempt<T>: token, attempt and result. Retrying never creates a new notification; after the notification closed, the attempt runs without one.

AttemptStartResult<T>:

StatusFieldsMeaning
'started'attemptThe new attempt and its result.
'rejected'reason'disposed'; 'not-retryable' for a promise source; 'already-pending' when retry is called while an attempt runs.

CancelResult:

StatusFieldsMeaning
'requested'—The signal was aborted (or cancel called). A request is not a confirmation: a late success stays a success.
'unchanged'reason'already-requested', or 'settled' when the attempt already finished.
'rejected'reason'disposed', or 'not-cancellable' for a promise without a cancel option.

OperationSnapshot

FieldTypeDescription
tokenOperationAttemptTokenThe current attempt.
attemptnumberIts number, from 1.
statusOperationStatus'pending', 'fulfilled', 'rejected' or 'cancelled'.
cancelRequestedbooleanCancellation was requested for this attempt. Not a final state.
canCancelbooleancancel() would be accepted now.
canRetrybooleanretry() would be accepted now. The Retry button shows only while this is true and the attempt did not succeed.
notificationNotificationHandle | nullThe notification showing it, or null once there is none.

dismiss

dismiss(handle: NotificationHandle): MutationResult

Closes the notification at once, with the close reason programmatic. A late result or animation can never bring it back. Dismissing a tracked operation's notification does not cancel the operation.

clear

clear(): ClearResult

Closes every notification (close reason clear) and collapses the Island. The missed count stays until acknowledgeOverflow().

StatusFieldsMeaning
'cleared'countHow many notifications were closed, including 0.
'rejected'reason'disposed'.

requestFocus

requestFocus(target?: FocusTarget): FocusResult

Arrival and auto-open never move focus; this is the explicit way to do it, for example from a keyboard shortcut. requested means the command was accepted: the renderer moves focus after its next commit, not synchronously.

FocusTarget, default { kind: 'anchor' }:

TargetFieldsFocuses
{ kind: 'anchor' }—The notifications region itself.
{ kind: 'notification' }handle, control?The presented notification: its title in the Island or the pill. With control: 'action' or 'close', that button; these exist only in the open Island.
{ kind: 'overflow' }—The button that acknowledges missed notifications.

FocusResult:

StatusFieldsMeaning
'requested'—Focus will move.
'rejected'reasonA handle reason (invalid-handle, foreign-controller, stale-handle); 'disposed'; 'no-host' without a mounted Toaster; 'not-visible' for a notification deeper in the stack, or for a control while the Island is collapsed (the Island does not open for either); 'unavailable' when the requested control does not exist.

acknowledgeOverflow

acknowledgeOverflow(): MutationResult

Resets the missed count shown on the badge and in the Island: applied, unchanged when there was nothing to acknowledge, or rejected with disposed. It does not touch the notifications.

getSnapshot and getServerSnapshot

getSnapshot(): ControllerSnapshot<ReactNode>

getServerSnapshot(): ControllerSnapshot<ReactNode>

getSnapshot returns the same readonly object until something observable changes, so it can feed useSyncExternalStore directly. getServerSnapshot always returns one stable empty snapshot, for the server render and the first hydration render.

notification-count.tsx
import { useSyncExternalStore } from 'react';
import { useCloudlet } from '@cloudlet/toast';

export function NotificationCount() {
  const cloudlet = useCloudlet();
  const snapshot = useSyncExternalStore(cloudlet.subscribe, cloudlet.getSnapshot, cloudlet.getServerSnapshot);
  return <span>{snapshot.notifications.length} open</span>;
}

ControllerSnapshot:

FieldTypeDescription
controllerControllerIdentity | nullThe controller's identity; null in the server snapshot.
revisionnumberGrows with every change.
disposedbooleantrue after dispose(); the snapshot is then empty.
notificationsreadonly NotificationSnapshot[]Every open notification in admission order, oldest first.
stackreadonly NotificationHandle[]The same notifications newest first; stack[0] is the collapsed pill.
islandIslandSnapshot{ open, reason, current }: reason is 'user' or 'attention' and current the visible slide, both null while collapsed.
overflowOverflowSnapshotThe missed count and whether it is unacknowledged.

Each NotificationSnapshot is described on Types.

subscribe and onEvent

subscribe(listener: () => void): Unsubscribe

onEvent(listener: (event: SemanticEvent) => void): Unsubscribe

subscribe is called after every committed change; read the new state with getSnapshot(). onEvent receives what happened, in order, after the snapshot listeners. Neither replays the past: read the snapshot when you subscribe. Calling the returned Unsubscribe twice is harmless. A listener that throws becomes a listener-error diagnostic and the others still run.

analytics.ts
import { createCloudlet } from '@cloudlet/toast';

const cloudlet = createCloudlet();
const stop = cloudlet.onEvent((event) => {
  if (event.type === 'closed') console.info(event.handle.id, event.reason);
});

Every SemanticEvent has the EventBase fields controller, sequence (a growing local number) and at (monotonic milliseconds, not wall-clock time), plus:

typeFieldsWhen
'admitted'handleshow or track added a notification.
'deduplicated'handle, occurrencesA show matched a live dedupeKey.
'updated'handleThe content changed.
'closed'handle, reasonA notification closed; reason is a CloseReason.
'presented'handle, firstPresentationA notification became readable on screen; firstPresentation is true once per appearance.
'operation-started'handle, token, attemptAn attempt started; handle is null without a notification.
'operation-settled'handle, token, status, uiAppliedAn attempt finished; uiApplied says whether the notification showed this result.
'cancellation-requested'handle, tokenCancellation of an attempt was requested.
'action-started'handle, tokenA command action started.
'action-failed'handle, token, messageA command action failed; the card stays and can be retried.
'overflow'countA notification was rejected because the stack was full.
'overflow-acknowledged'countThe missed count was acknowledged.
'diagnostic'diagnosticA contained problem; see onDiagnostic.
'disposed'—The last event of the controller.

Events carry no business values or errors. Opening and collapsing the Island is not an event: read snapshot.island.

dispose

dispose(): void

Ends the controller. Every open notification closes with the reason dispose (its onClose runs), the disposed event is delivered, then all listeners are released. Afterwards show, update, clear and the other commands return disposed, and getSnapshot() returns a stable empty snapshot with disposed: true. Calling it again does nothing. Running operations are not aborted: their result still settles.

NotificationController

NotificationController is the controller without track, for code that only shows notifications. Any controller can be passed where it is expected.

notify.ts
import type { ReactNode } from 'react';
import { createCloudlet, type NotificationController } from '@cloudlet/toast';

export function notifySaved(notifications: NotificationController<ReactNode>) {
  return notifications.show({ title: 'Saved', kind: 'success' });
}

notifySaved(createCloudlet());

The generic form is CloudletController<Slot, Signal>: Slot is the type of rich content (ReactNode here) and Signal the abort signal operations receive (AbortSignal here).