feat(app): top-level error boundary so a render error no longer blanks the app (#1194)

* feat(app): add a top-level error boundary so a render error no longer blanks the app

Until now any thrown error during render took the whole window down with no recovery (issue #382). A class ErrorBoundary around the authenticated shell catches it and shows a recoverable fallback (Try again / Reload app) while playback keeps going. Hook-free + English-only with literal CSS var() fallbacks so it renders even when i18n/theme/state is what broke.

* docs(changelog): error boundary prevents the app blanking on a render error (PR #1194)
This commit is contained in:
Psychotoxical
2026-06-26 20:51:42 +02:00
committed by GitHub
parent afe5b377e0
commit 014d57c53c
5 changed files with 130 additions and 3 deletions
+6
View File
@@ -337,6 +337,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
* The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border. * The AutoDJ overlap-cap and the Native Hi-Res blend-rate options in Settings → Audio now sit in the same bordered sub-card the Normalization options use, and the Hi-Res section no longer shows a double border.
### App no longer blanks on an unexpected error
**By [@Psychotoxical](https://github.com/Psychotoxical), PR [#1194](https://github.com/Psychotoxical/psysonic/pull/1194)**
* If a screen hit an unexpected rendering error, the whole window could go blank with no way back. The app now shows a small recoverable error card (Try again / Reload app) instead, and playback keeps going.
## [1.48.1] - 2026-06-15 ## [1.48.1] - 2026-06-15
## Fixed ## Fixed
+3
View File
@@ -32,6 +32,7 @@ import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
import { IS_WINDOWS } from '../utils/platform'; import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge'; import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell'; import AppShell from './AppShell';
import ErrorBoundary from '../components/ErrorBoundary';
import BlockingMigrationGate from './BlockingMigrationGate'; import BlockingMigrationGate from './BlockingMigrationGate';
import RequireAuth from './RequireAuth'; import RequireAuth from './RequireAuth';
import { useMigrationStore } from '../store/migrationStore'; import { useMigrationStore } from '../store/migrationStore';
@@ -191,9 +192,11 @@ export default function MainApp() {
path="/*" path="/*"
element={ element={
<RequireAuth> <RequireAuth>
<ErrorBoundary>
<DragDropProvider> <DragDropProvider>
<AppShell /> <AppShell />
</DragDropProvider> </DragDropProvider>
</ErrorBoundary>
</RequireAuth> </RequireAuth>
} }
/> />
+60
View File
@@ -0,0 +1,60 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
error: Error | null;
}
/**
* App-level error boundary. A render-time throw used to blank the entire window
* — no boundary existed (issue #382), so any thrown error during render took the
* whole UI down. This catches it and shows a recoverable fallback while playback
* (driven by the Rust audio engine, outside React) keeps going.
*
* Deliberately hook-free and English-only: the fallback has to render even when
* i18n, the theme, or app state is exactly what broke, so it must not depend on
* any of them. The CSS uses literal fallbacks in `var(...)` for the same reason.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[error-boundary]', error, info.componentStack);
}
private handleRetry = (): void => this.setState({ error: null });
private handleReload = (): void => window.location.reload();
render(): ReactNode {
const { error } = this.state;
if (!error) return this.props.children;
return (
<div className="app-error-boundary" role="alert">
<div className="app-error-boundary__card">
<h1 className="app-error-boundary__title">Something went wrong</h1>
<p className="app-error-boundary__text">
This view hit an unexpected error and stopped rendering. Playback keeps going try the
view again, or reload the app.
</p>
<pre className="app-error-boundary__detail">{error.message}</pre>
<div className="app-error-boundary__actions">
<button type="button" className="btn btn-primary" onClick={this.handleRetry}>
Try again
</button>
<button type="button" className="btn btn-surface" onClick={this.handleReload}>
Reload app
</button>
</div>
</div>
</div>
);
}
}
+57
View File
@@ -0,0 +1,57 @@
/* App-level error boundary fallback (components/ErrorBoundary.tsx). Literal
var() fallbacks so it renders even when the active theme is what broke. */
.app-error-boundary {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 60vh;
padding: var(--space-6, 24px);
background: var(--bg-app, #1e1e2e);
color: var(--text-primary, #cdd6f4);
}
.app-error-boundary__card {
width: 100%;
max-width: 520px;
padding: var(--space-6, 24px);
background: var(--bg-card, #313244);
border: 1px solid var(--border, #45475a);
border-radius: var(--radius-lg, 12px);
box-shadow: var(--shadow-md, 0 8px 24px rgba(0, 0, 0, 0.3));
text-align: center;
}
.app-error-boundary__title {
margin: 0 0 var(--space-2, 8px);
font-size: 20px;
font-weight: 700;
}
.app-error-boundary__text {
margin: 0 0 var(--space-4, 16px);
color: var(--text-secondary, #bac2de);
font-size: 14px;
line-height: 1.5;
}
.app-error-boundary__detail {
margin: 0 0 var(--space-4, 16px);
padding: var(--space-3, 12px);
max-height: 160px;
overflow: auto;
background: var(--bg-deep, #181825);
border-radius: var(--radius-md, 8px);
color: var(--text-muted, #a6adc8);
font-size: 12px;
text-align: left;
white-space: pre-wrap;
word-break: break-word;
}
.app-error-boundary__actions {
display: flex;
gap: var(--space-3, 12px);
justify-content: center;
}
+1
View File
@@ -94,3 +94,4 @@
@import './np-dash.css'; @import './np-dash.css';
@import './orbit-session-top-strip.css'; @import './orbit-session-top-strip.css';
@import './back-to-top.css'; @import './back-to-top.css';
@import './error-boundary.css';