import { Component, type ErrorInfo, Fragment, type ReactNode } from 'react'; import { SpinnerIcon } from './icons'; interface Props { children: ReactNode; /** * Optional scope label shown in logs / devtools. Defaults to `root` — set * per boundary (e.g. `route`, `conversation`) so multiple boundaries can be * distinguished at a glance. */ scope?: string; /** * If the retry count exceeds this, the boundary stops auto-retrying and * shows a more helpful message (still without a button — Discord-style, * the app keeps trying but hints the user to hold on or check network). */ maxAutoRetries?: number; } interface State { error: Error | null; retryKey: number; attempt: number; } const RETRY_DELAYS_MS = [2000, 4000, 8000, 15000, 30000]; // Discord-style error boundary. // - Catches render-time errors in its subtree. // - Shows a centred spinner + status text. Never renders a manual "Reload" // button; the boundary remounts its children on an exponential-backoff // schedule so the UI self-heals once the underlying issue clears (typical // causes: a realtime reconnect, a transient network blip, or a race that // only fires once). // - Escalates the label after each failed retry so the user sees that the // app is trying, rather than silent infinite spinning. export class ErrorBoundary extends Component { state: State = { error: null, retryKey: 0, attempt: 0 }; private retryTimer: number | null = null; static getDerivedStateFromError(error: Error): Partial { return { error }; } override componentDidCatch(error: Error, info: ErrorInfo): void { const scope = this.props.scope ?? 'root'; // We explicitly log here — the boundary itself swallows the error from // React, so without this the failure would be invisible in production. console.error('[ErrorBoundary:' + scope + '] caught render error', error, info); } override componentDidUpdate(_prev: Props, prevState: State): void { if (this.state.error && !prevState.error) { this.scheduleRetry(); } } override componentWillUnmount(): void { if (this.retryTimer !== null) { window.clearTimeout(this.retryTimer); this.retryTimer = null; } } private scheduleRetry(): void { if (this.retryTimer !== null) return; const attempt = this.state.attempt; const delay = RETRY_DELAYS_MS[Math.min(attempt, RETRY_DELAYS_MS.length - 1)] ?? RETRY_DELAYS_MS[RETRY_DELAYS_MS.length - 1] ?? 30000; this.retryTimer = window.setTimeout(() => { this.retryTimer = null; this.setState((prev) => ({ error: null, retryKey: prev.retryKey + 1, attempt: prev.attempt + 1, })); }, delay); } override render(): ReactNode { if (this.state.error) { const max = this.props.maxAutoRetries ?? RETRY_DELAYS_MS.length; const escalated = this.state.attempt >= max; return ; } // `retryKey` forces a remount of the subtree so hooks re-run cleanly after // an error (otherwise stale state from the crashed tree can immediately // re-throw). Use a keyed Fragment so the boundary doesn't inject an extra // wrapper div — that would break `flex h-full` chains (e.g. AppShell → // Outlet → page column). return {this.props.children}; } } function RetryingScreen({ escalated, attempt }: { escalated: boolean; attempt: number }) { const primary = escalated ? 'Verbindungsprobleme…' : attempt === 0 ? 'Einen Moment bitte' : 'Versuche erneut zu laden…'; const secondary = escalated ? 'Prüfe deine Internetverbindung. Wir versuchen es weiter.' : 'Die App lädt sich gleich selbst neu.'; return (

{primary}

{secondary}

); }