import { Component, type ReactNode } from 'react'
import i18n from '@/i18n'
interface Props {
children: ReactNode
fallback?: ReactNode
}
interface State {
hasError: boolean
error?: Error
retryCount: number
}
const MAX_RETRIES = 2
/**
* Detects chunk / dynamic-import load failures (stale deploy, network
* blip, Vite HMR transient error).
*
* Each browser reports these differently:
* - Safari: "Importing a module script failed"
* - Chrome: "Failed to fetch dynamically imported module"
* - Firefox: "error resolving module specifier"
* - Vite lazy: "Loading chunk <id> failed" / "Loading CSS chunk <id> failed"
*
* IMPORTANT: we deliberately do NOT match on `error.name === 'TypeError'`.
* App bugs like `undefined.map(...)` are also `TypeError`s, but they are
* NOT chunk-load failures. Matching on the name hid every real render
* error behind the misleading "웹 UI 파일을 업데이트 중" message and
* triggered an infinite auto-reload loop into the same crash.
*/
function isModuleImportError(error: Error): boolean {
const msg = error.message ?? ''
const name = error.name ?? ''
return (
name === 'ChunkLoadError' ||
msg.includes('Importing a module script failed') ||
msg.includes('Failed to fetch dynamically imported module') ||
msg.includes('error resolving module specifier') ||
msg.includes('Unable to resolve specifier') ||
// Vite's lazy-chunk loader: "Loading chunk <hash> failed." / "...CSS chunk..."
/Loading (?:CSS )?chunk [\w-]+ failed/i.test(msg)
)
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, retryCount: 0 }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error, retryCount: 0 }
}
private handleRetry = () => {
const { error, retryCount } = this.state
// For module-import errors, auto-reload after retries exhausted
if (error && isModuleImportError(error) && retryCount >= MAX_RETRIES) {
window.location.reload()
return
}
this.setState((prev) => ({ hasError: false, retryCount: prev.retryCount + 1 }))
}
private handleReload = () => {
window.location.reload()
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback
const { error, retryCount } = this.state
const isModuleError = error ? isModuleImportError(error) : false
const isChunkError =
isModuleError ||
(error?.message?.includes('chunk') ?? false) ||
(error?.message?.includes('Lazy') ?? false)
return (
<div className="flex items-center justify-center p-8">
<div className="text-center max-w-md">
<h2 className="text-lg font-semibold text-destructive">
{isChunkError
? i18n.t('errorBoundary.chunkError', '페이지를 불러오지 못했습니다')
: i18n.t('errorBoundary.genericError', '문제가 발생했습니다')}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{isChunkError
? i18n.t(
'errorBoundary.chunkErrorDesc',
'웹 UI 파일을 최신 상태로 업데이트 중입니다. 잠시 후 다시 시도해주세요.',
)
: (error?.message ?? i18n.t('errorBoundary.unknownError', '알 수 없는 오류'))}
</p>
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors"
onClick={this.handleRetry}
>
{retryCount >= MAX_RETRIES
? i18n.t('common.refresh', '새로고침')
: i18n.t('common.retry', '다시 시도')}
</button>
{!isChunkError && (
<button
type="button"
className="rounded-md border px-4 py-2 text-sm text-foreground hover:bg-accent transition-colors"
onClick={this.handleReload}
>
{i18n.t('common.refresh', '새로고침')}
</button>
)}
</div>
{import.meta.env.DEV && error && (
<details className="mt-4 text-left">
<summary className="text-xs text-muted-foreground cursor-pointer">
{i18n.t('errorBoundary.errorDetails', '오류 상세 (개발 모드)')}
</summary>
<pre className="mt-2 overflow-auto rounded-md bg-muted p-3 text-xs">
{error.stack ?? error.message}
</pre>
</details>
)}
</div>
</div>
)
}
return this.props.children
}
}