Skip to content

Commit 4342992

Browse files
authored
Merge pull request #4530 from atomantic/cos/task-msy5dmuo/agent-145c31b9
fix: provide route error recovery UI
2 parents 65b764c + aa31e47 commit 4342992

3 files changed

Lines changed: 119 additions & 0 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { AlertTriangle } from 'lucide-react';
2+
import { useNavigate, useRouteError, isRouteErrorResponse } from 'react-router';
3+
import Banner from './ui/Banner';
4+
5+
const getErrorMessage = (error) => {
6+
if (isRouteErrorResponse(error)) return error.statusText || `Request failed (${error.status})`;
7+
if (error instanceof Error) return error.message;
8+
if (typeof error === 'string') return error;
9+
return 'The page could not be loaded.';
10+
};
11+
12+
export default function RouteErrorFallback() {
13+
const error = useRouteError();
14+
const navigate = useNavigate();
15+
const message = getErrorMessage(error);
16+
17+
return (
18+
<div className="min-h-dvh-cap bg-port-bg flex items-center justify-center p-4">
19+
<div className="bg-port-card border border-port-border rounded-xl p-8 max-w-lg w-full">
20+
<div className="flex items-center justify-center mb-4">
21+
<AlertTriangle size={32} className="text-port-error" />
22+
</div>
23+
<h1 className="text-xl font-bold text-port-text text-center mb-2">PortOS could not load this page</h1>
24+
<p className="text-port-text-muted text-sm text-center mb-4">
25+
The server may still be restarting, or this browser may have an outdated app asset. Try again in a moment.
26+
</p>
27+
<Banner tone="error" size="md" className="mb-4">
28+
<p className="text-xs font-mono break-all">{message}</p>
29+
</Banner>
30+
<div className="flex flex-col sm:flex-row gap-2">
31+
<button
32+
type="button"
33+
onClick={() => window.location.reload()}
34+
className="flex-1 px-4 py-2 bg-port-accent hover:bg-port-accent/80 text-port-on-accent rounded-lg transition-colors"
35+
>
36+
Try again
37+
</button>
38+
<button
39+
type="button"
40+
onClick={() => navigate('/')}
41+
className="flex-1 px-4 py-2 border border-port-border hover:bg-port-card-hover text-port-text rounded-lg transition-colors"
42+
>
43+
Go to dashboard
44+
</button>
45+
</div>
46+
</div>
47+
</div>
48+
);
49+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { describe, expect, it, vi } from 'vitest';
2+
import { render, screen, act } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
import { createMemoryRouter, Outlet, RouterProvider } from 'react-router';
5+
import RouteErrorFallback from './RouteErrorFallback';
6+
7+
const renderRouteError = async (error) => {
8+
const router = createMemoryRouter([
9+
{
10+
path: '/',
11+
errorElement: <RouteErrorFallback />,
12+
element: <Outlet />,
13+
children: [
14+
{ index: true, element: <div>Loaded page</div> },
15+
{
16+
path: 'broken',
17+
loader: () => { throw error; },
18+
element: <div>Broken page</div>,
19+
},
20+
],
21+
},
22+
], { initialEntries: ['/broken'] });
23+
24+
let view;
25+
await act(async () => {
26+
view = render(<RouterProvider router={router} />);
27+
await router.initialize();
28+
});
29+
return view;
30+
};
31+
32+
describe('RouteErrorFallback', () => {
33+
it('explains that the page failed and offers recovery actions', async () => {
34+
await renderRouteError(new Error('Importing a module script failed'));
35+
36+
expect(screen.getByRole('heading', { name: 'PortOS could not load this page' })).toBeInTheDocument();
37+
expect(screen.getByText('Importing a module script failed')).toBeInTheDocument();
38+
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
39+
expect(screen.getByRole('button', { name: 'Go to dashboard' })).toBeInTheDocument();
40+
});
41+
42+
it('returns to the dashboard without reloading', async () => {
43+
const user = userEvent.setup();
44+
await renderRouteError(new Error('Server unavailable'));
45+
46+
await user.click(screen.getByRole('button', { name: 'Go to dashboard' }));
47+
48+
expect(screen.getByText('Loaded page')).toBeInTheDocument();
49+
});
50+
51+
it('handles a route error response', async () => {
52+
await renderRouteError(new Response(null, { status: 503, statusText: 'Service Unavailable' }));
53+
54+
expect(screen.getByText('Service Unavailable')).toBeInTheDocument();
55+
});
56+
57+
it('reloads when retry is clicked', async () => {
58+
const reload = vi.fn();
59+
vi.stubGlobal('location', { reload });
60+
const user = userEvent.setup();
61+
await renderRouteError(new Error('Server unavailable'));
62+
63+
await user.click(screen.getByRole('button', { name: 'Try again' }));
64+
65+
expect(reload).toHaveBeenCalledOnce();
66+
vi.unstubAllGlobals();
67+
});
68+
});

client/src/main.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import ReactDOM from 'react-dom/client';
44
import { createBrowserRouter, RouterProvider } from 'react-router';
55
import { Toaster } from './components/ui/Toast';
66
import ErrorBoundary from './components/ErrorBoundary';
7+
import RouteErrorFallback from './components/RouteErrorFallback';
78
import { ThemeProvider } from './components/ThemeContext';
89
import { isStaleChunkError, reloadOnceForStaleChunk } from './utils/staleChunkReload';
910
import { reportClientError } from './lib/clientErrorReporter';
@@ -60,6 +61,7 @@ window.addEventListener('error', (event) => {
6061
const router = createBrowserRouter([
6162
{
6263
path: '*',
64+
errorElement: <RouteErrorFallback />,
6365
element: (
6466
<>
6567
<App />

0 commit comments

Comments
 (0)