fix: offer recovery for blank desktop windows

React render failures now show a localized reload page, and renderer crashes offer native reload confirmation. Record renderer and load failures in existing diagnostics without automatic retry loops. Reload may lose unsaved input; the intermittent failure cause remains unconfirmed.

Release note: Recover from desktop rendering failures using a visible Reload action instead of being left without a recovery entry point.
This commit is contained in:
mesalogo
2026-09-15 08:07:08 +08:00
parent d4f1668c9b
commit b755951ecd
10 changed files with 339 additions and 14 deletions
+22
View File
@@ -0,0 +1,22 @@
.app-error-recovery {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: 100%;
height: 100vh;
overflow: auto;
padding: var(--space-6);
background: var(--surface-raised);
color: var(--text-primary);
-webkit-app-region: no-drag;
}
.app-error-recovery h1 {
font-size: var(--font-page-title);
}
.app-error-recovery p {
color: var(--text-secondary);
margin-block: var(--space-4);
}
@@ -0,0 +1,46 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { AppErrorBoundary } from './AppErrorBoundary'
function Broken(): never {
throw new Error('test render failure')
}
afterEach(() => {
cleanup()
localStorage.removeItem('goodbuddy.ui-locale')
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('AppErrorBoundary', () => {
it('leaves healthy children visible', () => {
render(<AppErrorBoundary><p>Working application</p></AppErrorBoundary>)
expect(screen.getByText('Working application')).toBeVisible()
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
})
it.each([
['zh-CN', '重新加载'],
['en-US', 'Reload']
])('recovers a failed tree without providers in %s and reloads only on click', (locale, label) => {
localStorage.setItem('goodbuddy.ui-locale', locale)
const log = vi.spyOn(console, 'error').mockImplementation(() => undefined)
const reload = vi.fn()
vi.stubGlobal('window', { location: { reload } })
render(<AppErrorBoundary><Broken /></AppErrorBoundary>)
expect(screen.getByRole('alert')).toBeVisible()
expect(screen.getByRole('main')).toHaveAttribute('lang', locale)
expect(log).toHaveBeenCalledWith('GoodBuddy UI failed', expect.any(Error), expect.stringContaining('Broken'))
expect(reload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: label }))
expect(reload).toHaveBeenCalledOnce()
})
it('still displays recovery when locale storage is unavailable', () => {
vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('storage unavailable') })
vi.spyOn(console, 'error').mockImplementation(() => undefined)
render(<AppErrorBoundary><Broken /></AppErrorBoundary>)
expect(screen.getByRole('button', { name: '重新加载' })).toBeVisible()
})
})
+39
View File
@@ -0,0 +1,39 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
import { loadUiLocalePreference, resolveUiLocale, systemUiLanguages } from './i18n/locale'
import './AppErrorBoundary.css'
export class AppErrorBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false }
static getDerivedStateFromError(): { failed: boolean } {
return { failed: true }
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('GoodBuddy UI failed', error, info.componentStack)
}
render(): ReactNode {
if (!this.state.failed) return this.props.children
// Recovery must work even when the locale provider or application failed.
const locale = resolveUiLocale(loadUiLocalePreference(), systemUiLanguages())
const en = locale === 'en-US'
return (
<main className="app-error-recovery" lang={locale} aria-labelledby="app-error-title">
<div role="alert">
<h1 id="app-error-title">{en ? 'GoodBuddy could not display this page' : 'GoodBuddy 无法显示当前页面'}</h1>
<p>{en
? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.'
: '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。'}</p>
</div>
<button className="primary-button" type="button" onClick={() => window.location.reload()}>
{en ? 'Reload' : '重新加载'}
</button>
</main>
)
}
}
+10 -7
View File
@@ -4,6 +4,7 @@ import '@fontsource-variable/noto-sans-sc/wght.css'
import 'katex/dist/katex.min.css'
import './markdown-math.css'
import App from './App'
import { AppErrorBoundary } from './AppErrorBoundary'
import { StorageUpgrade } from './StorageUpgrade'
import { installBundledUiFonts } from './fonts'
import { changeUiLocale } from './i18n'
@@ -51,12 +52,14 @@ installDocumentOcrBridge()
createRoot(root).render(
<StrictMode>
<UiLocaleProvider
initialPreference={initialUiLocalePreference}
>
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
? <StorageUpgrade />
: <App />}
</UiLocaleProvider>
<AppErrorBoundary>
<UiLocaleProvider
initialPreference={initialUiLocalePreference}
>
{new URLSearchParams(window.location.search).get('storageUpgrade') === '1'
? <StorageUpgrade />
: <App />}
</UiLocaleProvider>
</AppErrorBoundary>
</StrictMode>
)