Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
311 changes: 198 additions & 113 deletions src/components/ErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -1,149 +1,234 @@
import { Component, type ReactNode } from "react";
import { reportClientError } from "../lib/telemetry";
import { emergencyEmit } from "../lib/heapForensics";

/**
* UI bug vs memory exhaustion (#793). OOM surfaces as RangeError
* ("Array buffer allocation failed", "Invalid array length", "Maximum call
* stack") or engine "out of memory" messages; those get the preallocated,
* hint-free forensic census instead of a full report that would allocate more.
*/
export function isLikelyOom(error: unknown): boolean {
const msg = String((error as Error)?.message ?? error).toLowerCase();
return (
(error instanceof RangeError && /allocation|array length|buffer|call stack/.test(msg)) ||
/out of memory|oom|allocation failed/.test(msg)
);
}
import React, { Component, ReactNode } from 'react'
import { recordError, linkError } from '../lib/telemetry'

interface Props {
children: ReactNode;
fallback?: ReactNode;
children: ReactNode
fallback?: ReactNode
onError?: (error: Error, errorInfo: React.ErrorInfo) => void
}

interface State {
hasError: boolean;
error: Error | null;
hasError: boolean
error: Error | null
errorCount: number
lastErrorTime: number
}

export default class ErrorBoundary extends Component<Props, State> {
/**
* ErrorBoundary with crash recovery (Issue #746)
* - Catches unhandled React errors
* - Links errors to OTel traces via trace context
* - Recovers from cascading failures via exponential backoff reset
* - Prevents service worker failures from blocking the UI
*/
export class ErrorBoundary extends Component<Props, State> {
private resetTimeoutId: NodeJS.Timeout | null = null
private readonly MAX_ERRORS_BEFORE_RESET = 3
private readonly RESET_INTERVAL_MS = 5000

constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
super(props)
this.state = {
hasError: false,
error: null,
errorCount: 0,
lastErrorTime: 0,
}
}

static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error }
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
if (isLikelyOom(error)) {
emergencyEmit();
return;
const { onError } = this.props
const traceId = this.getTraceId()

// Record error with OTel linkage
recordError(error, {
source: 'react-error-boundary',
component: errorInfo.componentStack,
traceId,
})

// Link error to current trace context
if (traceId) {
linkError(error, traceId)
}

// Update error count and check for cascade failures
this.setState((prev) => {
const timeSinceLastError = Date.now() - prev.lastErrorTime
const errorCount =
timeSinceLastError > this.RESET_INTERVAL_MS * 2 ? 1 : prev.errorCount + 1

if (errorCount >= this.MAX_ERRORS_BEFORE_RESET) {
console.warn('[ErrorBoundary] Cascade failure detected, scheduling recovery reset')
this.scheduleRecoveryReset()
}

return {
errorCount,
lastErrorTime: Date.now(),
}
})

// Notify parent if callback provided
if (onError) {
onError(error, errorInfo)
}

// Attempt to clear blob-worker stuck state
this.clearBlobWorkerState()

// Log to console in development
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary caught:', error, errorInfo)
}
}

private getTraceId(): string | null {
// Extract trace ID from W3C trace context if available
const traceParent = document.querySelector('meta[name="trace-id"]')?.getAttribute('content')
if (traceParent) {
const parts = traceParent.split('-')
return parts.length > 1 ? parts[1] : null
}
// Fallback: check sessionStorage for ongoing trace
return sessionStorage.getItem('otel_trace_id')
}

private clearBlobWorkerState() {
// Clear stuck blob-worker state that might prevent window.onerror
try {
// Remove any pending blob URLs that might block cleanup
const blobUrls = sessionStorage.getItem('pending_blob_urls')
if (blobUrls) {
const urls = JSON.parse(blobUrls)
urls.forEach((url: string) => {
try {
URL.revokeObjectURL(url)
} catch {
// Ignore if already revoked
}
})
sessionStorage.removeItem('pending_blob_urls')
}

// Signal service worker to clear cache if stuck
if ('serviceWorker' in navigator) {
navigator.serviceWorker.controller?.postMessage({
type: 'CLEAR_ERROR_STATE',
timestamp: Date.now(),
})
}
} catch (e) {
console.warn('[ErrorBoundary] Failed to clear blob-worker state:', e)
}
const traceId = reportClientError(error, errorInfo.componentStack || undefined);
console.error("[ErrorBoundary] Component error caught:", { error, traceId });
}

