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,54 @@
|
||||
# Desktop Window Recovery
|
||||
|
||||
This is recovery hardening for intermittent blank windows, not an established
|
||||
diagnosis of their cause.
|
||||
|
||||
- `src/renderer/src/main.tsx` wraps the locale provider, App and storage-upgrade
|
||||
page in `AppErrorBoundary`. A React render/lifecycle failure replaces the tree
|
||||
with a blocking recovery page and an explicit Reload button. It uses the saved
|
||||
UI language (including system preference resolution), shared theme tokens and
|
||||
primary-button styling, without depending on the failed application providers.
|
||||
- Reload refreshes the current document, retaining its URL and storage-upgrade
|
||||
query. It can lose unsaved input. There are no automatic React retries.
|
||||
- `src/main/window.ts` listens for `render-process-gone`. Abnormal exits open one
|
||||
native dialog at a time, using the OS locale because renderer language storage
|
||||
is unavailable to Main. Reload occurs only on explicit confirmation. Cancel
|
||||
leaves the window unchanged; persistent failures require quitting and reopening
|
||||
GoodBuddy. Clean exits, shutdown and destroyed windows do not trigger recovery.
|
||||
- Main-frame load failures are logged; aborted navigation and subframe failures
|
||||
are ignored. Initial load promises are caught, without automatic load retries
|
||||
or a new load-failure UI.
|
||||
- Native crashes and main-frame load failures use the existing bounded desktop
|
||||
diagnostics store (`desktop.renderer.gone`, `desktop.renderer.load-failed`,
|
||||
stage `renderer`). Persistent records retain fixed summaries rather than raw
|
||||
errors. Crash reason/exit code and React errors/component stacks go to their
|
||||
respective process consoles. There is no new diagnostic IPC.
|
||||
|
||||
The boundary does not catch module evaluation/startup failures before React
|
||||
mounts, event-handler exceptions, asynchronous rejections, a hung renderer, or
|
||||
main-process failure. Reload does not establish that a repeated underlying error
|
||||
has been fixed. Agent runtimes and remote execution are unchanged.
|
||||
|
||||
Targeted regressions are in `AppErrorBoundary.test.tsx`, `window.test.ts` and
|
||||
`desktop-diagnostics.test.ts`. Electron API mocks verify decision paths but do
|
||||
not establish native dialog appearance or full application recovery.
|
||||
|
||||
Validation on Windows, 2026-09-15:
|
||||
|
||||
- Targeted Vitest run: 20 tests passed across the three files above.
|
||||
- `npm test`: 359 files passed, 9 skipped; 4,225 tests passed, 67 skipped.
|
||||
- `npm run typecheck`, `npm run lint` and `git diff --check` passed.
|
||||
- An isolated Electron fixture bundled the current boundary, shared styles and
|
||||
production window module. Both languages and themes at requested content sizes
|
||||
1280x800, 960x720, 720x640 and 640x420 passed visibility, focus and horizontal
|
||||
overflow checks. Windows scaling rounded actual widths down by one CSS pixel.
|
||||
The 16-image overview and Chinese/light and English/dark short-window originals
|
||||
were visually reviewed; no clipping or focus-style defect was found.
|
||||
- A native Space key activated the recovery button and reloaded the document.
|
||||
Two actual renderer crashes verified confirmed reload with URL retention and
|
||||
cancellation without retry. Only native dialog responses were mocked; native
|
||||
dialog appearance, full App business-state recovery and other OSes were not
|
||||
verified. No model calls were made by this fixture.
|
||||
- The first temporary Electron driver timed out during ESM startup. Removing its
|
||||
top-level readiness await fixed the driver; the subsequent run passed. This
|
||||
was a test-driver failure, not evidence of the reported application root cause.
|
||||
@@ -34,6 +34,22 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
describe('DesktopDiagnostics', () => {
|
||||
it('persists renderer failure codes without storing raw error content', async () => {
|
||||
const diagnostics = new DesktopDiagnostics(await temporaryDirectory())
|
||||
for (const code of ['desktop.renderer.gone', 'desktop.renderer.load-failed']) {
|
||||
await diagnostics.recordFailure({
|
||||
component: 'desktop', stage: 'renderer', code,
|
||||
error: new Error('private renderer details')
|
||||
})
|
||||
}
|
||||
const records = await diagnostics.readRecent()
|
||||
expect(records.map(record => record.code)).toEqual([
|
||||
'desktop.renderer.gone', 'desktop.renderer.load-failed'
|
||||
])
|
||||
expect(records.every(record => record.message === 'Desktop renderer failed')).toBe(true)
|
||||
expect(JSON.stringify(records)).not.toContain('private renderer details')
|
||||
await diagnostics.dispose()
|
||||
})
|
||||
it('rotates within fixed file and byte bounds', async () => {
|
||||
const directory = await temporaryDirectory()
|
||||
let sequence = 0
|
||||
|
||||
@@ -14,6 +14,7 @@ const DEFAULT_MAXIMUM_RECORDS = 1_000
|
||||
export const MAXIMUM_PENDING_DESKTOP_DIAGNOSTIC_WRITES = 32
|
||||
const DIAGNOSTIC_FILE_NAME = 'desktop-diagnostics.ndjson'
|
||||
const allowedStages = new Set([
|
||||
'renderer',
|
||||
'startup',
|
||||
'create',
|
||||
'status',
|
||||
@@ -25,6 +26,8 @@ const allowedStages = new Set([
|
||||
'disconnect'
|
||||
])
|
||||
const allowedCodes = new Set([
|
||||
'desktop.renderer.gone',
|
||||
'desktop.renderer.load-failed',
|
||||
'desktop.startup.failed',
|
||||
'runtime.operation.failed',
|
||||
'runtime.run.failed',
|
||||
@@ -108,6 +111,7 @@ function fixedMessage(
|
||||
stage: string
|
||||
): string {
|
||||
if (component === 'desktop') {
|
||||
if (stage === 'renderer') return 'Desktop renderer failed'
|
||||
return 'Desktop startup failed'
|
||||
}
|
||||
if (component === 'remote-agent') {
|
||||
|
||||
+1
-1
@@ -547,7 +547,7 @@ if (hasSingleInstanceLock) {
|
||||
details.mediaType === 'audio'
|
||||
)
|
||||
|
||||
mainWindow = createMainWindow(() => isQuitting)
|
||||
mainWindow = createMainWindow(() => isQuitting, observeDesktopFailure)
|
||||
registerDesktopNotificationActivation(mainWindow)
|
||||
tray = buildTray()
|
||||
storageUpgrade = prepareAssistantStorage(
|
||||
|
||||
+90
-2
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createMainWindow, resolveWindowIcon } from './window'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createMainWindow, loadMainWindow, resolveWindowIcon } from './window'
|
||||
import { dialog } from 'electron'
|
||||
|
||||
const electronMocks = vi.hoisted(() => ({
|
||||
options: [] as Array<Record<string, unknown>>,
|
||||
@@ -9,11 +10,14 @@ const electronMocks = vi.hoisted(() => ({
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getLocale: vi.fn(() => 'en-US'),
|
||||
getAppPath: vi.fn(() => 'C:\\source')
|
||||
},
|
||||
BrowserWindow: class {
|
||||
webContents = {
|
||||
on: vi.fn(),
|
||||
reload: vi.fn(),
|
||||
isDestroyed: vi.fn(() => false),
|
||||
setWindowOpenHandler: vi.fn()
|
||||
}
|
||||
|
||||
@@ -22,6 +26,9 @@ vi.mock('electron', () => ({
|
||||
}
|
||||
|
||||
setIcon = vi.fn()
|
||||
isDestroyed = vi.fn(() => false)
|
||||
loadURL = vi.fn(async () => undefined)
|
||||
loadFile = vi.fn(async () => undefined)
|
||||
once = vi.fn()
|
||||
hide = vi.fn()
|
||||
on = vi.fn(
|
||||
@@ -40,11 +47,92 @@ vi.mock('electron', () => ({
|
||||
isEmpty: vi.fn(() => false)
|
||||
}))
|
||||
},
|
||||
dialog: { showMessageBox: vi.fn(async () => ({ response: 1 })) },
|
||||
shell: {
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
describe('window recovery', () => {
|
||||
function setup(shouldQuit = () => false) {
|
||||
const observe = vi.fn()
|
||||
const observedWindow = createMainWindow(shouldQuit, observe)
|
||||
const listeners = vi.mocked(observedWindow.webContents.on).mock.calls as unknown as Array<[string, unknown]>
|
||||
const gone = listeners.find(([event]) => event === 'render-process-gone')![1] as
|
||||
(event: unknown, details: { reason: string; exitCode: number }) => void
|
||||
vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
return { window: observedWindow, gone, observe, listeners }
|
||||
}
|
||||
|
||||
it('logs a crash and reloads only after explicit confirmation, without duplicate dialogs', async () => {
|
||||
let respond!: (value: { response: number; checkboxChecked: boolean }) => void
|
||||
vi.mocked(dialog.showMessageBox).mockReturnValueOnce(new Promise(resolve => { respond = resolve }))
|
||||
const { window, gone, observe } = setup()
|
||||
const before = vi.mocked(dialog.showMessageBox).mock.calls.length
|
||||
gone({}, { reason: 'oom', exitCode: 9 })
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
expect(dialog.showMessageBox).toHaveBeenCalledTimes(before + 1)
|
||||
expect(window.webContents.reload).not.toHaveBeenCalled()
|
||||
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.gone' }))
|
||||
respond({ response: 0, checkboxChecked: false })
|
||||
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
await Promise.resolve()
|
||||
expect(window.webContents.reload).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('does not recover clean exits or shutdown, or reload a window destroyed during the dialog', async () => {
|
||||
let quitting = false
|
||||
const { window, gone, observe } = setup(() => quitting)
|
||||
gone({}, { reason: 'clean-exit', exitCode: 0 })
|
||||
quitting = true
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
quitting = false
|
||||
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
vi.mocked(window.isDestroyed).mockReturnValue(true)
|
||||
await Promise.resolve()
|
||||
expect(window.webContents.reload).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles dialog rejection and allows a later user-driven recovery', async () => {
|
||||
const { window, gone } = setup()
|
||||
vi.mocked(dialog.showMessageBox).mockRejectedValueOnce(new Error('dialog unavailable'))
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy renderer recovery failed', expect.any(Error)))
|
||||
vi.mocked(dialog.showMessageBox).mockResolvedValueOnce({ response: 0, checkboxChecked: false })
|
||||
gone({}, { reason: 'crashed', exitCode: 1 })
|
||||
await vi.waitFor(() => expect(window.webContents.reload).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('records main-frame load failures but ignores aborted navigation and subframes', () => {
|
||||
const { listeners, observe } = setup()
|
||||
const failed = listeners.find(([event]) => event === 'did-fail-load')![1] as
|
||||
(event: unknown, code: number, description: string, url: string, main: boolean) => void
|
||||
failed({}, -3, 'aborted', '', true)
|
||||
failed({}, -6, 'missing', '', false)
|
||||
expect(observe).not.toHaveBeenCalled()
|
||||
failed({}, -6, 'missing', '', true)
|
||||
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ code: 'desktop.renderer.load-failed' }))
|
||||
})
|
||||
|
||||
it.each([true, false])('consumes rejected load promises (development=%s)', async development => {
|
||||
vi.stubEnv('ELECTRON_RENDERER_URL', development ? 'http://localhost:5173' : '')
|
||||
const { window } = setup()
|
||||
vi.mocked(development ? window.loadURL : window.loadFile).mockRejectedValueOnce(new Error('load failed'))
|
||||
loadMainWindow(window, true)
|
||||
await vi.waitFor(() => expect(console.error).toHaveBeenCalledWith('GoodBuddy window load rejected', expect.any(Error)))
|
||||
if (development) expect(window.loadURL).toHaveBeenCalledWith('http://localhost:5173/?storageUpgrade=1')
|
||||
else expect(window.loadFile).toHaveBeenCalledWith(expect.any(String), { query: { storageUpgrade: '1' } })
|
||||
})
|
||||
})
|
||||
|
||||
describe('createMainWindow', () => {
|
||||
it('disables the system frame while preserving renderer isolation', () => {
|
||||
createMainWindow(() => false)
|
||||
|
||||
+57
-4
@@ -1,6 +1,7 @@
|
||||
import { app, BrowserWindow, nativeImage, shell } from 'electron'
|
||||
import { app, BrowserWindow, dialog, nativeImage, shell } from 'electron'
|
||||
import { dirname, join, posix, win32 } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { DesktopDiagnosticFailureObserver } from './desktop-diagnostics'
|
||||
|
||||
const currentDirectory = dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
@@ -51,7 +52,10 @@ function hasSameOrigin(url: string, allowedUrl: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
|
||||
export function createMainWindow(
|
||||
shouldQuit: () => boolean,
|
||||
observeFailure?: DesktopDiagnosticFailureObserver
|
||||
): BrowserWindow {
|
||||
const iconPath = resolveWindowIcon()
|
||||
const icon = iconPath
|
||||
? nativeImage.createFromPath(iconPath)
|
||||
@@ -102,6 +106,51 @@ export function createMainWindow(shouldQuit: () => boolean): BrowserWindow {
|
||||
}
|
||||
})
|
||||
|
||||
let recoveryPending = false
|
||||
window.webContents.on('render-process-gone', (_event, details) => {
|
||||
if (details.reason === 'clean-exit' || shouldQuit() || window.isDestroyed()) return
|
||||
console.error('GoodBuddy renderer exited', details.reason, details.exitCode)
|
||||
observeFailure?.({
|
||||
component: 'desktop',
|
||||
stage: 'renderer',
|
||||
code: 'desktop.renderer.gone',
|
||||
error: new Error(`Renderer exited: ${details.reason} (${details.exitCode})`)
|
||||
})
|
||||
if (recoveryPending) return
|
||||
recoveryPending = true
|
||||
// No automatic retries: a persistent crash requires a fresh user action.
|
||||
const en = !app.getLocale().toLowerCase().startsWith('zh')
|
||||
void dialog.showMessageBox(window, {
|
||||
type: 'error',
|
||||
title: 'GoodBuddy',
|
||||
message: en ? 'The GoodBuddy window stopped working' : 'GoodBuddy 窗口已停止工作',
|
||||
detail: en
|
||||
? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.'
|
||||
: '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。',
|
||||
buttons: en ? ['Reload', 'Cancel'] : ['重新加载', '取消'],
|
||||
defaultId: 0,
|
||||
cancelId: 1,
|
||||
noLink: true
|
||||
}).then(({ response }) => {
|
||||
if (response === 0 && !shouldQuit() && !window.isDestroyed() && !window.webContents.isDestroyed()) {
|
||||
window.webContents.reload()
|
||||
}
|
||||
}).catch((error: unknown) => {
|
||||
console.error('GoodBuddy renderer recovery failed', error)
|
||||
}).finally(() => {
|
||||
recoveryPending = false
|
||||
})
|
||||
})
|
||||
|
||||
window.webContents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => {
|
||||
if (!isMainFrame || code === -3 || shouldQuit() || window.isDestroyed()) return
|
||||
console.error('GoodBuddy window load failed', code, description)
|
||||
observeFailure?.({
|
||||
component: 'desktop', stage: 'renderer', code: 'desktop.renderer.load-failed',
|
||||
error: new Error(`Window load failed: ${code}`)
|
||||
})
|
||||
})
|
||||
|
||||
return window
|
||||
}
|
||||
|
||||
@@ -112,12 +161,16 @@ export function loadMainWindow(
|
||||
if (process.env.ELECTRON_RENDERER_URL) {
|
||||
const url = new URL(process.env.ELECTRON_RENDERER_URL)
|
||||
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
|
||||
void window.loadURL(url.href)
|
||||
void window.loadURL(url.href).catch((error: unknown) => {
|
||||
console.error('GoodBuddy window load rejected', error)
|
||||
})
|
||||
} else {
|
||||
void window.loadFile(
|
||||
join(currentDirectory, '../renderer/index.html'),
|
||||
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
|
||||
)
|
||||
).catch((error: unknown) => {
|
||||
console.error('GoodBuddy window load rejected', error)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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