Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion apps/electron/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-router": "^1.45.0",
"@xnetjs/canvas": "workspace:*",
"@xnetjs/charts": "workspace:*",
"@xnetjs/core": "workspace:*",
Expand Down
65 changes: 65 additions & 0 deletions apps/electron/src/renderer/components/ShellErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* @vitest-environment jsdom
*/

/**
* A shell render failure must degrade to a recoverable panel (exploration 0406).
*
* A browser tab that white-screens still has a reload button; a packaged
* desktop window has none, so an unmounted tree is a dead app. This is the
* guard for the failure the `MenuLabel` crash actually produced.
*/

import { render, screen } from '@testing-library/react'
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ShellErrorBoundary } from './ShellErrorBoundary'

function Boom(): React.ReactElement {
throw new Error('MenuGroupRootContext is missing')
}

describe('ShellErrorBoundary', () => {
beforeEach(() => {
// React logs the caught error; silence it so the run stays readable.
vi.spyOn(console, 'error').mockImplementation(() => {})
})

afterEach(() => {
vi.restoreAllMocks()
})

it('renders children when nothing throws', () => {
render(
<ShellErrorBoundary>
<p>shell</p>
</ShellErrorBoundary>
)
expect(screen.getByText('shell')).toBeTruthy()
})

it('shows the crash panel instead of a blank window', () => {
render(
<ShellErrorBoundary>
<Boom />
</ShellErrorBoundary>
)

expect(screen.getByText('Something broke in the shell')).toBeTruthy()
// The message is surfaced, not swallowed — a silent shell crash reads as
// "the app is fine".
expect(screen.getByText(/MenuGroupRootContext is missing/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Reload' })).toBeTruthy()
})

it('reports the failure rather than swallowing it', () => {
render(
<ShellErrorBoundary>
<Boom />
</ShellErrorBoundary>
)

const logged = vi.mocked(console.error).mock.calls.flat()
expect(logged.some((arg) => String(arg).includes('[shell] render failure'))).toBe(true)
})
})
78 changes: 78 additions & 0 deletions apps/electron/src/renderer/components/ShellErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/**
* Catches render failures anywhere in the desktop shell (exploration 0406).
*
* A browser tab that white-screens still has a URL bar and a reload button. A
* packaged desktop window has neither: an unmounted React tree is a dead app
* the user can only fix by quitting. That is what happened when `MenuLabel`
* rendered Base UI's `GroupLabel` outside a group — opening the system menu,
* the shell's only navigation affordance, blanked the whole window.
*
* So the boundary degrades to a recoverable panel and reports loudly rather
* than swallowing: a shell crash that logs nothing reads as "the app is fine".
*/

import React from 'react'

interface Props {
children: React.ReactNode
}

interface State {
error: Error | null
}

export class ShellErrorBoundary extends React.Component<Props, State> {
state: State = { error: null }

static getDerivedStateFromError(error: Error): State {
return { error }
}

componentDidCatch(error: Error, info: React.ErrorInfo): void {
console.error('[shell] render failure', error, info.componentStack)
}

private handleReload = (): void => {
window.location.reload()
}

private handleDismiss = (): void => {
this.setState({ error: null })
}

render(): React.ReactNode {
const { error } = this.state
if (!error) return this.props.children

return (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-background p-8">
<div className="w-full max-w-lg rounded-2xl border border-border bg-surface-1 p-6 shadow-2xl">
<h1 className="text-lg font-semibold text-foreground">Something broke in the shell</h1>
<p className="mt-2 text-sm text-foreground-muted">
The rest of the app kept running. Reloading restores the window; your data is
unaffected.
</p>
<pre className="mt-4 max-h-40 overflow-auto rounded-lg bg-surface-2 p-3 text-xs text-foreground-muted">
{error.message}
</pre>
<div className="mt-5 flex justify-end gap-2">
<button
type="button"
onClick={this.handleDismiss}
className="rounded-lg px-3 py-1.5 text-sm text-foreground-muted hover:text-foreground"
>
Dismiss
</button>
<button
type="button"
onClick={this.handleReload}
className="rounded-lg bg-accent px-3 py-1.5 text-sm font-medium text-accent-foreground"
>
Reload
</button>
</div>
</div>
</div>
)
}
}
Loading
Loading