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:
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user