import { View } from '@tarojs/components';
import { Component, PropsWithChildren, useSyncExternalStore } from 'react';
import { CircleAlert, Copy, RefreshCw, X } from 'lucide-react-taro';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Portal } from '@/components/ui/portal';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from '@/components/ui/toast';
import { cn } from '@/lib/utils';
import { IS_H5_ENV } from './env';
type ErrorState = {
error: Error | null;
};
type ErrorBoundaryProps = PropsWithChildren;
type ErrorReportOptions = {
componentStack?: string;
source?: string;
};
type OverlayStore = {
error: Error | null;
report: string;
source: string;
visible: boolean;
open: boolean;
timestamp: string;
};
const EMPTY_STORE: OverlayStore = {
error: null,
report: '',
source: '',
visible: false,
open: false,
timestamp: '',
};
const ERROR_ACCENT_COLOR = 'hsl(360, 100%, 45%)';
let handlersInstalled = false;
let overlayStore: OverlayStore = EMPTY_STORE;
const listeners = new Set<() => void>();
const emitChange = () => {
listeners.forEach(listener => listener());
};
const subscribe = (listener: () => void) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
const getSnapshot = () => overlayStore;
const setOverlayStore = (nextStore: OverlayStore) => {
overlayStore = nextStore;
emitChange();
};
const copyText = async (text: string) => {
if (typeof window === 'undefined') return false;
try {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (error) {
console.warn('[H5ErrorBoundary] Clipboard API copy failed:', error);
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', 'true');
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const copied = document.execCommand('copy');
document.body.removeChild(textarea);
return copied;
} catch (error) {
console.warn('[H5ErrorBoundary] Fallback copy failed:', error);
return false;
}
};
const normalizeError = (value: unknown) => {
if (value instanceof Error) {
return value;
}
if (typeof value === 'string') {
return new Error(value);
}
try {
return new Error(JSON.stringify(value));
} catch {
return new Error(String(value));
}
};
const buildErrorReport = (error: Error, options: ErrorReportOptions = {}) => {
const lines = [
'[H5 Runtime Error]',
`Time: ${new Date().toISOString()}`,
options.source ? `Source: ${options.source}` : '',
`Name: ${error.name}`,
`Message: ${error.message}`,
error.stack ? `Stack:\n${error.stack}` : '',
options.componentStack ? `Component Stack:\n${options.componentStack}` : '',
typeof navigator !== 'undefined'
? `User Agent: ${navigator.userAgent}`
: '',
].filter(Boolean);
return lines.join('\n\n');
};
const setPanelOpen = (open: boolean) => {
if (!overlayStore.visible) return;
setOverlayStore({
...overlayStore,
open,
});
};
export const showH5ErrorOverlay = (
input: unknown,
options: ErrorReportOptions = {},
) => {
if (typeof window === 'undefined') {
return;
}
const error = normalizeError(input);
const report = buildErrorReport(error, options);
const timestamp = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
});
setOverlayStore({
error,
report,
source: options.source || 'runtime',
timestamp,
visible: true,
open: false,
});
console.error('[H5ErrorOverlay] Showing error overlay:', error, options);
};
const handleWindowError = (event: ErrorEvent) => {
const error =
event.error || new Error(event.message || 'Unknown H5 runtime error');
showH5ErrorOverlay(error, { source: 'window.error' });
};
const handleUnhandledRejection = (event: PromiseRejectionEvent) => {
showH5ErrorOverlay(event.reason, { source: 'window.unhandledrejection' });
};
export const initializeH5ErrorHandling = () => {
if (!IS_H5_ENV || typeof window === 'undefined' || handlersInstalled) {
return;
}
handlersInstalled = true;
window.addEventListener('error', handleWindowError);
window.addEventListener('unhandledrejection', handleUnhandledRejection);
};
const H5ErrorOverlayHost = () => {
const store = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
if (!IS_H5_ENV || !store.visible) {
return null;
}
const errorName = store.error?.name || 'Error';
return (
{store.open && (
Runtime Error
{store.source}
{errorName}
Error
{store.error?.name || 'Error'}
Source
{store.source}
Full Report
{store.timestamp}
{store.report}
)}
);
};
class H5ErrorBoundaryInner extends Component {
static getDerivedStateFromError(error: Error): Partial {
return { error };
}
state: ErrorState = {
error: null,
};
componentDidUpdate(prevProps: ErrorBoundaryProps) {
if (this.state.error && prevProps.children !== this.props.children) {
this.setState({ error: null });
}
}
componentDidCatch(error: Error, info: { componentStack: string }) {
showH5ErrorOverlay(error, {
source: 'React Error Boundary',
componentStack: info.componentStack || '',
});
}
render() {
return (
<>
{this.state.error ? null : this.props.children}
>
);
}
}
export const H5ErrorBoundary = ({ children }: PropsWithChildren) => {
if (!IS_H5_ENV) {
return <>{children}>;
}
return {children};
};