feat(frontend): add LazyBoundary for chunk-load failure recovery (#875)

* feat(frontend): add LazyBoundary for chunk-load failure recovery

When a lazy chunk fetch fails, the existing top-level ErrorBoundary shows
"Something went wrong" with a "Try again" CTA. "Try again" cannot succeed
against a chunk URL that no longer exists on the server (typical post-
deploy case where the user's tab was opened against an older bundle).
The right remedy is to reload the tab so the browser fetches the new
hashed chunks emitted by the current build.

Add LazyBoundary, a section-local error boundary that:

- Detects chunk-load errors via a substring union covering Chrome / Edge
  ("Failed to fetch dynamically imported module"), Safari ("Importing a
  module script failed"), Firefox ("disallowed MIME type" thrown when a
  deploy serves SPA index.html for a missing chunk URL), older Webpack
  ("Error loading dynamically imported module"), and Vite ("Loading
  chunk/CSS chunk N failed").
- For chunk errors, renders a glass-card matching the LockCard aesthetic
  with an AlertTriangle icon, a "This part of Sencho needs a reload"
  message, and a Reload CTA that calls window.location.reload().
- For non-chunk runtime errors, falls back to "Something went wrong" +
  the error message + a Try again CTA. Try again is safe on this path
  because the lazy import has already resolved before the render error
  fires.
- Logs to console.error in componentDidCatch so the underlying failure
  is still observable.
- Has role="alert" so screen readers announce the failure.

Wrap every existing Suspense site (1 in SettingsPage, 7 in EditorLayout
including the security-history overlay, 1 in ResourcesView) with
LazyBoundary. The top-level ErrorBoundary remains the catch-all for
errors that escape the section-local boundary.

Includes a unit test enumerating each browser's documented chunk-load
message so a regression in any one runtime is caught early.

* fix(frontend): move isChunkLoadError to its own file to satisfy react-refresh/only-export-components
This commit is contained in:
Anso
2026-05-02 03:40:45 -04:00
committed by GitHub
parent 6fa0272e79
commit b843b89ca4
6 changed files with 251 additions and 58 deletions
+80
View File
@@ -0,0 +1,80 @@
import { Component } 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,
};
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 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 variant="outline" size="sm" onClick={onCta}>
{ctaLabel}
</Button>
</div>
</div>
);
}
}
export default LazyBoundary;