import type { ToastLayout, ToastOptions, ToastPosition, ToastRecord, ToastType } from './types'; type ToastDefaults = ToastOptions & { types?: Partial> }; type Subscriber = (toasts: ToastRecord[]) => void; interface Timer { handle: ReturnType | null; /** ms still to run. */ remaining: number; /** Timestamp the current run started, or null while paused. */ startedAt: number | null; } const HISTORY_LIMIT = 50; export const DEFAULT_DURATION = 4000; export const DEFAULT_VISIBLE_TOASTS = 5; /** Durations applied when the caller does not specify one. */ const durationForType = (type: ToastType): number => { if (type === 'loading') return Infinity; if (type === 'error') return 5000; return DEFAULT_DURATION; }; // `0`, `Infinity`, `NaN` and negatives all mean "do not auto-dismiss". A // negative duration must never fire immediately. const isEndless = (duration: number) => duration === 0 || !isFinite(duration) || duration < 0; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); /** * Run a consumer-supplied callback without letting it break our own state * machine. A throwing `onDismiss` used to abort `close()` half-way through. */ const safe = (label: string, fn: () => void) => { try { fn(); } catch (error) { // eslint-disable-next-line no-console console.error(`[vyrn] ${label} threw:`, error); } }; /** Progress is a percentage; anything outside 0-100 is a caller mistake. */ const normalize = (input: T): T => input.progress === undefined ? input : { ...input, progress: clamp(input.progress, 0, 100) }; /** * Framework-agnostic toast state. * * Deliberately free of React imports so `toast()` works from anywhere — * module scope, event handlers, before the `` has mounted. * Timers are `setTimeout`-based, so they keep running in background tabs. */ export class ToastStore { private toasts: ToastRecord[] = []; private history: ToastRecord[] = []; private subscribers = new Set(); private timers = new Map(); private defaults: ToastDefaults = {}; private limit = DEFAULT_VISIBLE_TOASTS; private paused = false; private counter = 0; private overrides: { position?: ToastPosition; layout?: ToastLayout } = {}; /** Mounted `` instances, in mount order. */ private renderers: symbol[] = []; /** * Register a mounted ``. Only the first one still mounted paints * the toasts, so a second `` (a stray one in a nested layout, say) * cannot double-render every toast. */ claimRenderer(token: symbol): () => void { this.renderers = [...this.renderers, token]; this.bump(); return () => { this.renderers = this.renderers.filter((candidate) => candidate !== token); // The next instance in line has to re-render to take over painting. this.bump(); }; } /** Force subscribers to re-render even though the toast list is unchanged. */ private bump() { this.toasts = [...this.toasts]; this.notify(); } /** True for the one instance that should render. */ isActiveRenderer(token: symbol): boolean { return this.renderers[0] === token; } subscribe(subscriber: Subscriber): () => void { this.subscribers.add(subscriber); return () => { this.subscribers.delete(subscriber); }; } private notify() { const snapshot = this.toasts; // A broken subscriber must not stop the others from updating. this.subscribers.forEach((subscriber) => safe('subscriber', () => subscriber(snapshot)) ); } /** A copy, so a caller cannot corrupt the queue by mutating what it gets. */ getToasts(): ToastRecord[] { return [...this.toasts]; } /** The slice that should actually be rendered; the rest are queued. */ getVisibleToasts(): ToastRecord[] { return this.toasts.slice(0, this.limit); } getHistory(): ToastRecord[] { return [...this.history]; } setLimit(limit: number) { const next = Math.max(1, Math.floor(limit)); if (next === this.limit) return; this.limit = next; this.syncTimers(); this.notify(); } setDefaults(defaults: ToastDefaults | undefined) { this.defaults = defaults || {}; } /** Is this toast still queued or on screen? */ isActive(id: string | number): boolean { const key = String(id); return this.toasts.some((toast) => toast.id === key); } /** * Runtime overrides for the mounted ``, so `useToast()` can still * move the toast region the way it could in v4. */ setOverrides(patch: { position?: ToastPosition; layout?: ToastLayout }) { this.overrides = { ...this.overrides, ...patch }; this.bump(); } getOverrides(): { position?: ToastPosition; layout?: ToastLayout } { return this.overrides; } /** Pause every running timer, banking the time left. */ pause() { if (this.paused) return; this.paused = true; const now = Date.now(); this.timers.forEach((timer) => { if (timer.handle !== null) { clearTimeout(timer.handle); timer.handle = null; } if (timer.startedAt !== null) { timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt)); timer.startedAt = null; } }); } /** Resume every paused timer with only its banked time left. */ resume() { if (!this.paused) return; this.paused = false; this.timers.forEach((timer, id) => { if (timer.handle === null) this.runTimer(id, timer); }); } isPaused() { return this.paused; } private runTimer(id: string, timer: Timer) { timer.startedAt = Date.now(); timer.handle = setTimeout(() => { this.timers.delete(id); this.close(id, 'timeout'); }, timer.remaining); } private clearTimer(id: string) { const timer = this.timers.get(id); if (timer && timer.handle !== null) clearTimeout(timer.handle); this.timers.delete(id); } /** * Start timers for visible toasts, stop them for queued ones. * Queued toasts must not burn their duration while off-screen. */ private syncTimers() { const visible = new Set(this.getVisibleToasts().map((toast) => toast.id)); this.timers.forEach((_, id) => { if (!visible.has(id)) this.clearTimer(id); }); this.getVisibleToasts().forEach((toast) => { if (isEndless(toast.duration)) return; if (this.timers.has(toast.id)) return; const timer: Timer = { handle: null, remaining: toast.duration, startedAt: null }; this.timers.set(toast.id, timer); if (!this.paused) this.runTimer(toast.id, timer); }); } private resolveType(options: ToastOptions & { type?: ToastType }): ToastType { if (options.type) return options.type === 'normal' ? 'default' : options.type; if (options.status === 'loading') return 'loading'; return 'default'; } /** * Add a toast, or patch an existing one when `id` is already present. * Returns the toast id. */ add(options: ToastOptions & { type?: ToastType }): string { const requestedId = options.id !== undefined ? String(options.id) : undefined; if (requestedId !== undefined) { const existing = this.toasts.find((toast) => toast.id === requestedId); if (existing) { this.update(requestedId, options); return requestedId; } } const wantsDedupe = options.preventDuplicate !== undefined ? options.preventDuplicate : this.defaults.preventDuplicate; // Identical message of the same type already showing: refresh it rather // than stacking a second copy (rapid retries, repeated validation errors). if (wantsDedupe && typeof options.content === 'string') { const type = this.resolveType({ ...this.defaults, ...options }); const twin = this.toasts.find( (toast) => toast.type === type && toast.content === options.content ); if (twin) { this.clearTimer(twin.id); this.update(twin.id, { ...options, id: undefined }); return twin.id; } } // A group occupies a single slot: the newest message replaces the previous // one rather than stacking (upload progress, autosave, presence, …). if (options.groupId !== undefined) { const grouped = this.toasts.find((toast) => toast.groupId === options.groupId); if (grouped) { // A replacement is a new message, so it gets the full duration again. this.clearTimer(grouped.id); this.update(grouped.id, { ...options, id: undefined }); return grouped.id; } } const id = requestedId !== undefined ? requestedId : String(++this.counter); // Precedence: explicit options > per-type defaults > global defaults. const { types, ...common } = this.defaults; const provisionalType = this.resolveType({ ...common, ...options }); const perType = types?.[provisionalType] || {}; const merged = normalize({ ...common, ...perType, ...options }); const type = this.resolveType(merged); const duration = merged.duration !== undefined ? merged.duration : durationForType(type); const record: ToastRecord = { ...merged, id, type, content: merged.content !== undefined ? merged.content : merged.title, duration, seq: ++this.counter, }; delete (record as { title?: unknown }).title; this.toasts = [...this.toasts, record]; this.syncTimers(); this.notify(); return id; } /** Patch a toast in place, preserving fields the patch does not mention. */ update(id: string | number, patch: ToastOptions & { type?: ToastType }): void { const key = String(id); const index = this.toasts.findIndex((toast) => toast.id === key); if (index === -1) return; const previous = this.toasts[index]; const next: ToastRecord = { ...previous, ...normalize(stripUndefined(patch)), id: key }; if (patch.title !== undefined && patch.content === undefined) next.content = patch.title; delete (next as { title?: unknown }).title; if (patch.type) next.type = patch.type === 'normal' ? 'default' : patch.type; const durationChanged = patch.duration !== undefined && patch.duration !== previous.duration; this.toasts = [ ...this.toasts.slice(0, index), next, ...this.toasts.slice(index + 1), ]; if (durationChanged) this.clearTimer(key); this.syncTimers(); this.notify(); } /** * Dismiss one toast, or every toast when `id` is omitted. * Fires `onDismiss` — not `onAutoClose`. */ dismiss(id?: string | number): void { if (id === undefined) { [...this.toasts].forEach((toast) => this.close(toast.id, 'dismiss')); return; } this.close(String(id), 'dismiss'); } /** Remove a toast without firing dismissal callbacks. */ remove(id: string | number): void { this.close(String(id), 'silent'); } private close(id: string, reason: 'dismiss' | 'timeout' | 'silent') { const toast = this.toasts.find((candidate) => candidate.id === id); if (!toast) return; this.clearTimer(id); this.toasts = this.toasts.filter((candidate) => candidate.id !== id); this.history = [...this.history, toast].slice(-HISTORY_LIMIT); const { onDismiss, onAutoClose, onClose } = toast; if (reason === 'dismiss' && onDismiss) safe('onDismiss', () => onDismiss(toast)); if (reason === 'timeout' && onAutoClose) safe('onAutoClose', () => onAutoClose(toast)); if (reason !== 'silent' && onClose) safe('onClose', () => onClose()); this.syncTimers(); this.notify(); } /** Drop all state. Intended for tests and hot reloads. */ reset(): void { this.timers.forEach((timer) => { if (timer.handle !== null) clearTimeout(timer.handle); }); this.timers.clear(); this.toasts = []; this.history = []; this.paused = false; this.counter = 0; this.notify(); } } /** `{...a, ...b}` would let an explicit `undefined` in `b` erase a good value in `a`. */ const stripUndefined = (input: T): T => { const output = {} as T; (Object.keys(input) as (keyof T)[]).forEach((key) => { if (input[key] !== undefined) output[key] = input[key]; }); return output; }; /** The single shared store instance backing the `toast()` API. */ export const store = new ToastStore();