/** * ErrorBoundary.js * ================= * Preact Error Boundary Component for ComfyUI-Doctor. * Uses async factory pattern because preact-loader.js doesn't export named exports. * * R5: Frontend Error Boundaries * @module ErrorBoundary */ import { loadPreact } from './preact-loader.js'; import { recordError } from './island_registry.js'; import { sanitizeErrorData } from './privacy_utils.js'; /** * Create ErrorBoundary class using loaded Preact modules. * MUST be called AFTER loadPreact() completes. * * @param {Object} preactModules - Result from loadPreact() * @returns {Class} ErrorBoundary component class */ export function createErrorBoundary(preactModules) { const { Component, html } = preactModules; return class ErrorBoundary extends Component { constructor(props) { super(props); this.state = { hasError: false, error: null, errorId: null, attemptCount: 0, copied: false, copyFailed: false }; } componentDidCatch(error, errorInfo) { const errorId = this.generateErrorId(); const attemptCount = this.state.attemptCount + 1; // 1. Record to island registry if (this.props.islandId) { recordError(this.props.islandId, error); } // 2. Sanitize error data before logging (respect privacy_mode) const sanitized = sanitizeErrorData({ message: error.message, stack: error.stack }); // 3. Console logging (NO telemetry in Phase 1) console.error(`[ErrorBoundary] Component error (${errorId}):`, { islandId: this.props.islandId, error: sanitized.message, stack: sanitized.stack, attemptCount, componentStack: errorInfo?.componentStack }); // 4. Update UI state this.setState({ hasError: true, error, errorId, attemptCount }); } generateErrorId() { const timestamp = Date.now(); const random = Math.random().toString(36).substring(2, 11); return `err-${timestamp}-${random}`; } handleReload = () => { if (this.state.attemptCount < 3) { this.setState({ hasError: false }); // Remount children } } handleCopyId = async () => { try { await navigator.clipboard.writeText(this.state.errorId); this.setState({ copied: true, copyFailed: false }); setTimeout(() => this.setState({ copied: false }), 2000); } catch (err) { console.error('[ErrorBoundary] Failed to copy error ID:', err); // Fallback for non-secure context (http://, permissions denied) this.fallbackCopyToClipboard(this.state.errorId); } } /** * Fallback copy method for non-secure contexts. * Uses temporary textarea + execCommand('copy'). */ fallbackCopyToClipboard(text) { try { const textarea = document.createElement('textarea'); textarea.value = text; textarea.style.position = 'fixed'; textarea.style.opacity = '0'; document.body.appendChild(textarea); textarea.select(); const success = document.execCommand('copy'); document.body.removeChild(textarea); if (success) { this.setState({ copied: true, copyFailed: false }); setTimeout(() => this.setState({ copied: false }), 2000); } else { console.error('[ErrorBoundary] Fallback copy failed'); this.setState({ copyFailed: true }); setTimeout(() => this.setState({ copyFailed: false }), 2000); } } catch (err) { console.error('[ErrorBoundary] Fallback copy exception:', err); this.setState({ copyFailed: true }); setTimeout(() => this.setState({ copyFailed: false }), 2000); } } render() { if (this.state.hasError) { const isPermanent = this.state.attemptCount >= 3; const uiText = this.props.uiText || {}; return html`
${uiText.error_boundary_error_id_label || 'Error ID:'} ${this.state.errorId}