'use client'; import { AlertTriangle, RotateCcw } from 'lucide-react'; import { Component, createContext, useContext, type ComponentType, type ErrorInfo, type ReactNode, } from 'react'; import { isDev } from '../../lib/env'; import { cn } from '../../lib/utils'; import { Button } from '../forms/button'; export type BoundaryVariant = 'silent' | 'inline' | 'card' | 'fullscreen'; export interface BoundaryRenderProps { error: Error; errorInfo: ErrorInfo | null; reset: () => void; } export type BoundaryResetReason = 'imperative' | 'keys'; export interface BoundaryResetDetails { reason: BoundaryResetReason; prevResetKeys?: ReadonlyArray; nextResetKeys?: ReadonlyArray; } export interface BoundaryLogger { (message: string, error: Error, info: ErrorInfo | null): void; } export interface BoundaryProps { children: ReactNode; /** * Visual style of the fallback. * - silent: render nothing (good for non-critical widgets like a chat launcher) * - inline: compact one-line warning (good for inline blocks inside a page) * - card: bordered card with retry button (default; good for panels/features) * - fullscreen: centered fullscreen fallback (good for top-level layout) * @default 'card' */ variant?: BoundaryVariant; /** * Custom fallback. Receives the caught error and a reset() function. * Overrides `variant` rendering when provided. * Takes precedence over `FallbackComponent`. */ fallback?: ReactNode | ((props: BoundaryRenderProps) => ReactNode); /** * Custom fallback component. Use this form instead of `fallback` for * better memoization (no inline closures). */ FallbackComponent?: ComponentType; /** * Auto-reset the boundary when any of these values change (shallow Object.is per index). * Common use: pass `[pathname]` to clear errors on route change. * AVOID passing volatile values like `Math.random()` or new object/array literals — causes reset loops. */ resetKeys?: ReadonlyArray; /** * Called when an error is caught. * Hook up Sentry / logging / @djangocfg/monitor here. * Use the wrapping `MonitorBoundary` from @djangocfg/layouts for automatic reporting. */ onError?: (error: Error, info: ErrorInfo) => void; /** * Called when the boundary recovers (via Retry button or resetKeys change). * Use it to refetch data / invalidate caches (e.g. React Query queryClient.invalidateQueries). */ onReset?: (details: BoundaryResetDetails) => void; /** * Optional label shown in dev console logs to help locate the source. */ name?: string; /** * Extra className for the fallback wrapper (variant: inline / card / fullscreen). */ className?: string; /** * Replace default dev console logger. Defaults to `console.error` in development, no-op in production. */ logger?: BoundaryLogger; } interface BoundaryState { error: Error | null; errorInfo: ErrorInfo | null; } interface BoundaryContextValue { /** Programmatically push an error into the nearest boundary (use for async / event handler errors). */ showBoundary: (error: unknown) => void; /** Programmatically clear the boundary (same as the Retry button). */ resetBoundary: () => void; } const BoundaryContext = createContext(null); /** * Access the nearest `` programmatically. * Lets you funnel errors from async code / event handlers into the boundary * (regular React error boundaries don't catch those automatically). * * @example * const { showBoundary } = useBoundary(); * useEffect(() => { * fetchData().catch(showBoundary); * }, [showBoundary]); */ export function useBoundary(): BoundaryContextValue { const ctx = useContext(BoundaryContext); if (!ctx) { throw new Error('useBoundary must be used inside '); } return ctx; } function arraysShallowEqual(a: ReadonlyArray, b: ReadonlyArray): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { if (!Object.is(a[i], b[i])) return false; } return true; } function toError(value: unknown): Error { 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('Unknown error'); } } const defaultLogger: BoundaryLogger = (message, error, info) => { if (!isDev) return; console.error(message, error, info ?? ''); }; export class Boundary extends Component { state: BoundaryState = { error: null, errorInfo: null }; /** Stable context value identity — recreated only when error/reset functions change (i.e. on mount). */ private contextValue: BoundaryContextValue = { showBoundary: (error: unknown) => this.showBoundary(error), resetBoundary: () => this.reset('imperative'), }; /** Prevents `onError` from firing twice for the same error instance. */ private lastReportedError: Error | null = null; static getDerivedStateFromError(error: unknown): BoundaryState { return { error: toError(error), errorInfo: null }; } componentDidCatch(error: Error, info: ErrorInfo) { const normalized = toError(error); this.setState({ errorInfo: info }); if (this.lastReportedError === normalized) return; this.lastReportedError = normalized; try { this.props.onError?.(normalized, info); } catch (callbackErr) { // Never let user's onError crash the boundary. (this.props.logger ?? defaultLogger)( `[Boundary${this.props.name ? `:${this.props.name}` : ''}] onError handler threw`, toError(callbackErr), null, ); } const tag = this.props.name ? `[Boundary:${this.props.name}]` : '[Boundary]'; (this.props.logger ?? defaultLogger)(`${tag} caught error`, normalized, info); } componentDidUpdate(prevProps: BoundaryProps) { if (!this.state.error) return; const prevKeys = prevProps.resetKeys; const nextKeys = this.props.resetKeys; if (prevKeys && nextKeys && !arraysShallowEqual(prevKeys, nextKeys)) { this.reset('keys', prevKeys, nextKeys); } } /** Imperatively raise an error from async code / event handlers via `useBoundary()`. */ showBoundary = (raw: unknown) => { const error = toError(raw); this.setState({ error, errorInfo: null }); }; reset = ( reason: BoundaryResetReason = 'imperative', prevResetKeys?: ReadonlyArray, nextResetKeys?: ReadonlyArray, ) => { // Clear state BEFORE calling onReset — otherwise an error thrown // inside onReset would re-trigger the boundary before state is cleared. this.lastReportedError = null; this.setState({ error: null, errorInfo: null }); // Defer onReset to a microtask so the state clear is flushed first. queueMicrotask(() => { try { this.props.onReset?.({ reason, prevResetKeys, nextResetKeys }); } catch (err) { (this.props.logger ?? defaultLogger)( `[Boundary${this.props.name ? `:${this.props.name}` : ''}] onReset handler threw`, toError(err), null, ); } }); }; /** Wraps fallback rendering so errors in the fallback itself can't crash the boundary. */ private safeRenderFallback(): ReactNode { const { error, errorInfo } = this.state; if (!error) return null; const { fallback, FallbackComponent, variant = 'card', className } = this.props; try { if (typeof fallback === 'function') { return fallback({ error, errorInfo, reset: () => this.reset('imperative') }); } if (fallback !== undefined) { return fallback; } if (FallbackComponent) { return ( this.reset('imperative')} /> ); } return renderVariant(variant, error, () => this.reset('imperative'), className); } catch (fallbackError) { // Last-resort static fallback — prevents infinite loops if user fallback throws. (this.props.logger ?? defaultLogger)( `[Boundary${this.props.name ? `:${this.props.name}` : ''}] fallback render threw`, toError(fallbackError), null, ); return (
Something went wrong, and the error fallback also failed to render.
); } } render() { if (this.state.error) { return this.safeRenderFallback(); } return ( {this.props.children} ); } } function renderVariant( variant: BoundaryVariant, error: Error, reset: () => void, className?: string, ): ReactNode { if (variant === 'silent') return null; const message = isDev ? error.message : null; if (variant === 'inline') { return (
{message ?? 'Something went wrong.'}
); } if (variant === 'fullscreen') { return (

Something went wrong

We're sorry, but something unexpected happened. Please try refreshing the page.

{message && (
              {message}
            
)}
); } // card (default) return (

Something went wrong

{message &&

{message}

}
); }