Error toasts are sticky (duration: 0) so a misbehaving command that fires errors in a loop — a backend that flaps, a polling effect over a broken endpoint — accumulates toast items in the in-memory store indefinitely. The audit found no other unbounded $state arrays in the frontend stores (history caps at 500, recentNudges prunes by time, tasks/taskLists replace-on-load), but `items.push(toast)` had no upper bound. Add MAX_TOASTS = 50 with FIFO eviction. Doesn't change behaviour for realistic toast volumes; only kicks in if something is genuinely wrong. https://claude.ai/code/session_0189xUb6ie6t9qHkzatGZ9Rb
100 lines
3.3 KiB
TypeScript
100 lines
3.3 KiB
TypeScript
import type { ToastItem, ToastSeverity } from "$lib/types/app";
|
|
import { errorMessage } from "$lib/utils/errors.js";
|
|
|
|
// Minimal toast store. Roll-our-own (no svelte-french-toast etc) so Kon
|
|
// stays lean. Toasts auto-dismiss after `duration` ms unless duration is 0
|
|
// (sticky) or unless the user clicks the close button.
|
|
//
|
|
// Severity → colour:
|
|
// info → moss (informational, default 4s)
|
|
// success → moss (operation succeeded, default 3s)
|
|
// warn → signal (degraded but not failed, default 6s)
|
|
// error → ember (operation failed, sticky until dismissed)
|
|
//
|
|
// Usage from a component:
|
|
// import { toasts } from '$lib/stores/toasts.svelte.js';
|
|
// toasts.error('Could not start recording', 'Selected microphone disappeared.');
|
|
// toasts.success('Saved');
|
|
|
|
let nextId = 1;
|
|
|
|
// Soft cap on how many toasts can be queued at once. Error toasts are sticky
|
|
// (duration: 0) so a misbehaving command that fires errors in a loop can pile
|
|
// up indefinitely without this cap. When exceeded, the oldest toast is
|
|
// evicted to make room.
|
|
const MAX_TOASTS = 50;
|
|
|
|
function defaultDuration(severity: ToastSeverity): number {
|
|
switch (severity) {
|
|
case "success": return 3000;
|
|
case "info": return 4000;
|
|
case "warn": return 6000;
|
|
case "error": return 0; // sticky
|
|
default: return 4000;
|
|
}
|
|
}
|
|
|
|
function createToastsStore() {
|
|
// $state requires a class field or top-level let in Svelte 5; we expose
|
|
// `items` via a getter on the singleton.
|
|
let items = $state<ToastItem[]>([]);
|
|
|
|
function show(severity: ToastSeverity, title: string, body?: string) {
|
|
const id = nextId++;
|
|
const duration = defaultDuration(severity);
|
|
const toast = { id, severity, title, body: body ?? '', duration };
|
|
items.push(toast);
|
|
if (items.length > MAX_TOASTS) {
|
|
items.splice(0, items.length - MAX_TOASTS);
|
|
}
|
|
if (duration > 0) {
|
|
setTimeout(() => dismiss(id), duration);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
function dismiss(id: number) {
|
|
const ix = items.findIndex(t => t.id === id);
|
|
if (ix >= 0) items.splice(ix, 1);
|
|
}
|
|
|
|
function dismissAll() {
|
|
items.length = 0;
|
|
}
|
|
|
|
return {
|
|
get items() { return items; },
|
|
info: (title: string, body?: string) => show("info", title, body),
|
|
success: (title: string, body?: string) => show("success", title, body),
|
|
warn: (title: string, body?: string) => show("warn", title, body),
|
|
error: (title: string, body?: string) => show("error", title, body),
|
|
dismiss,
|
|
dismissAll,
|
|
};
|
|
}
|
|
|
|
export const toasts = createToastsStore();
|
|
|
|
// Helper: wrap a Tauri invoke and toast on failure. Returns the result on
|
|
// success, throws on failure (so callers that need to handle the error
|
|
// can still try/catch).
|
|
//
|
|
// import { invoke } from '@tauri-apps/api/core';
|
|
// import { invokeWithToast } from '$lib/stores/toasts.svelte.js';
|
|
// const result = await invokeWithToast('start_native_capture', { deviceName });
|
|
//
|
|
// Optional `errorTitle` overrides the default ("Action failed").
|
|
export async function invokeWithToast<TArgs, TResult>(
|
|
invokeFn: (command: string, args?: TArgs) => Promise<TResult>,
|
|
command: string,
|
|
args?: TArgs,
|
|
errorTitle?: string,
|
|
): Promise<TResult> {
|
|
try {
|
|
return await invokeFn(command, args);
|
|
} catch (err) {
|
|
toasts.error(errorTitle ?? "Action failed", errorMessage(err));
|
|
throw err;
|
|
}
|
|
}
|