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:
parent
e4dcfb3845
commit
7f358f63e3
3 changed files with 63 additions and 5 deletions
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue