From d17fc5ddd788456e8940d7dd5c88dd6c6efaf359 Mon Sep 17 00:00:00 2001 From: Chad Curtis Date: Thu, 17 Jul 2025 17:18:58 +0000 Subject: [PATCH] Add default ErrorBoundary with iframe postMessage to template. --- src/components/ErrorBoundary.tsx | 143 +++++++++++++++++++++++++++++++ src/main.tsx | 7 +- src/test/ErrorBoundary.test.tsx | 90 +++++++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 src/components/ErrorBoundary.tsx create mode 100644 src/test/ErrorBoundary.test.tsx diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..52a5f91 --- /dev/null +++ b/src/components/ErrorBoundary.tsx @@ -0,0 +1,143 @@ +import { Component, ErrorInfo, ReactNode } from 'react'; + +interface ErrorBoundaryState { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; +} + +interface ErrorBoundaryProps { + children: ReactNode; + fallback?: ReactNode; +} + +interface ErrorMessage { + type: 'mkstack-error'; + error: { + message: string; + stack?: string; + componentStack?: string; + url: string; + timestamp: string; + userAgent: string; + }; +} + +export class ErrorBoundary extends Component { + constructor(props: ErrorBoundaryProps) { + super(props); + this.state = { + hasError: false, + error: null, + errorInfo: null, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('Error caught by ErrorBoundary:', error, errorInfo); + + // Send error details via postMessage for iframe embedding + const errorMessage: ErrorMessage = { + type: 'mkstack-error', + error: { + message: error.message, + stack: error.stack || undefined, + componentStack: errorInfo.componentStack || undefined, + url: window.location.href, + timestamp: new Date().toISOString(), + userAgent: navigator.userAgent, + }, + }; + + // Send to parent window if in iframe, or broadcast to all + if (window.parent !== window) { + window.parent.postMessage(errorMessage, '*'); + } else { + window.postMessage(errorMessage, '*'); + } + + this.setState({ + error, + errorInfo, + }); + } + + handleReset = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + }); + }; + + render() { + if (this.state.hasError) { + if (this.props.fallback) { + return this.props.fallback; + } + + return ( +
+
+
+

+ Something went wrong +

+

+ An unexpected error occurred. The error has been reported. +

+
+ +
+
+ + Error details + +
+
+ Message: +

+ {this.state.error?.message} +

+
+ {this.state.error?.stack && ( +
+ Stack trace: +
+                        {this.state.error.stack}
+                      
+
+ )} +
+
+
+ +
+ + +
+
+
+ ); + } + + return this.props.children; + } +} \ No newline at end of file diff --git a/src/main.tsx b/src/main.tsx index f322051..e263904 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,10 +3,15 @@ import { createRoot } from 'react-dom/client'; // Import polyfills first import './lib/polyfills.ts'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; import App from './App.tsx'; import './index.css'; // FIXME: a custom font should be used. Eg: // import '@fontsource-variable/'; -createRoot(document.getElementById("root")!).render(); +createRoot(document.getElementById("root")!).render( + + + +); diff --git a/src/test/ErrorBoundary.test.tsx b/src/test/ErrorBoundary.test.tsx new file mode 100644 index 0000000..14dd33f --- /dev/null +++ b/src/test/ErrorBoundary.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { ErrorBoundary } from '@/components/ErrorBoundary'; + +// Mock window.postMessage +const postMessageMock = vi.fn(); +Object.defineProperty(window, 'postMessage', { + value: postMessageMock, +}); + +// Test component that throws an error +const ThrowError = ({ shouldThrow }: { shouldThrow: boolean }) => { + if (shouldThrow) { + throw new Error('Test error'); + } + return
No error
; +}; + +describe('ErrorBoundary', () => { + it('renders children when no error occurs', () => { + render( + +
Test content
+
+ ); + + expect(screen.getByText('Test content')).toBeInTheDocument(); + }); + + it('catches and displays error when child throws', () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + + ); + + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + expect(screen.getByText('An unexpected error occurred. The error has been reported.')).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); + + it('sends error details via postMessage', () => { + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + postMessageMock.mockClear(); + + render( + + + + ); + + expect(postMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'mkstack-error', + error: expect.objectContaining({ + message: 'Test error', + stack: expect.any(String), + url: expect.any(String), + timestamp: expect.any(String), + userAgent: expect.any(String), + }), + }), + '*' + ); + + consoleSpy.mockRestore(); + }); + + it('uses custom fallback when provided', () => { + const customFallback =
Custom error message
; + + // Suppress console.error for this test + const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + render( + + + + ); + + expect(screen.getByText('Custom error message')).toBeInTheDocument(); + + consoleSpy.mockRestore(); + }); +}); \ No newline at end of file