Files
sencho/frontend/src/components/ErrorBoundary.tsx
T
Anso 72919ccd1b chore(frontend): polish error boundaries and dismissal sync (#877)
Bundles three small follow-ups deferred from the recent lazy-loading
and gate-refactor PRs:

ErrorBoundary visual harmonization. The top-level boundary used a red
banner with custom button styling that no longer matched the glass-
card aesthetic the lock cards and LazyBoundary settled on. Replace
with the same glass-card + AlertTriangle layout. The user already
knows something broke when this fires; a calm card with a clear Try
again CTA is more actionable than the louder treatment, and a
consistent recovery surface across both boundaries means a user never
sees two different "something went wrong" treatments depending on
which boundary catches the error.

Keyboard focus on boundary trip. When either boundary trips, focus
typically falls back to <body> because the throwing subtree
unmounted. Keyboard users would have to tab from the top to reach the
recovery action. Add a ref on the CTA button and a componentDidUpdate
gate that focuses it on the false-to-true hasError transition. The
gate fires once per trip, not on every error-state re-render, so a
user who tabbed elsewhere within the card does not get focus stolen
back. Verified the gate also fires on a re-error after Try again
(setState({hasError:false}) re-renders with prevState.hasError=false,
the next throw flips to true and the transition condition triggers).

Cross-tab dismissal sync in useDismissalState. The hook previously
read localStorage only in the lazy initializer, so dismissing in tab
A did not propagate to tab B until tab B re-mounted. Add a useEffect
that listens for storage events on the configured key. The browser
fires storage events only in OTHER tabs than the one that wrote the
change, so this handles the tab B receives tab A's dismiss case;
same-tab updates flow through setDismissed directly, unchanged.
Malformed event.newValue (NaN, empty string) defaults to dismissed=
false, the conservative outcome.

Adds 4 new vitest cases for the storage-event paths: recent
timestamp, null newValue (restore), unrelated key, and stale
timestamp.
2026-05-02 04:15:49 -04:00

88 lines
3.3 KiB
TypeScript

import { Component, createRef } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* App-level catch-all error boundary. Sits above feature-specific
* boundaries (e.g. `LazyBoundary` around `<Suspense>` blocks) so any
* uncaught render error in a non-lazy subtree still produces a
* consistent recovery card instead of a blank page.
*
* Visually matches `LazyBoundary`: glass card, AlertTriangle icon,
* single Try-again CTA. The shared aesthetic is intentional so a user
* never sees two different "something went wrong" treatments depending
* on which boundary catches the error.
*
* "Try again" resets the boundary's state, which causes React to
* re-render the children. For deterministic errors this just shows the
* card again, which is the expected behavior of any error boundary;
* for transient errors (stale API response, race) it can recover
* cleanly.
*/
class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};
/**
* Ref on the CTA button so we can focus it when the boundary trips.
* When a render error fires, focus is typically inside the now-
* unmounted subtree and falls back to <body>; keyboard users would
* otherwise have to tab from the top to reach the recovery action.
*/
private ctaRef = createRef<HTMLButtonElement>();
public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('ErrorBoundary caught an error:', error, errorInfo);
}
public componentDidUpdate(_prevProps: Props, prevState: State) {
if (!prevState.hasError && this.state.hasError) {
this.ctaRef.current?.focus();
}
}
public render() {
if (!this.state.hasError) return this.props.children;
return (
<div className="flex flex-1 items-center justify-center min-h-[280px] p-8" role="alert">
<div className="flex flex-col items-center gap-4 rounded-xl border border-glass-border bg-glass px-10 py-8 text-center max-w-md">
<div className="flex items-center justify-center w-12 h-12 rounded-full border border-glass-border bg-glass">
<AlertTriangle className="w-5 h-5 text-stat-subtitle" strokeWidth={1.5} />
</div>
<div className="flex flex-col gap-1">
<p className="text-sm font-semibold text-stat-value">Something went wrong</p>
<p className="text-sm text-stat-subtitle">{this.state.error?.message || 'Unknown error'}</p>
</div>
<Button
ref={this.ctaRef}
variant="outline"
size="sm"
onClick={() => this.setState({ hasError: false, error: null })}
>
Try again
</Button>
</div>
</div>
);
}
}
export default ErrorBoundary;