You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
138 lines
4.8 KiB
138 lines
4.8 KiB
import { Button } from '@/components/ui/button' |
|
import { MessageCircle, RotateCw } from 'lucide-react' |
|
import React, { Component, ReactNode } from 'react' |
|
import { toast } from 'sonner' |
|
import logger from '@/lib/logger' |
|
import { isChunkLoadFailureMessage, tryStaleChunkReloadOnce } from '@/lib/stale-chunk-recovery' |
|
|
|
const ISSUES_URL = |
|
'https://gitrepublic.imwald.eu/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/imwald?tab=issues' |
|
|
|
/** HMR can remount children before parents; context hooks throw. One recovery reload fixes it. */ |
|
const CONTEXT_RECOVERY_RELOAD_KEY = 'jumble-context-recovery-reload-at' |
|
const CONTEXT_RECOVERY_COOLDOWN_MS = 20_000 |
|
|
|
function isLikelyBrokenReactContextFromHmr(message: string): boolean { |
|
return ( |
|
/must be used within (a )?[\w]+/i.test(message) || |
|
message.includes('useNostr must be used within') || |
|
message.includes('useInterestList must be used within') || |
|
(message.includes('useContext') && message.includes('null')) |
|
) |
|
} |
|
|
|
/** Avoid double `reload()` when React StrictMode runs render twice before navigation. */ |
|
let contextRecoveryReloadScheduled = false |
|
|
|
function tryContextRecoveryReload(): boolean { |
|
if (typeof window === 'undefined') return false |
|
if (contextRecoveryReloadScheduled) return true |
|
try { |
|
const last = Number(sessionStorage.getItem(CONTEXT_RECOVERY_RELOAD_KEY) || '0') |
|
const now = Date.now() |
|
if (now - last <= CONTEXT_RECOVERY_COOLDOWN_MS) return false |
|
sessionStorage.setItem(CONTEXT_RECOVERY_RELOAD_KEY, String(now)) |
|
contextRecoveryReloadScheduled = true |
|
window.location.reload() |
|
return true |
|
} catch { |
|
return false |
|
} |
|
} |
|
|
|
interface ErrorBoundaryProps { |
|
children: ReactNode |
|
} |
|
|
|
interface ErrorBoundaryState { |
|
hasError: boolean |
|
error?: Error |
|
} |
|
|
|
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> { |
|
constructor(props: ErrorBoundaryProps) { |
|
super(props) |
|
this.state = { hasError: false } |
|
} |
|
|
|
static getDerivedStateFromError(error: Error) { |
|
return { hasError: true, error } |
|
} |
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { |
|
logger.error('ErrorBoundary caught an error', { error, errorInfo }) |
|
// Recovery reload runs in render() so navigation starts before the error UI paints. |
|
} |
|
|
|
render() { |
|
if (this.state.hasError) { |
|
const msg = this.state.error?.message ?? '' |
|
if (isChunkLoadFailureMessage(msg) && tryStaleChunkReloadOnce()) { |
|
return ( |
|
<div className="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground"> |
|
Reloading to pick up the latest app version… |
|
</div> |
|
) |
|
} |
|
if (isLikelyBrokenReactContextFromHmr(msg) && tryContextRecoveryReload()) { |
|
return ( |
|
<div className="flex h-screen w-screen items-center justify-center p-4 text-muted-foreground"> |
|
Reloading after a dev hot-reload glitch… |
|
</div> |
|
) |
|
} |
|
return ( |
|
<div className="w-screen h-screen flex flex-col items-center justify-center p-4 gap-4"> |
|
<h1 className="text-2xl font-bold">Oops, something went wrong.</h1> |
|
<p className="text-lg text-center max-w-md"> |
|
Sorry for the inconvenience. You can help by logging an issue with the error details. |
|
</p> |
|
{this.state.error?.message && ( |
|
<> |
|
<div className="flex gap-2"> |
|
<Button asChild className="bg-primary text-primary-foreground"> |
|
<a |
|
href={ISSUES_URL} |
|
target="_blank" |
|
rel="noopener noreferrer" |
|
className="inline-flex items-center" |
|
> |
|
<MessageCircle className="w-4 h-4 mr-2" /> |
|
Report issue |
|
</a> |
|
</Button> |
|
<Button |
|
onClick={() => { |
|
navigator.clipboard.writeText(this.state.error!.message) |
|
toast.success('Error message copied to clipboard') |
|
}} |
|
variant="secondary" |
|
> |
|
Copy Error Message |
|
</Button> |
|
</div> |
|
<pre className="bg-destructive/10 text-destructive p-2 rounded text-wrap break-words whitespace-pre-wrap"> |
|
Error: {this.state.error.message} |
|
</pre> |
|
</> |
|
)} |
|
<Button |
|
onClick={() => { |
|
try { |
|
sessionStorage.removeItem(CONTEXT_RECOVERY_RELOAD_KEY) |
|
} catch { |
|
/* ignore */ |
|
} |
|
window.location.reload() |
|
}} |
|
className="mt-2" |
|
> |
|
<RotateCw className="w-4 h-4 mr-2" /> |
|
Reload Page |
|
</Button> |
|
</div> |
|
) |
|
} |
|
return this.props.children |
|
} |
|
}
|
|
|