diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx
index cd0325887..246504de8 100644
--- a/src/renderer/App.tsx
+++ b/src/renderer/App.tsx
@@ -13,6 +13,7 @@ import { GlobalEffects } from './components/GlobalEffects';
import { router } from './router';
import './App.css';
import { queryClient } from './utils/api/queryClient';
+import { rendererLogError, toError } from './utils/core/logger';
export function App() {
// Initialize store subscriptions with proper cleanup
@@ -26,7 +27,10 @@ export function App() {
-
+ rendererLogError('router', 'Router error', toError(error))}
+ router={router}
+ />
diff --git a/src/renderer/__helpers__/visual-utils.tsx b/src/renderer/__helpers__/visual-utils.tsx
index 4bc2ca3b7..5dcb625cf 100644
--- a/src/renderer/__helpers__/visual-utils.tsx
+++ b/src/renderer/__helpers__/visual-utils.tsx
@@ -30,6 +30,8 @@ export interface VisualRenderOptions {
settings?: Partial;
filters?: Partial;
notifications?: AccountNotifications[];
+ /** Set false for full-window states that replace the app shell (e.g. app error fallback). */
+ withChrome?: boolean;
}
/**
@@ -88,6 +90,7 @@ export function renderRoute(
settings,
filters,
notifications,
+ withChrome = true,
}: VisualRenderOptions,
) {
useSettingsStore.setState({ ...settings, theme, designLanguage });
@@ -137,7 +140,7 @@ export function renderRoute(
{/* Routes reading `location.state` (AccountScopes) get their
account through here, so it needs stubbing too. */}
- {children}
+ {withChrome ? {children} : children}
diff --git a/src/renderer/components/AppErrorBoundary.test.tsx b/src/renderer/components/AppErrorBoundary.test.tsx
new file mode 100644
index 000000000..227e04ce7
--- /dev/null
+++ b/src/renderer/components/AppErrorBoundary.test.tsx
@@ -0,0 +1,29 @@
+import { render, screen } from '@testing-library/react';
+import type { ReactElement } from 'react';
+
+import * as logger from '../utils/core/logger';
+import { AppErrorBoundary } from './AppErrorBoundary';
+
+function Boom(): ReactElement {
+ throw new Error('boom');
+}
+
+describe('renderer/components/AppErrorBoundary.tsx', () => {
+ it('renders the fallback and logs the error', async () => {
+ const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});
+ const logSpy = vi.spyOn(logger, 'rendererLogError').mockImplementation(() => {});
+
+ render(
+
+
+ ,
+ );
+
+ expect(await screen.findByText('Oops! Something went wrong')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Reload/ })).toBeInTheDocument();
+ expect(logSpy).toHaveBeenCalled();
+
+ logSpy.mockRestore();
+ consoleError.mockRestore();
+ });
+});
diff --git a/src/renderer/components/AppErrorBoundary.tsx b/src/renderer/components/AppErrorBoundary.tsx
new file mode 100644
index 000000000..988d41337
--- /dev/null
+++ b/src/renderer/components/AppErrorBoundary.tsx
@@ -0,0 +1,49 @@
+import { Component, type ReactNode } from 'react';
+
+import { EmojiSplash } from './layout/EmojiSplash';
+import { ReloadButton } from './primitives/ReloadButton';
+
+import { getErrorDetails } from '../utils/core/errors';
+import { rendererLogError, toError } from '../utils/core/logger';
+
+export function AppErrorFallback() {
+ const error = getErrorDetails('UNKNOWN');
+
+ return (
+ }
+ emoji={error.emojis[0]}
+ fullHeight
+ heading={error.title}
+ subHeadings={error.descriptions}
+ />
+ );
+}
+
+interface AppErrorBoundaryProps {
+ children: ReactNode;
+}
+
+interface AppErrorBoundaryState {
+ error: Error | null;
+}
+
+/**
+ * Last-resort boundary for failures outside the route tree (providers, app
+ * shell bootstrap). Route failures are handled by the router error elements.
+ */
+export class AppErrorBoundary extends Component {
+ override state: AppErrorBoundaryState = { error: null };
+
+ static getDerivedStateFromError(error: Error): AppErrorBoundaryState {
+ return { error };
+ }
+
+ override componentDidCatch(error: Error) {
+ rendererLogError('app', 'Renderer error boundary', toError(error));
+ }
+
+ override render() {
+ return this.state.error ? : this.props.children;
+ }
+}
diff --git a/src/renderer/components/ErrorStates.visual.test.tsx b/src/renderer/components/ErrorStates.visual.test.tsx
new file mode 100644
index 000000000..ec2b7b994
--- /dev/null
+++ b/src/renderer/components/ErrorStates.visual.test.tsx
@@ -0,0 +1,38 @@
+import { page } from 'vite-plus/test/browser';
+
+import { renderRoute } from '../__helpers__/visual-utils';
+import { mockGitHubCloudAccount } from '../__mocks__/account-mocks';
+
+import { DesignLanguage, Theme } from '../types';
+
+import { AppErrorFallback } from './AppErrorBoundary';
+import { RouteError } from './RouteError';
+
+const ERROR_STATES = [
+ { element: , name: 'route-error', withChrome: true },
+ { element: , name: 'app-error', withChrome: false },
+];
+
+const THEME_VARIANTS = [
+ { designLanguage: DesignLanguage.CLASSIC, label: 'classic light', theme: Theme.LIGHT },
+ { designLanguage: DesignLanguage.CLASSIC, label: 'classic dark', theme: Theme.DARK },
+ { designLanguage: DesignLanguage.GLASS, label: 'glass light', theme: Theme.LIGHT },
+ { designLanguage: DesignLanguage.GLASS, label: 'glass dark', theme: Theme.DARK },
+] as const;
+
+describe('renderer/components error states visual regression', () => {
+ describe.each(THEME_VARIANTS)('$label', ({ designLanguage, theme }) => {
+ it.each(ERROR_STATES)('$name', async ({ element, name, withChrome }) => {
+ renderRoute(element, {
+ accounts: [mockGitHubCloudAccount],
+ designLanguage,
+ theme,
+ withChrome,
+ });
+
+ await expect(page.getByTestId('visual-root')).toMatchScreenshot(
+ `${name}-${theme.toLowerCase()}-${designLanguage}`,
+ );
+ });
+ });
+});
diff --git a/src/renderer/components/Oops.test.tsx b/src/renderer/components/Oops.test.tsx
index 7cf619896..b84ff5bf5 100644
--- a/src/renderer/components/Oops.test.tsx
+++ b/src/renderer/components/Oops.test.tsx
@@ -57,4 +57,35 @@ describe('renderer/components/Oops.tsx', () => {
expect(navigateMock).toHaveBeenCalledWith('/somewhere');
});
+
+ it('renders a recovery action alongside the error actions', async () => {
+ const recovery = vi.fn();
+
+ const mockError = {
+ title: 'Error title',
+ descriptions: ['Error description'],
+ emojis: ['🔥'],
+ actions: [
+ {
+ label: 'Go somewhere',
+ route: '/somewhere',
+ variant: 'danger' as const,
+ icon: PersonIcon,
+ },
+ ],
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText('Go somewhere')).toBeInTheDocument();
+
+ await userEvent.click(screen.getByText('Reload'));
+
+ expect(recovery).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/src/renderer/components/Oops.tsx b/src/renderer/components/Oops.tsx
index 498355bba..147c75705 100644
--- a/src/renderer/components/Oops.tsx
+++ b/src/renderer/components/Oops.tsx
@@ -1,28 +1,36 @@
import { useMemo } from 'react';
import { useNavigate } from 'react-router';
-import { Button } from '@primer/react';
+import { Button, Stack } from '@primer/react';
import { EmojiSplash } from './layout/EmojiSplash';
-import type { GitifyError } from '../types';
+import type { GitifyError, GitifyErrorAction } from '../types';
import { getErrorDetails } from '../utils/core/errors';
import { randomElement } from '../utils/core/random';
+export interface RecoveryAction {
+ label: string;
+ icon: GitifyErrorAction['icon'];
+ onClick: () => void;
+}
+
interface OopsProps {
error: GitifyError;
fullHeight?: boolean;
+ /** Context-specific recovery action rendered after the error's route actions. */
+ recovery?: RecoveryAction;
}
-export function Oops({ error, fullHeight = true }: Readonly) {
+export function Oops({ error, fullHeight = true, recovery }: Readonly) {
const navigate = useNavigate();
const err = error ?? getErrorDetails('UNKNOWN');
const emoji = useMemo(() => randomElement(err.emojis), [err]);
- const actions = err.actions?.length
+ const errorActions = err.actions?.length
? err.actions.map((action) => (