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
+2
View File
@@ -1,6 +1,7 @@
import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom'; import { BrowserRouter, Navigate, Outlet, Route, Routes } from 'react-router-dom';
import { AppShell } from './components/AppShell'; import { AppShell } from './components/AppShell';
import { CrashToast } from './components/CrashToast';
import { ErrorBoundary } from './components/ErrorBoundary'; import { ErrorBoundary } from './components/ErrorBoundary';
import { UpdateToast } from './components/UpdateToast'; import { UpdateToast } from './components/UpdateToast';
import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards'; import { RequireAdmin, RequireAuth, RequireDevice } from './components/guards';
@@ -91,6 +92,7 @@ export function App() {
<Route path="*" element={<Navigate to="/chats" replace />} /> <Route path="*" element={<Navigate to="/chats" replace />} />
</Routes> </Routes>
<UpdateToast /> <UpdateToast />
<CrashToast />
</BrowserRouter> </BrowserRouter>
</CallProvider> </CallProvider>
</ConversationsProvider> </ConversationsProvider>
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { type CrashEntry, subscribeCrashes } from '../lib/crashRecovery';
import { AlertIcon, XIcon } from './icons';
const VISIBLE_MS = 7_000;
const MAX_STACK = 3;
// Bottom-right stack of toasts for uncaught errors. Auto-dismisses each
// entry after VISIBLE_MS. The user can X-out earlier.
export function CrashToast() {
const [entries, setEntries] = useState<CrashEntry[]>([]);
useEffect(
() =>
subscribeCrashes((entry) => {
setEntries((prev) => [...prev.slice(-(MAX_STACK - 1)), entry]);
}),
[],
);
useEffect(() => {
if (entries.length === 0) return;
const latest = entries[entries.length - 1]!;
const id = window.setTimeout(() => {
setEntries((prev) => prev.filter((e) => e.id !== latest.id));
}, VISIBLE_MS);
return () => window.clearTimeout(id);
}, [entries]);
if (entries.length === 0) return null;
return createPortal(
<div
role="region"
aria-label="Fehler"
className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
>
{entries.map((entry) => (
<div
key={entry.id}
role="alert"
className="pointer-events-auto flex max-w-sm items-start gap-2 rounded-lg border border-rose-500/40 bg-rose-500/10 p-3 text-sm text-rose-700 shadow-xl backdrop-blur-sm dark:text-rose-100"
>
<AlertIcon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-xs font-semibold uppercase tracking-wider">
{entry.source === 'promise' ? 'Promise-Fehler' : 'Unerwarteter Fehler'}
</p>
<p className="mt-0.5 break-words text-xs">{entry.message}</p>
</div>
<button
type="button"
aria-label="Schließen"
onClick={() =>
setEntries((prev) => prev.filter((e) => e.id !== entry.id))
}
className="shrink-0 cursor-pointer rounded-md p-1 text-rose-700 transition hover:bg-rose-500/20 dark:text-rose-100"
>
<XIcon className="h-3 w-3" />
</button>
</div>
))}
</div>,
document.body,
);
}
+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'));
}
+3
View File
@@ -4,10 +4,13 @@ import ReactDOM from 'react-dom/client';
import { App } from './App'; import { App } from './App';
import { createLibsodiumBackend } from './lib/cryptoBackend'; import { createLibsodiumBackend } from './lib/cryptoBackend';
import { installCrashHandlers } from './lib/crashRecovery';
import { bootstrapI18n } from './lib/i18n'; import { bootstrapI18n } from './lib/i18n';
import './styles/globals.css'; import './styles/globals.css';
async function boot(): Promise<void> { async function boot(): Promise<void> {
// Install first so bootstrap errors (crypto backend, i18n) are captured.
installCrashHandlers();
bootstrapI18n(); bootstrapI18n();
setCryptoBackend(await createLibsodiumBackend()); setCryptoBackend(await createLibsodiumBackend());