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
6 changes: 5 additions & 1 deletion src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -26,7 +27,10 @@ export function App() {
<ThemeProvider>
<BaseStyles>
<GlobalEffects />
<RouterProvider router={router} />
<RouterProvider
onError={(error) => rendererLogError('router', 'Router error', toError(error))}
router={router}
/>
</BaseStyles>
</ThemeProvider>
</QueryClientProvider>
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/__helpers__/visual-utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export interface VisualRenderOptions {
settings?: Partial<SettingsState>;
filters?: Partial<FiltersStore>;
notifications?: AccountNotifications[];
/** Set false for full-window states that replace the app shell (e.g. app error fallback). */
withChrome?: boolean;
}

/**
Expand Down Expand Up @@ -88,6 +90,7 @@ export function renderRoute(
settings,
filters,
notifications,
withChrome = true,
}: VisualRenderOptions,
) {
useSettingsStore.setState({ ...settings, theme, designLanguage });
Expand Down Expand Up @@ -137,7 +140,7 @@ export function renderRoute(
{/* Routes reading `location.state` (AccountScopes) get their
account through here, so it needs stubbing too. */}
<MemoryRouter initialEntries={withStubbedAvatars(initialEntries)}>
<AppLayout>{children}</AppLayout>
{withChrome ? <AppLayout>{children}</AppLayout> : children}
</MemoryRouter>
</BaseStyles>
</ThemeProvider>
Expand Down
29 changes: 29 additions & 0 deletions src/renderer/components/AppErrorBoundary.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<AppErrorBoundary>
<Boom />
</AppErrorBoundary>,
);

expect(await screen.findByText('Oops! Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reload/ })).toBeInTheDocument();
expect(logSpy).toHaveBeenCalled();

logSpy.mockRestore();
consoleError.mockRestore();
});
});
49 changes: 49 additions & 0 deletions src/renderer/components/AppErrorBoundary.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<EmojiSplash
actions={<ReloadButton />}
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<AppErrorBoundaryProps, AppErrorBoundaryState> {
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 ? <AppErrorFallback /> : this.props.children;
}
}
38 changes: 38 additions & 0 deletions src/renderer/components/ErrorStates.visual.test.tsx
Original file line number Diff line number Diff line change
@@ -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: <RouteError />, name: 'route-error', withChrome: true },
{ element: <AppErrorFallback />, 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}`,
);
});
});
});
31 changes: 31 additions & 0 deletions src/renderer/components/Oops.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<Oops
error={mockError}
recovery={{ icon: PersonIcon, label: 'Reload', onClick: recovery }}
/>,
);

expect(screen.getByText('Go somewhere')).toBeInTheDocument();

await userEvent.click(screen.getByText('Reload'));

expect(recovery).toHaveBeenCalledTimes(1);
});
});
31 changes: 27 additions & 4 deletions src/renderer/components/Oops.tsx
Original file line number Diff line number Diff line change
@@ -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<OopsProps>) {
export function Oops({ error, fullHeight = true, recovery }: Readonly<OopsProps>) {
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) => (
<Button
key={action.route}
Expand All @@ -35,6 +43,21 @@ export function Oops({ error, fullHeight = true }: Readonly<OopsProps>) {
))
: null;

const recoveryAction = recovery ? (
<Button key="recovery" leadingVisual={recovery.icon} onClick={recovery.onClick}>
{recovery.label}
</Button>
) : null;

const actions = recoveryAction ? (
<Stack direction="horizontal" gap="condensed">
{errorActions}
{recoveryAction}
</Stack>
) : (
errorActions
);

return (
<EmojiSplash
actions={actions}
Expand Down
27 changes: 27 additions & 0 deletions src/renderer/components/RouteError.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { render, screen } from '@testing-library/react';
import type { ReactElement } from 'react';
import { createMemoryRouter, RouterProvider } from 'react-router';

import { RouteError } from './RouteError';

function Boom(): ReactElement {
throw new Error('boom');
}

describe('renderer/components/RouteError.tsx', () => {
it('renders the Oops fallback with a reload action', async () => {
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {});

const router = createMemoryRouter(
[{ path: '/', element: <Boom />, errorElement: <RouteError /> }],
{ initialEntries: ['/'] },
);

render(<RouterProvider router={router} />);

expect(await screen.findByText('Oops! Something went wrong')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Reload/ })).toBeInTheDocument();

consoleError.mockRestore();
});
});
14 changes: 14 additions & 0 deletions src/renderer/components/RouteError.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useReloadAction } from './primitives/ReloadButton';

import { getErrorDetails } from '../utils/core/errors';
import { Oops } from './Oops';

/**
* Route-level fallback. React Router renders it inside the nearest layout, so
* an error in a protected screen keeps the sidebar and the rest of the shell.
*/
export function RouteError() {
const recovery = useReloadAction();

return <Oops error={getErrorDetails('UNKNOWN')} recovery={recovery} />;
}
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions src/renderer/components/primitives/ReloadButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { useTranslation } from 'react-i18next';

import { SyncIcon } from '@primer/octicons-react';
import { Button } from '@primer/react';

/** Reloads the renderer; shared by the route fallback and the outer boundary. */
export function reloadRenderer() {
window.location.reload();
}

/** Typed recovery action used by `Oops` and the application error fallback. */
export function useReloadAction() {
const { t } = useTranslation();

return {
icon: SyncIcon,
label: t('errors.reload', 'Reload'),
onClick: reloadRenderer,
} as const;
}

export function ReloadButton() {
const action = useReloadAction();

return (
<Button leadingVisual={action.icon} onClick={action.onClick}>
{action.label}
</Button>
);
}
1 change: 1 addition & 0 deletions src/renderer/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
"description": "Please wait a while before trying again.",
"title": "Rate Limited"
},
"reload": "Reload",
"unknown": {
"description": "Please try again later.",
"title": "Oops! Something went wrong"
Expand Down
8 changes: 7 additions & 1 deletion src/renderer/index.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { createRoot } from 'react-dom/client';

import './i18n';
import { AppErrorBoundary } from './components/AppErrorBoundary';

import { App } from './App';

const container = document.getElementById('root');
const root = createRoot(container!);

root.render(<App />);
root.render(
<AppErrorBoundary>
<App />
</AppErrorBoundary>,
);
4 changes: 4 additions & 0 deletions src/renderer/router.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,16 @@ import { ProtectedLayout } from './routes/ProtectedLayout';
import { RootLayout } from './routes/RootLayout';
import { SettingsRoute } from './routes/Settings';

import { RouteError } from './components/RouteError';

/**
* The renderer's route table. Exported separately from the router instance so
* tests can build a memory router from the same routes.
*/
export const routes: RouteObject[] = [
{
element: <RootLayout />,
errorElement: <RouteError />,
children: [
{ element: <LoginRoute />, path: 'login' },
{
Expand Down Expand Up @@ -52,6 +55,7 @@ export const routes: RouteObject[] = [
},
{
element: <ProtectedLayout />,
errorElement: <RouteError />,
children: [
{ element: <NotificationsRoute />, index: true },
{ element: <FiltersRoute />, path: 'filters' },
Expand Down
Loading