feat: root error boundary and network-error normalisation
A load() that rethrew (e.g. the profile overview) fell through to SvelteKit's bare default error page, and a connection-refused fetch rejected as a raw TypeError that blanked the view. Wrap fetch network failures in the client as ApiError(status 0, network_error) so callers handle them uniformly, and add a root +error.svelte with a friendly message plus Try again / Go home actions. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
42
frontend/e2e/error-boundary.spec.ts
Normal file
42
frontend/e2e/error-boundary.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { test, expect, type Page } from './fixtures';
|
||||||
|
|
||||||
|
// The root +error.svelte catches load() errors that a page rethrows rather than
|
||||||
|
// capturing in-band (e.g. the profile overview). Without it these fell through
|
||||||
|
// to SvelteKit's bare default error page — or blanked entirely on a raw
|
||||||
|
// TypeError. Drive the profile loader into a failure and assert the boundary.
|
||||||
|
|
||||||
|
async function authed(page: Page) {
|
||||||
|
await page.route('**/api/v1/auth/config', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ self_register_enabled: true, private_mode: false })
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me', (r) =>
|
||||||
|
r.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
user: { id: 'u1', username: 'reader', created_at: '2026-01-01T00:00:00Z', is_admin: false }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
);
|
||||||
|
await page.route('**/api/v1/auth/me/preferences', (r) =>
|
||||||
|
r.fulfill({ status: 200, contentType: 'application/json', body: '{}' })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('profile: a network failure renders the error boundary, not a blank page', async ({
|
||||||
|
page
|
||||||
|
}) => {
|
||||||
|
await authed(page);
|
||||||
|
// Profile's load rethrows non-401 errors; a raw abort is the TypeError case
|
||||||
|
// the client now normalises to an ApiError so it reaches the boundary.
|
||||||
|
await page.route('**/api/v1/me/bookmarks*', (r) => r.abort());
|
||||||
|
await page.route('**/api/v1/me/collections*', (r) => r.abort());
|
||||||
|
|
||||||
|
await page.goto('/profile');
|
||||||
|
await expect(page.getByTestId('error-boundary')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Try again' })).toBeVisible();
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.125.1",
|
"version": "0.125.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -80,6 +80,17 @@ describe('request error envelope parsing', () => {
|
|||||||
expect(err.code).toBe('http_error');
|
expect(err.code).toBe('http_error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('wraps a network-level fetch rejection in ApiError (status 0)', async () => {
|
||||||
|
// Connection refused / offline / DNS: fetch rejects with a TypeError.
|
||||||
|
// It must surface as an ApiError, not a bare TypeError that crashes a
|
||||||
|
// load function into SvelteKit's default error page.
|
||||||
|
fetchSpy.mockRejectedValueOnce(new TypeError('Failed to fetch'));
|
||||||
|
const err = (await getManga('x').catch((e) => e)) as ApiError;
|
||||||
|
expect(err).toBeInstanceOf(ApiError);
|
||||||
|
expect(err.status).toBe(0);
|
||||||
|
expect(err.code).toBe('network_error');
|
||||||
|
});
|
||||||
|
|
||||||
it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => {
|
it('treats empty 200/201 bodies as undefined (no JSON.parse crash)', async () => {
|
||||||
// Regression: addMangaToCollection is typed `void` and the
|
// Regression: addMangaToCollection is typed `void` and the
|
||||||
// backend returns 201 (created) / 200 (already there) with
|
// backend returns 201 (created) / 200 (already there) with
|
||||||
|
|||||||
@@ -103,7 +103,21 @@ export async function request<T>(
|
|||||||
// working. For same-origin requests this is a no-op compared to the
|
// working. For same-origin requests this is a no-op compared to the
|
||||||
// default 'same-origin', so the same-origin happy path is
|
// default 'same-origin', so the same-origin happy path is
|
||||||
// unchanged.
|
// unchanged.
|
||||||
const res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
let res: Response;
|
||||||
|
try {
|
||||||
|
res = await fetch(`${BASE}${path}`, { credentials: 'include', ...init });
|
||||||
|
} catch {
|
||||||
|
// A network-level failure (connection refused, DNS, offline, blocked by
|
||||||
|
// CORS) rejects `fetch` with a bare TypeError instead of returning a
|
||||||
|
// response. Normalise it to an ApiError (status 0 = "no response reached
|
||||||
|
// us") so callers and SvelteKit load functions handle it uniformly
|
||||||
|
// instead of crashing on an unexpected TypeError.
|
||||||
|
throw new ApiError(
|
||||||
|
0,
|
||||||
|
'network_error',
|
||||||
|
'Could not reach the server. Check your connection and try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let code = 'http_error';
|
let code = 'http_error';
|
||||||
let message = `${res.status} ${res.statusText}`;
|
let message = `${res.status} ${res.statusText}`;
|
||||||
|
|||||||
73
frontend/src/routes/+error.svelte
Normal file
73
frontend/src/routes/+error.svelte
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { page } from '$app/stores';
|
||||||
|
import { goto, invalidateAll } from '$app/navigation';
|
||||||
|
|
||||||
|
// Shown whenever a load function throws (including a wrapped network_error
|
||||||
|
// ApiError from the client). Without this boundary an uncaught load error
|
||||||
|
// fell through to SvelteKit's bare default error page — or, for a raw
|
||||||
|
// TypeError, blanked the view entirely.
|
||||||
|
let status = $derived($page.status);
|
||||||
|
let message = $derived($page.error?.message || 'Something went wrong.');
|
||||||
|
let isNetwork = $derived(status === 0 || status >= 500);
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
// Re-run the failed load(s) in place rather than a hard reload.
|
||||||
|
await invalidateAll();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<section class="error-page" data-testid="error-boundary">
|
||||||
|
<p class="status">{status || 'Error'}</p>
|
||||||
|
<h1>{isNetwork ? 'We hit a snag' : 'This page is unavailable'}</h1>
|
||||||
|
<p class="message">{message}</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="primary" onclick={retry}>Try again</button>
|
||||||
|
<button type="button" class="ghost" onclick={() => goto('/')}>Go home</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.error-page {
|
||||||
|
max-width: 32rem;
|
||||||
|
margin: 4rem auto;
|
||||||
|
padding: 0 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.status {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
margin: 0.75rem 0 0.5rem;
|
||||||
|
font-size: 1.4rem;
|
||||||
|
}
|
||||||
|
.message {
|
||||||
|
color: var(--color-text-muted, #888);
|
||||||
|
margin: 0 0 1.5rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
padding: 0.55rem 1.1rem;
|
||||||
|
border-radius: var(--radius-md, 8px);
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--color-border, #ccc);
|
||||||
|
}
|
||||||
|
.primary {
|
||||||
|
background: var(--color-accent, #3b82f6);
|
||||||
|
color: var(--color-accent-contrast, #fff);
|
||||||
|
border-color: transparent;
|
||||||
|
}
|
||||||
|
.ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user