syncova-backup/apps/web/src/components/layout/ErrorBoundary.tsx
Jerrit Fritzsche b50ad2b9bc Sitzung ueberlebt Neuladen, Umlaute, Fehlergrenze
**Sitzung.** Die Tokens lagen nur im Arbeitsspeicher — jedes Neuladen warf den
Betreiber auf die Anmeldemaske. Das war die sicherste Variante und praktisch
unbrauchbar; mitten in einer Stoerung ist es kein Sicherheitsgewinn, sondern ein
Hindernis. Jetzt `sessionStorage` (nicht `localStorage`: stirbt mit dem Tab),
begrenzt durch zwei Uhren:

- **Harte Obergrenze** von 30 Minuten ab Anmeldung, durch keine Interaktion
  verschiebbar. Sonst waere "30 Minuten" keine Zusage.
- **Untaetigkeitsgrenze** von 30 Minuten.
- Der sofortige serverseitige Widerruf bleibt die eigentliche Absicherung — die
  Tokens sind opak, kein JWT, und genau dafuer wurden sie gewaehlt.

Dazu die Sitzungsuhr oben rechts neben "Abmelden", unter fuenf Minuten
auffaellig. Umgesetzt mit `useSyncExternalStore`: Die Restzeit haengt an der Uhr
und am Speicher, also an zwei Dingen ausserhalb von React. Sie beim Rendern
auszurechnen waere ein unreiner Aufruf, sie in einem Effekt zu setzen eine
zweite Renderrunde je Sekunde — beides hat der Linter gemeldet.

Sechs Tests halten die Grenzen fest, zwei davon durch Mutation als fangend
bestaetigt (Obergrenze mitverschieben schlaegt fehl).

**Fehlergrenze.** Ein Fehler in einer Komponente riss bisher den gesamten Baum
ab; uebrig blieb eine leere Seite — im dunklen Thema ein schwarzer Bildschirm
ohne jeden Hinweis. Die Grenze sitzt **um den Inhalt**: Menue und Kopfzeile
bleiben stehen, der Fehlertext ist lesbar und kopierbar.

**Umlaute.** Die Oberflaeche schrieb durchgehend ae/oe/ue/ss. Jetzt aeoeuess.

Dabei ein selbst verursachter Schaden, gefunden und behoben: Eine Regel
"ue → ü" ist falsch, weil die Buchstabenfolge nicht immer ein Umlaut ist. Sie
machte aus "Quelle" ein "Qülle", aus "neue" ein "neü", aus "aktuell" ein
"aktüll", aus "Dauer" ein "Daür". Die Abbildung laeuft jetzt ueber eine
gepruefte Wortliste mit Ausschluss englischer Bezeichner (`value`, `message`,
`session`, `queued`, `true`); die 23 zerstoerten Woerter sind einzeln
zurueckgesetzt. Ein alter Tippfehler ("geprueter") ist dabei mit aufgefallen.

82 Tests gruen, tsc sauber, eslint ohne Warnung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 14:42:37 +02:00

113 lines
3.9 KiB
TypeScript

/**
* Fehlergrenze um den Seiteninhalt.
*
* Ohne sie reißt ein einziger Fehler in einer Komponente den gesamten Baum ab —
* React hängt alles aus, und übrig bleibt eine leere Seite. Im dunklen Thema ist
* das ein schwarzer Bildschirm ohne jeden Hinweis, und ein Betreiber hat keine
* Möglichkeit zu melden, was schiefging.
*
* Die Grenze sitzt **um den Inhalt**, nicht um die ganze Anwendung: Seitenleiste
* und Kopfzeile bleiben stehen, und man kann auf eine andere Seite wechseln,
* statt neu laden zu müssen.
*
* Klassenkomponente, weil React für Fehlergrenzen keine Entsprechung als Haken
* anbietet.
*/
import { AlertTriangle } from 'lucide-react';
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { Button } from '@/components/ui';
interface ErrorBoundaryProps {
/** Wechselt der Wert, wird der Fehler verworfen — etwa beim Seitenwechsel. */
readonly resetKey: string;
readonly children: ReactNode;
}
interface ErrorBoundaryState {
readonly caughtError: Error | null;
readonly componentStack: string;
readonly resetKey: string;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
public constructor(properties: ErrorBoundaryProps) {
super(properties);
this.state = { caughtError: null, componentStack: '', resetKey: properties.resetKey };
}
public static getDerivedStateFromError(caughtError: Error): Partial<ErrorBoundaryState> {
return { caughtError };
}
/**
* Verwirft den Fehler beim Seitenwechsel.
*
* Abgeleitet statt in einem Effekt gesetzt: Sonst zeigte die neue Seite noch
* einen Wimpernschlag lang den Fehler der alten.
*/
public static getDerivedStateFromProps(
nextProperties: ErrorBoundaryProps,
currentState: ErrorBoundaryState,
): Partial<ErrorBoundaryState> | null {
if (nextProperties.resetKey !== currentState.resetKey) {
return { caughtError: null, componentStack: '', resetKey: nextProperties.resetKey };
}
return null;
}
public override componentDidCatch(caughtError: Error, errorInfo: ErrorInfo): void {
this.setState({ componentStack: errorInfo.componentStack ?? '' });
// Die Meldung gehört in die Entwicklerkonsole, damit sie sich kopieren
// lässt. Sie hier zu verschlucken wäre der stille Fehler, den die
// Entwicklungsregeln ausdrücklich verbieten.
console.error('Unbehandelter Fehler in der Oberfläche:', caughtError, errorInfo);
}
public override render(): ReactNode {
const { caughtError, componentStack } = this.state;
if (caughtError === null) {
return this.props.children;
}
return (
<div className="rounded-lg border border-critical/30 bg-card p-6" role="alert">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 size-5 shrink-0 text-critical" aria-hidden />
<div className="min-w-0 flex-1">
<h2 className="text-sm font-semibold text-fg">
Diese Seite konnte nicht angezeigt werden
</h2>
<p className="mt-1 text-sm text-fg-muted">
Die übrige Konsole arbeitet weiter. Wechseln Sie den Bereich oder
laden Sie neu.
</p>
<pre className="mt-4 max-h-40 overflow-auto rounded-md bg-sunken p-3 text-xs text-fg">
{caughtError.message || caughtError.name}
{componentStack ? `\n${componentStack.trim().split('\n').slice(0, 6).join('\n')}` : ''}
</pre>
<div className="mt-4 flex gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => this.setState({ caughtError: null, componentStack: '' })}
>
Erneut versuchen
</Button>
<Button variant="ghost" size="sm" onClick={() => window.location.reload()}>
Seite neu laden
</Button>
</div>
</div>
</div>
</div>
);
}
}