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) => ( + ) : null; + + const actions = recoveryAction ? ( + + {errorActions} + {recoveryAction} + + ) : ( + errorActions + ); + return ( { + it('renders the Oops fallback with a reload action', async () => { + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const router = createMemoryRouter( + [{ path: '/', element: , errorElement: }], + { initialEntries: ['/'] }, + ); + + render(); + + expect(await screen.findByText('Oops! Something went wrong')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: /Reload/ })).toBeInTheDocument(); + + consoleError.mockRestore(); + }); +}); diff --git a/src/renderer/components/RouteError.tsx b/src/renderer/components/RouteError.tsx new file mode 100644 index 000000000..e36875965 --- /dev/null +++ b/src/renderer/components/RouteError.tsx @@ -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 ; +} diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-classic-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-classic-chromium-linux.png new file mode 100644 index 000000000..72a942ea5 Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-classic-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-glass-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-glass-chromium-linux.png new file mode 100644 index 000000000..1a40d24f6 Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-dark-glass-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-classic-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-classic-chromium-linux.png new file mode 100644 index 000000000..ec7b90bb9 Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-classic-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-glass-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-glass-chromium-linux.png new file mode 100644 index 000000000..12679761b Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/app-error-light-glass-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-classic-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-classic-chromium-linux.png new file mode 100644 index 000000000..6907427ac Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-classic-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-glass-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-glass-chromium-linux.png new file mode 100644 index 000000000..b92d1af7c Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-dark-glass-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-classic-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-classic-chromium-linux.png new file mode 100644 index 000000000..4cb578feb Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-classic-chromium-linux.png differ diff --git a/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-glass-chromium-linux.png b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-glass-chromium-linux.png new file mode 100644 index 000000000..bdc730545 Binary files /dev/null and b/src/renderer/components/__screenshots__/ErrorStates.visual.test.tsx/route-error-light-glass-chromium-linux.png differ diff --git a/src/renderer/components/primitives/ReloadButton.tsx b/src/renderer/components/primitives/ReloadButton.tsx new file mode 100644 index 000000000..f123b4160 --- /dev/null +++ b/src/renderer/components/primitives/ReloadButton.tsx @@ -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 ( + + ); +} diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 9f2c5076a..5bea98a14 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -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" diff --git a/src/renderer/index.tsx b/src/renderer/index.tsx index cc8a47cfd..751a258c7 100644 --- a/src/renderer/index.tsx +++ b/src/renderer/index.tsx @@ -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(); +root.render( + + + , +); diff --git a/src/renderer/router.tsx b/src/renderer/router.tsx index 847e0c95e..bbb945da2 100644 --- a/src/renderer/router.tsx +++ b/src/renderer/router.tsx @@ -16,6 +16,8 @@ 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. @@ -23,6 +25,7 @@ import { SettingsRoute } from './routes/Settings'; export const routes: RouteObject[] = [ { element: , + errorElement: , children: [ { element: , path: 'login' }, { @@ -52,6 +55,7 @@ export const routes: RouteObject[] = [ }, { element: , + errorElement: , children: [ { element: , index: true }, { element: , path: 'filters' },