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:
MechaCat02
2026-07-11 14:47:41 +02:00
parent d779fa2b97
commit 32d0a7e13b
5 changed files with 142 additions and 2 deletions

View 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>