From 014d57c53cdec2a52c4d23a8e9e9b8be3113dd89 Mon Sep 17 00:00:00 2001
From: Psychotoxical <171614930+Psychotoxical@users.noreply.github.com>
Date: Fri, 26 Jun 2026 20:51:42 +0200
Subject: [PATCH] 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)
---
CHANGELOG.md | 6 +++
src/app/MainApp.tsx | 9 ++--
src/components/ErrorBoundary.tsx | 60 ++++++++++++++++++++++++
src/styles/components/error-boundary.css | 57 ++++++++++++++++++++++
src/styles/components/index.css | 1 +
5 files changed, 130 insertions(+), 3 deletions(-)
create mode 100644 src/components/ErrorBoundary.tsx
create mode 100644 src/styles/components/error-boundary.css
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00854b22..74d2a7d1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
+### 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
## Fixed
diff --git a/src/app/MainApp.tsx b/src/app/MainApp.tsx
index fce705f9..da6d6895 100644
--- a/src/app/MainApp.tsx
+++ b/src/app/MainApp.tsx
@@ -32,6 +32,7 @@ import { useMigrationOrchestrator } from '../hooks/useMigrationOrchestrator';
import { IS_WINDOWS } from '../utils/platform';
import TauriEventBridge from './TauriEventBridge';
import AppShell from './AppShell';
+import ErrorBoundary from '../components/ErrorBoundary';
import BlockingMigrationGate from './BlockingMigrationGate';
import RequireAuth from './RequireAuth';
import { useMigrationStore } from '../store/migrationStore';
@@ -191,9 +192,11 @@ export default function MainApp() {
path="/*"
element={
-
-
-
+
+
+
+
+
}
/>
diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx
new file mode 100644
index 00000000..cfcda29d
--- /dev/null
+++ b/src/components/ErrorBoundary.tsx
@@ -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 {
+ 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 (
+
+
+
Something went wrong
+
+ This view hit an unexpected error and stopped rendering. Playback keeps going — try the
+ view again, or reload the app.
+