handleReload = () => {
window.location.reload();
};
private scheduleRecoveryReset() {
if (this.resetTimeoutId) {
clearTimeout(this.resetTimeoutId)
}

this.resetTimeoutId = setTimeout(() => {
console.log('[ErrorBoundary] Executing cascade failure recovery reset')
this.setState({
hasError: false,
error: null,
errorCount: 0,
lastErrorTime: 0,
})
this.resetTimeoutId = null
}, this.RESET_INTERVAL_MS)
}

componentWillUnmount() {
if (this.resetTimeoutId) {
clearTimeout(this.resetTimeoutId)
}
}

private handleRetry = () => {
console.log('[ErrorBoundary] User initiated recovery')
this.setState({
hasError: false,
error: null,
errorCount: 0,
})
}

render() {
if (this.state.hasError) {
if (this.props.fallback) {
return this.props.fallback;
const { children, fallback } = this.props
const { hasError, error } = this.state

if (hasError) {
if (fallback) {
return fallback
}

return (
<div
role="alert"
aria-label="Application error"
style={{
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
minHeight: "100vh",
padding: "24px",
fontFamily: "system-ui, -apple-system, sans-serif",
background: "#ECE0CC",
color: "#234B4E",
textAlign: "center",
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
backgroundColor: '#234B4E',
color: '#ECE0CC',
padding: '2rem',
fontFamily: 'system-ui, -apple-system, sans-serif',
}}
>
<div
style={{
width: "56px",
height: "56px",
borderRadius: "50%",
background: "rgba(255,122,107,0.15)",
border: "1px solid rgba(255,122,107,0.3)",
display: "flex",
alignItems: "center",
justifyContent: "center",
marginBottom: "20px",
}}
>
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="#FF7A6B"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
</div>
<h2
style={{
margin: "0 0 8px",
fontFamily: "'Instrument Serif', serif",
fontWeight: 400,
fontSize: "26px",
color: "#234B4E",
}}
>
Something went wrong
</h2>
<p
style={{
margin: "0 0 24px",
fontSize: "14px",
lineHeight: 1.55,
color: "#5a554c",
maxWidth: "400px",
}}
>
An unexpected error occurred. Please try reloading the page.
<h1 style={{ marginBottom: '1rem', color: '#FF7A6B' }}>Something went wrong</h1>
<p style={{ marginBottom: '1rem', opacity: 0.8 }}>
{error?.message || 'An unexpected error occurred. Please try again.'}
</p>
<button
type="button"
onClick={this.handleReload}
onClick={this.handleRetry}
style={{
padding: "12px 28px",
borderRadius: "10px",
border: "none",
background: "#FF7A6B",
color: "#fff",
fontSize: "15px",
fontWeight: 700,
cursor: "pointer",
minHeight: "44px",
padding: '0.75rem 1.5rem',
backgroundColor: '#FF7A6B',
color: '#234B4E',
border: 'none',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: 'bold',
fontSize: '1rem',
}}
onMouseOver={(e) => {
;(e.target as HTMLButtonElement).style.opacity = '0.9'
}}
onMouseOut={(e) => {
;(e.target as HTMLButtonElement).style.opacity = '1'
}}
>
Reload
Try Again
</button>
{process.env.NODE_ENV === 'development' && (
<pre
style={{
marginTop: '2rem',
padding: '1rem',
backgroundColor: 'rgba(0,0,0,0.2)',
borderRadius: '4px',
overflow: 'auto',
maxHeight: '200px',
fontSize: '0.85rem',
fontFamily: 'monospace',
}}
>
{error?.stack}
</pre>
)}
</div>
);
)
}

return this.props.children;
return children
}
}

export default ErrorBoundary
Loading