Files
sencho/frontend/src/components/LazyBoundary.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

98 lines
4.1 KiB
TypeScript

import { Component, createRef } from 'react';
import type { ErrorInfo, ReactNode } from 'react';
import { AlertTriangle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { isChunkLoadError } from './isChunkLoadError';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
/**
* Specialized error boundary for lazy-loaded subtrees. Catches errors
* thrown during chunk fetch and renders a small card inviting the user
* to reload. Reload is the actual remedy because the stale tab is asking
* for chunk URLs that the deployed bundle no longer emits; a "Try again"
* against the same URL would just fail again.
*
* For non-chunk runtime errors (the lazy module loaded but threw during
* render) the boundary falls back to a "Try again" CTA that resets state
* and re-renders the children. This is safe because the lazy import has
* already resolved on this path, so re-rendering does not re-trigger a
* chunk fetch; if the underlying error is non-deterministic (e.g. a stale
* API response) the retry can succeed. If the error is deterministic the
* card just reappears, which is the expected behavior of any boundary.
*
* Sized via min-h-[280px] to look at home in both the workspace area
* and inline-section contexts that wrap lazy components.
*/
class LazyBoundary 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('LazyBoundary caught an error:', error, errorInfo);
}
public componentDidUpdate(_prevProps: Props, prevState: State) {
// Focus the CTA the moment the error UI mounts, not on every
// re-render of the error state, so we don't steal focus from a
// user who has already tabbed elsewhere within the card.
if (!prevState.hasError && this.state.hasError) {
this.ctaRef.current?.focus();
}
}
public render() {
if (!this.state.hasError) return this.props.children;
const isChunk = isChunkLoadError(this.state.error);
const title = isChunk ? 'This part of Sencho needs a reload' : 'Something went wrong';
const body = isChunk
? 'A newer version may have shipped while this tab was open. Reload to fetch the latest.'
: this.state.error?.message || 'Unknown error';
const ctaLabel = isChunk ? 'Reload' : 'Try again';
const onCta = isChunk
? () => window.location.reload()
: () => this.setState({ hasError: false, error: null });
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">{title}</p>
<p className="text-sm text-stat-subtitle">{body}</p>
</div>
<Button ref={this.ctaRef} variant="outline" size="sm" onClick={onCta}>
{ctaLabel}
</Button>
</div>
</div>
);
}
}
export default LazyBoundary;