diff --git a/Frontend/src/components/ErrorBoundary.tsx b/Frontend/src/components/ErrorBoundary.tsx new file mode 100644 index 000000000..5f9390d7a --- /dev/null +++ b/Frontend/src/components/ErrorBoundary.tsx @@ -0,0 +1,176 @@ +import React, { ErrorInfo, ReactNode } from 'react'; +import { buildErrorPayload, copyErrorPayload } from '../utils/errorDiagnostics'; + +type ErrorBoundaryProps = { + children: ReactNode; + title?: string; + description?: string; +}; + +type ErrorBoundaryState = { + hasError: boolean; + error: Error | null; + errorInfo: ErrorInfo | null; + copied: boolean; +}; + +class ErrorBoundary extends React.Component { + state: ErrorBoundaryState = { + hasError: false, + error: null, + errorInfo: null, + copied: false, + }; + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + copied: false, + }; + } + + componentDidCatch(error: Error, errorInfo: ErrorInfo) { + this.setState({ errorInfo }); + console.error('[error-boundary] Unhandled application error', error, errorInfo); + } + + handleRetry = () => { + this.setState({ + hasError: false, + error: null, + errorInfo: null, + copied: false, + }); + }; + + handleCopy = async () => { + const { error, errorInfo } = this.state; + + if (!error) { + return; + } + + const payload = buildErrorPayload(error, errorInfo); + const copied = await copyErrorPayload(payload); + this.setState({ copied }); + }; + + render() { + if (!this.state.hasError) { + return this.props.children; + } + + const payload = this.state.error + ? buildErrorPayload(this.state.error, this.state.errorInfo) + : null; + + return ( +
+
+
+ Application Recovery +
+ +
+

+ Something went wrong +

+

+ {this.props.title ?? 'We hit an unexpected error.'} +

+

+ {this.props.description ?? + 'The page crashed before it could finish rendering. You can retry immediately or copy the diagnostic payload and share it with support for faster investigation.'} +

+ +
+ + +
+ + {payload ? ( +
+                {payload}
+              
+ ) : null} +
+
+
+ ); + } +} + +export default ErrorBoundary; diff --git a/Frontend/src/main.tsx b/Frontend/src/main.tsx new file mode 100644 index 000000000..c07ab8b85 --- /dev/null +++ b/Frontend/src/main.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; +import ErrorBoundary from './components/ErrorBoundary'; + +ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( + + + + + , +); diff --git a/Frontend/src/store/createPersistedStore.ts b/Frontend/src/store/createPersistedStore.ts new file mode 100644 index 000000000..e7966ea6b --- /dev/null +++ b/Frontend/src/store/createPersistedStore.ts @@ -0,0 +1,106 @@ +import { StateCreator, StoreMutatorIdentifier, create } from 'zustand'; +import { StateStorage, StorageValue, createJSONStorage, persist } from 'zustand/middleware'; + +type PersistedStoreOptions = { + name: string; + version?: number; + partialize?: (state: T) => Partial; +}; + +type WriteResult = { + ok: boolean; + error?: Error; +}; + +const inMemoryFallback = new Map(); + +const normalizeError = (error: unknown): Error => { + if (error instanceof Error) { + return error; + } + + return new Error(typeof error === 'string' ? error : 'Unknown storage error'); +}; + +const safeStorage: StateStorage = { + getItem: (name) => { + try { + if (typeof window === 'undefined' || !window.localStorage) { + return inMemoryFallback.get(name) ?? null; + } + + return window.localStorage.getItem(name); + } catch (error) { + console.error('[store:persist] Failed to read localStorage key:', name, error); + return inMemoryFallback.get(name) ?? null; + } + }, + setItem: (name, value) => { + const result = writeStorageValue(name, value); + + if (!result.ok) { + console.error('[store:persist] Failed to write localStorage key:', name, result.error); + } + }, + removeItem: (name) => { + try { + inMemoryFallback.delete(name); + + if (typeof window === 'undefined' || !window.localStorage) { + return; + } + + window.localStorage.removeItem(name); + } catch (error) { + console.error('[store:persist] Failed to remove localStorage key:', name, error); + } + }, +}; + +export const writeStorageValue = (name: string, value: string): WriteResult => { + try { + inMemoryFallback.set(name, value); + + if (typeof window === 'undefined' || !window.localStorage) { + return { ok: true }; + } + + window.localStorage.setItem(name, value); + return { ok: true }; + } catch (error) { + const normalized = normalizeError(error); + + if (normalized.name === 'QuotaExceededError') { + console.error('[store:persist] Storage quota exceeded while writing key:', name); + } + + return { ok: false, error: normalized }; + } +}; + +export const createPersistedStore = ( + initializer: StateCreator, + options: PersistedStoreOptions, +) => { + return create()( + persist(initializer, { + name: options.name, + version: options.version ?? 1, + partialize: options.partialize, + storage: createJSONStorage(() => safeStorage), + onRehydrateStorage: () => (state, error) => { + if (error) { + console.error('[store:persist] Failed to rehydrate store:', options.name, error); + return; + } + + if (!state) { + console.warn('[store:persist] No state available during rehydration for store:', options.name); + } + }, + }), + ); +}; + +export type PersistedStateCreator = StateCreator; +export type PersistedStorageValue = StorageValue; diff --git a/Frontend/src/utils/errorDiagnostics.ts b/Frontend/src/utils/errorDiagnostics.ts new file mode 100644 index 000000000..1c3835a3d --- /dev/null +++ b/Frontend/src/utils/errorDiagnostics.ts @@ -0,0 +1,50 @@ +import { ErrorInfo } from 'react'; + +type DiagnosticPayload = { + message: string; + stack: string | null; + componentStack: string | null; + userAgent: string | null; + url: string | null; + timestamp: string; +}; + +export const buildErrorPayload = (error: Error, errorInfo?: ErrorInfo | null) => { + const payload: DiagnosticPayload = { + message: error.message, + stack: error.stack ?? null, + componentStack: errorInfo?.componentStack ?? null, + userAgent: typeof navigator !== 'undefined' ? navigator.userAgent : null, + url: typeof window !== 'undefined' ? window.location.href : null, + timestamp: new Date().toISOString(), + }; + + return JSON.stringify(payload, null, 2); +}; + +export const copyErrorPayload = async (payload: string) => { + try { + if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(payload); + return true; + } + + if (typeof document === 'undefined') { + return false; + } + + const textArea = document.createElement('textarea'); + textArea.value = payload; + 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.error('[error-boundary] Failed to copy diagnostic payload', error); + return false; + } +};