ERP-node/frontend/lib/v2-core/components/V2ErrorBoundary.tsx

361 lines
9.8 KiB
TypeScript

"use client";
/**
* V2 ErrorBoundary - 컴포넌트별 에러 격리
*
* 특징:
* - 각 컴포넌트의 에러가 다른 컴포넌트에 영향을 주지 않음
* - 폴백 UI 제공
* - 재시도 기능
* - 에러 로깅 및 이벤트 발행
*/
import React, { Component, ErrorInfo, ReactNode } from "react";
import { v2EventBus, V2_EVENTS } from "../events";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
interface V2ErrorBoundaryProps {
/** 자식 컴포넌트 */
children: ReactNode;
/** 컴포넌트 ID (에러 추적용) */
componentId: string;
/** 컴포넌트 타입 (에러 추적용) */
componentType: string;
/** 사용자 정의 폴백 UI */
fallback?: ReactNode | ((error: Error, retry: () => void) => ReactNode);
/** 폴백 UI 표시 방식 */
fallbackStyle?: "minimal" | "compact" | "full";
/** 에러 발생 시 콜백 */
onError?: (error: Error, errorInfo: ErrorInfo) => void;
/** 복구 가능 여부 */
recoverable?: boolean;
/** 자동 재시도 횟수 (0이면 자동 재시도 안 함) */
autoRetryCount?: number;
/** 자동 재시도 간격 (ms) */
autoRetryDelay?: number;
}
interface V2ErrorBoundaryState {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
retryCount: number;
}
export class V2ErrorBoundary extends Component<
V2ErrorBoundaryProps,
V2ErrorBoundaryState
> {
private retryTimeoutId: ReturnType<typeof setTimeout> | null = null;
static defaultProps = {
fallbackStyle: "compact" as const,
recoverable: true,
autoRetryCount: 0,
autoRetryDelay: 3000,
};
constructor(props: V2ErrorBoundaryProps) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
retryCount: 0,
};
}
static getDerivedStateFromError(error: Error): Partial<V2ErrorBoundaryState> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
const { componentId, componentType, onError, autoRetryCount = 0 } = this.props;
const { retryCount } = this.state;
// 상태 업데이트
this.setState({ errorInfo });
// 에러 로깅
console.error(
`[V2ErrorBoundary] 컴포넌트 에러 - ${componentType}(${componentId}):`,
error
);
console.error("Component Stack:", errorInfo.componentStack);
// 에러 이벤트 발행
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
componentId,
componentType,
error,
recoverable: this.props.recoverable ?? true,
});
// 사용자 정의 에러 핸들러 호출
onError?.(error, errorInfo);
// 자동 재시도
if (autoRetryCount > 0 && retryCount < autoRetryCount) {
this.scheduleAutoRetry();
}
}
componentWillUnmount(): void {
if (this.retryTimeoutId) {
clearTimeout(this.retryTimeoutId);
}
}
private scheduleAutoRetry = (): void => {
const { autoRetryDelay = 3000 } = this.props;
this.retryTimeoutId = setTimeout(() => {
this.handleRetry();
}, autoRetryDelay);
};
private handleRetry = (): void => {
const { componentId, componentType } = this.props;
// 복구 이벤트 발행
v2EventBus.emitSync(V2_EVENTS.COMPONENT_RECOVER, {
componentId,
componentType,
});
this.setState((prev) => ({
hasError: false,
error: null,
errorInfo: null,
retryCount: prev.retryCount + 1,
}));
};
private renderMinimalFallback(): ReactNode {
const { recoverable = true } = this.props;
return (
<div className="flex items-center justify-center p-2 text-xs text-destructive">
<AlertCircle className="mr-1 h-3 w-3" />
<span> </span>
{recoverable && (
<button
onClick={this.handleRetry}
className="ml-2 underline hover:no-underline"
>
</button>
)}
</div>
);
}
private renderCompactFallback(): ReactNode {
const { componentType, recoverable = true } = this.props;
const { error } = this.state;
return (
<div className="rounded-md border border-destructive/50 bg-destructive/5 p-3">
<div className="flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-destructive" />
<span className="text-sm font-medium text-destructive">
{componentType}
</span>
</div>
{error && (
<p className="mt-1 text-xs text-muted-foreground">
{error.message.substring(0, 100)}
{error.message.length > 100 ? "..." : ""}
</p>
)}
{recoverable && (
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="mt-2"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
</div>
);
}
private renderFullFallback(): ReactNode {
const { componentId, componentType, recoverable = true } = this.props;
const { error, errorInfo, retryCount } = this.state;
return (
<Alert variant="destructive" className="my-2">
<AlertCircle className="h-4 w-4" />
<AlertTitle>
{componentType}
</AlertTitle>
<AlertDescription>
<div className="mt-2 space-y-2">
<p className="text-sm">
<strong> ID:</strong> {componentId}
</p>
{error && (
<p className="text-sm">
<strong> :</strong> {error.message}
</p>
)}
{retryCount > 0 && (
<p className="text-sm text-muted-foreground">
: {retryCount}
</p>
)}
{process.env.NODE_ENV === "development" && errorInfo && (
<details className="mt-2">
<summary className="cursor-pointer text-xs">
</summary>
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap rounded bg-muted p-2 text-xs">
{errorInfo.componentStack}
</pre>
</details>
)}
{recoverable && (
<Button
variant="outline"
size="sm"
onClick={this.handleRetry}
className="mt-2"
>
<RefreshCw className="mr-1 h-3 w-3" />
</Button>
)}
</div>
</AlertDescription>
</Alert>
);
}
render(): ReactNode {
const { children, fallback, fallbackStyle = "compact" } = this.props;
const { hasError, error } = this.state;
if (!hasError) {
return children;
}
// 사용자 정의 폴백
if (fallback) {
if (typeof fallback === "function") {
return fallback(error!, this.handleRetry);
}
return fallback;
}
// 기본 폴백 스타일별 렌더링
switch (fallbackStyle) {
case "minimal":
return this.renderMinimalFallback();
case "full":
return this.renderFullFallback();
case "compact":
default:
return this.renderCompactFallback();
}
}
}
// ============================================================================
// 함수형 래퍼 (HOC)
// ============================================================================
interface WithV2ErrorBoundaryOptions {
componentType: string;
fallbackStyle?: "minimal" | "compact" | "full";
recoverable?: boolean;
}
/**
* V2 에러 바운더리 HOC
*
* @example
* ```typescript
* const SafeComponent = withV2ErrorBoundary(MyComponent, {
* componentType: "MyComponent",
* fallbackStyle: "compact",
* });
* ```
*/
export function withV2ErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
options: WithV2ErrorBoundaryOptions
): React.FC<P & { componentId?: string }> {
const { componentType, fallbackStyle, recoverable } = options;
const WithErrorBoundary: React.FC<P & { componentId?: string }> = (props) => {
const componentId =
props.componentId ?? `${componentType}_${Date.now()}`;
return (
<V2ErrorBoundary
componentId={componentId}
componentType={componentType}
fallbackStyle={fallbackStyle}
recoverable={recoverable}
>
<WrappedComponent {...props} />
</V2ErrorBoundary>
);
};
WithErrorBoundary.displayName = `WithV2ErrorBoundary(${componentType})`;
return WithErrorBoundary;
}
// ============================================================================
// 훅 기반 에러 리포팅 (ErrorBoundary 외부에서 에러 보고용)
// ============================================================================
/**
* V2 에러 리포팅 훅
*
* ErrorBoundary가 잡지 못하는 비동기 에러 등을 보고할 때 사용
*
* @example
* ```typescript
* const reportError = useV2ErrorReporter("my-component", "MyComponent");
*
* const handleClick = async () => {
* try {
* await someAsyncOperation();
* } catch (error) {
* reportError(error as Error);
* }
* };
* ```
*/
export function useV2ErrorReporter(
componentId: string,
componentType: string
): (error: Error, recoverable?: boolean) => void {
return React.useCallback(
(error: Error, recoverable = true) => {
console.error(
`[V2ErrorReporter] ${componentType}(${componentId}):`,
error
);
v2EventBus.emitSync(V2_EVENTS.COMPONENT_ERROR, {
componentId,
componentType,
error,
recoverable,
});
},
[componentId, componentType]
);
}