Show a crash outside a page instead of a white screen

Moving between pages sometimes left a white screen until a reload: an
error outside the page's own error boundary (the layout, the sign-in
gate) unmounted the whole app. An error boundary now wraps the whole
app, shows the error's name and message, and tries again when the route
changes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iqbal
2026-10-30 10:20:22 +07:00
parent 977a8fafc4
commit 4e1bee4503
3 changed files with 20 additions and 6 deletions

View File

@@ -3,10 +3,15 @@ import { t } from '../i18n'
import { Empty } from './Page' import { Empty } from './Page'
// Without this, one component throwing while rendering unmounts the whole app and leaves a white // Without this, one component throwing while rendering unmounts the whole app and leaves a white
// page. The layout keys it by route, so moving to another page clears the error. // page. The layout keys it by route, so moving to another page clears the error; main.tsx also puts
export class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> { // one around the whole app (the layout included), cleared by a change of `resetKey`.
export class ErrorBoundary extends Component<{ children: ReactNode; resetKey?: string; className?: string }, { error: Error | null }> {
state = { error: null as Error | null } state = { error: null as Error | null }
componentDidUpdate(prev: { resetKey?: string }) {
if (this.state.error && prev.resetKey !== this.props.resetKey) this.setState({ error: null })
}
static getDerivedStateFromError(error: Error) { static getDerivedStateFromError(error: Error) {
return { error } return { error }
} }
@@ -19,11 +24,11 @@ export class ErrorBoundary extends Component<{ children: ReactNode }, { error: E
const { error } = this.state const { error } = this.state
if (!error) return this.props.children if (!error) return this.props.children
return ( return (
<div className="card"> <div className={this.props.className ?? 'card'}>
<Empty icon="info" title={t('err.crashTitle')}> <Empty icon="info" title={t('err.crashTitle')}>
{t('err.crashCopy')} {t('err.crashCopy')}
<br /> <br />
<small>{error.message}</small> <small>{error.name}: {error.message}</small>
</Empty> </Empty>
<div style={{ display: 'flex', justifyContent: 'center', gap: 8, paddingBottom: 24 }}> <div style={{ display: 'flex', justifyContent: 'center', gap: 8, paddingBottom: 24 }}>
<button className="btn small" onClick={() => this.setState({ error: null })}>{t('common.retry')}</button> <button className="btn small" onClick={() => this.setState({ error: null })}>{t('common.retry')}</button>

View File

@@ -3,9 +3,10 @@ import { createRoot } from 'react-dom/client'
import { ClerkProvider } from '@clerk/react' import { ClerkProvider } from '@clerk/react'
import { enUS, idID } from '@clerk/localizations' import { enUS, idID } from '@clerk/localizations'
import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query' import { MutationCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter } from 'react-router-dom' import { BrowserRouter, useLocation } from 'react-router-dom'
import { EngineError } from './api/client' import { EngineError } from './api/client'
import App from './App' import App from './App'
import { ErrorBoundary } from './components/ErrorBoundary'
import { forgetReads } from './express/results' import { forgetReads } from './express/results'
import { useLang } from './i18n' import { useLang } from './i18n'
import './styles/mockup.css' import './styles/mockup.css'
@@ -40,6 +41,13 @@ const clerkAppearance = {
variables: { colorPrimary: '#1570ef', colorText: '#101828', fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", borderRadius: '12px' }, variables: { colorPrimary: '#1570ef', colorText: '#101828', fontFamily: "'Plus Jakarta Sans', system-ui, sans-serif", borderRadius: '12px' },
} }
// A crash outside a page (the layout, the sign-in gate) would otherwise unmount everything and leave
// a white page until a reload; moving to another page tries again.
function AppBoundary() {
const { pathname } = useLocation()
return <ErrorBoundary resetKey={pathname} className="card app-crash"><App /></ErrorBoundary>
}
// Clerk's own sign-in and organization screens follow the app language. // Clerk's own sign-in and organization screens follow the app language.
function Root() { function Root() {
const lang = useLang() const lang = useLang()
@@ -47,7 +55,7 @@ function Root() {
<ClerkProvider publishableKey={clerkPubKey} afterSignOutUrl="/" localization={lang === 'id' ? idID : enUS} appearance={clerkAppearance}> <ClerkProvider publishableKey={clerkPubKey} afterSignOutUrl="/" localization={lang === 'id' ? idID : enUS} appearance={clerkAppearance}>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<App /> <AppBoundary />
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</ClerkProvider> </ClerkProvider>

View File

@@ -209,3 +209,4 @@ textarea{resize:vertical;line-height:1.6}
.compact-source-row>input:disabled{opacity:.4;cursor:not-allowed} .compact-source-row>input:disabled{opacity:.4;cursor:not-allowed}
.compact-brain .brain-selection label{font-size:13px} .compact-brain .brain-selection label{font-size:13px}
.compact-brain .brain-selection{font-size:12px} .compact-brain .brain-selection{font-size:12px}
.app-crash{max-width:560px;margin:12vh auto;padding-top:8px}