feat: crash recovery — global error handlers + toast + burst-reload

- lib/crashRecovery.ts: window.onerror + unhandledrejection handlers,
  dedupe identical messages within 10s, burst-reload after 8 distinct
  errors in 30s
- components/CrashToast.tsx: portal stack in bottom-right, max 3 visible,
  auto-dismiss after 7s, per-entry close button
- Wired in main.tsx before bootstrap so crypto/i18n errors are captured,
  rendered from App.tsx alongside UpdateToast

Covers the async layer ErrorBoundary misses (realtime handlers, stray
Promises, setTimeout callbacks, LiveKit listeners).
This commit is contained in:
2026-04-21 09:29:01 +02:00
parent 672c8738c7
commit 636565d552
4 changed files with 201 additions and 0 deletions
+128
View File
@@ -0,0 +1,128 @@
// Crash / uncaught-error recovery.
//
// The React <ErrorBoundary> already catches render errors. This module covers
// the OTHER half: async code outside React (realtime handlers, Promises
// that never hit a .catch, setTimeout callbacks, LiveKit event listeners,
// etc.). Those bubble to window.onerror / onunhandledrejection and would
// otherwise disappear silently in production.
//
// Behaviour:
// - First error: emit a toast + log.
// - Repeated identical errors within DEDUPE_MS: swallow (no spam).
// - More than BURST_LIMIT distinct errors within BURST_WINDOW_MS: force a
// full page reload. Something is systematically broken and the UI
// probably can't recover in-place.
export interface CrashEntry {
id: string;
message: string;
stack: string | null;
source: 'window' | 'promise';
at: number;
}
type Listener = (entry: CrashEntry) => void;
const DEDUPE_MS = 10_000;
const BURST_WINDOW_MS = 30_000;
const BURST_LIMIT = 8;
const listeners = new Set<Listener>();
const recent = new Map<string, number>(); // message hash → last seen
let burst: number[] = [];
let installed = false;
function hash(message: string): string {
let h = 0;
for (let i = 0; i < message.length; i++) {
h = ((h << 5) - h + message.charCodeAt(i)) | 0;
}
return h.toString(36);
}
function emit(entry: CrashEntry): void {
// Dedupe on the message hash so a handler that fires every animation
// frame doesn't drown the UI in toasts.
const key = hash(entry.message);
const now = entry.at;
const last = recent.get(key);
if (last && now - last < DEDUPE_MS) return;
recent.set(key, now);
// Purge stale dedupe entries to keep the map bounded.
for (const [k, ts] of recent) {
if (now - ts > DEDUPE_MS * 3) recent.delete(k);
}
// Burst detection: drop samples older than window, count what's left.
burst = burst.filter((t) => now - t < BURST_WINDOW_MS);
burst.push(now);
if (burst.length >= BURST_LIMIT) {
console.error('[crash-recovery] burst threshold reached — reloading');
// Let existing toast handlers see the final entry first, then reload.
// Delay lets the log flush + any pending realtime ack go through.
window.setTimeout(() => window.location.reload(), 500);
}
for (const fn of listeners) {
try {
fn(entry);
} catch (err) {
// Listener itself threw — log but don't recurse.
console.error('[crash-recovery] listener threw', err);
}
}
}
function toEntry(
err: unknown,
source: CrashEntry['source'],
): CrashEntry {
const message =
err instanceof Error
? err.message
: typeof err === 'string'
? err
: (() => {
try {
return JSON.stringify(err);
} catch {
return String(err);
}
})();
const stack = err instanceof Error ? err.stack ?? null : null;
return {
id: crypto.randomUUID(),
message: message || 'unknown error',
stack,
source,
at: Date.now(),
};
}
export function installCrashHandlers(): void {
if (installed) return;
installed = true;
window.addEventListener('error', (ev: ErrorEvent) => {
// Some events (ResizeObserver loop, benign extension injections) arrive
// as errors without a real message. Skip those to avoid spam.
if (!ev.message && !ev.error) return;
emit(toEntry(ev.error ?? ev.message, 'window'));
});
window.addEventListener('unhandledrejection', (ev: PromiseRejectionEvent) => {
emit(toEntry(ev.reason, 'promise'));
});
}
export function subscribeCrashes(fn: Listener): () => void {
listeners.add(fn);
return () => {
listeners.delete(fn);
};
}
// Test / escape hatch — lets the reload threshold get checked in dev.
export function reportManualCrash(err: unknown): void {
emit(toEntry(err, 'window'));
}