636565d552
- 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).
69 lines
2.2 KiB
TypeScript
69 lines
2.2 KiB
TypeScript
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,
|
|
);
|
|
}
|