feat(notifications): coalesce identical notifications with a repeat count

Fold a burst of identical notifications (same level+title+message within a
short window) into one entry with a ×N count instead of stacking copies — tames
a crash-retry loop or a repeatedly-failing poll. Shown in the toast and centre.
This commit is contained in:
npeter83 2026-07-13 05:13:18 +02:00
parent e4dcfb3845
commit 7f358f63e3
3 changed files with 63 additions and 5 deletions

View file

@ -59,6 +59,11 @@ export interface Notification {
// timer) that the producer removes itself when the condition resolves. Never persisted
// to history, so a reload can't orphan it with no live handle to clear it.
transient: boolean;
// How many times an identical notification (same level+title+message) fired in quick
// succession and was coalesced into this one entry (see notify). 1 = fired once; shown as
// "×N" in the toast/center when >1. Guards against a burst (e.g. an ErrorBoundary catching
// the same crash on every retry) spamming the center with dozens of copies.
repeat: number;
}
export interface NotifyInput {
@ -136,7 +141,10 @@ function load(): Notification[] {
// resurrect as active toasts on reload — their auto-dismiss timers aren't
// re-armed across a reload, so otherwise they'd stick forever. Also drop the
// live `action` callback, which can't survive serialization.
return raw.map((n) => ({ ...n, action: undefined, dismissed: true, transient: false }) as Notification);
return raw.map(
(n) =>
({ repeat: 1, ...(n as object), action: undefined, dismissed: true, transient: false }) as Notification
);
} catch {
return [];
}
@ -184,10 +192,13 @@ export function removeByMetaKind(kind: NotifMeta["kind"]): void {
if (items.length !== before) emit();
}
// Window within which an identical notification is coalesced into the prior one (see below).
const COALESCE_WINDOW_MS = 5000;
export function notify(input: NotifyInput): number {
const id = counter++;
const requiresInteraction = input.requiresInteraction ?? false;
const level = input.level ?? "info";
const now = Date.now();
// config.durationMs === 0 means "don't auto-dismiss" (toast stays until dismissed).
// A transient status stays put until its producer clears it (no auto-dismiss timer),
// same as an interaction-awaiting notice.
@ -198,6 +209,38 @@ export function notify(input: NotifyInput): number {
: config.durationMs > 0
? config.durationMs
: undefined;
// Coalesce a rapid burst of identical notifications (same level+title+message within the
// window) into one entry with a bumped repeat count, instead of appending a fresh copy each
// time. This tames spam like an ErrorBoundary catching the same crash on every render retry,
// or a poll that keeps failing the same way. Skipped when the input carries a live `action`
// (its callback identity matters) or is a transient status (those are singleton-guarded by
// their producer). Distinct events — any difference in level/title/message — are never merged.
if (!input.action && !input.transient) {
const prior = items.find(
(n) =>
!n.transient &&
!n.action &&
now - n.ts <= COALESCE_WINDOW_MS &&
n.level === level &&
(n.title ?? "") === (input.title ?? "") &&
n.message === input.message
);
if (prior) {
// Re-surface the existing toast (undismiss + unread) and re-arm its auto-dismiss timer.
items = items.map((n) =>
n.id === prior.id
? { ...n, ts: now, repeat: n.repeat + 1, dismissed: false, read: false, duration }
: n
);
emit();
if (duration) setTimeout(() => dismiss(prior.id), duration);
// Deliberately no re-beep: the first occurrence already sounded; a loop must stay quiet.
return prior.id;
}
}
const id = counter++;
items = [
...items,
{
@ -209,10 +252,11 @@ export function notify(input: NotifyInput): number {
meta: input.meta,
requiresInteraction,
duration,
ts: Date.now(),
ts: now,
read: false,
dismissed: false,
transient: input.transient ?? false,
repeat: 1,
},
];
emit();