Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
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
5 changes: 5 additions & 0 deletions .changeset/cyan-walls-leave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: allow error boundaries to catch errors on the server
48 changes: 48 additions & 0 deletions documentation/docs/30-advanced/25-errors.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,54 @@ By default, unexpected errors are printed to the console (or, in production, you

Unexpected errors will go through the [`handleError`](hooks#Shared-hooks-handleError) hook, where you can add your own error handling — for example, sending errors to a reporting service, or returning a custom error object which becomes `page.error`.

## Rendering errors

Ordinarily, if an error happens during server-side rendering (for example inside a component's `<script>` block or template), SvelteKit will return a 500 error page.

Since SvelteKit 2.54 and Svelte 5.53, you can change this by enabling the experimental `handleRenderingErrors` option in your config:

```js
/// file: svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
experimental: {
handleRenderingErrors: true
}
}
};

export default config;
```

When this is enabled, SvelteKit will wrap your route components in an error boundary. If an error occurs during rendering, the nearest [`+error.svelte`](routing#error) page will be shown, just as if the error had occurred in a `load` function.

The error is first passed to [`handleError`](hooks#Shared-hooks-handleError), allowing you to report it and transform it, before the resulting object is passed to the `+error.svelte` component.

> [!NOTE]
> Since rendering errors occur after the page has started rendering, and multiple boundaries could in parallel catch distinct errors, the [`page`]($app-state#page) object (and its `error` property) will not be updated. Instead, the error is passed directly to the `+error.svelte` component as a prop.

```svelte
<!--- file: +error.svelte --->
<script>
let { error } = $props();
</script>

<h1>{error.message}</h1>
```

The same applies for other error boundaries you define in your code:

```svelte
<svelte:boundary>
...
{#snippet failed(error)}
<!-- error went through handleError and is of type App.Error -->
{error.message}
{/snippet}
</svelte:boundary>
```

## Responses

If an error occurs inside `handle` or inside a [`+server.js`](routing#server) request handler, SvelteKit will respond with either a fallback error page or a JSON representation of the error object, depending on the request's `Accept` headers.
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ const get_defaults = (prefix = '') => ({
tracing: { server: false },
instrumentation: { server: false },
remoteFunctions: false,
forkPreloads: false
forkPreloads: false,
handleRenderingErrors: false
},
files: {
src: join(prefix, 'src'),
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,8 @@ const options = object(
server: boolean(false)
}),
remoteFunctions: boolean(false),
forkPreloads: boolean(false)
forkPreloads: boolean(false),
handleRenderingErrors: boolean(false)
}),

files: object({
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/sync/sync.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function create(config) {

write_client_manifest(config.kit, manifest_data, `${output}/client`);
write_server(config, output);
write_root(manifest_data, output);
write_root(manifest_data, config, output);
write_all_types(config, manifest_data);
write_non_ambient(config.kit, manifest_data);

Expand Down
47 changes: 41 additions & 6 deletions packages/kit/src/core/sync/write_root.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ import { dedent, isSvelte5Plus, write_if_changed } from './utils.js';

/**
* @param {import('types').ManifestData} manifest_data
* @param {import('types').ValidatedConfig} config
* @param {string} output
*/
export function write_root(manifest_data, output) {
export function write_root(manifest_data, config, output) {
// TODO remove default layout altogether

const use_boundaries = config.kit.experimental.handleRenderingErrors && isSvelte5Plus();

const max_depth = Math.max(
...manifest_data.routes.map((route) =>
route.page ? route.page.layouts.filter(Boolean).length + 1 : 0
Expand All @@ -20,17 +23,47 @@ export function write_root(manifest_data, output) {
}

let l = max_depth;
/** @type {string} */
let pyramid;

let pyramid = dedent`
if (isSvelte5Plus() && use_boundaries) {
// with the @const we force the data[depth] access to be derived, which is important to not fire updates needlessly
// TODO in Svelte 5 we should rethink the client.js side, we can likely make data a $state and only update indexes that changed there, simplifying this a lot
pyramid = dedent`
{#snippet pyramid(depth)}
{@const Pyramid = constructors[depth]}
{#snippet failed(error)}
{@const ErrorPage = errors[depth]}
<ErrorPage {error} />
{/snippet}
<svelte:boundary failed={errors[depth] ? failed : undefined}>
{#if constructors[depth + 1]}
{@const d = data[depth]}
<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params}>
{@render pyramid(depth + 1)}
</Pyramid>
{:else}
{@const d = data[depth]}
<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params} {error} />
{/if}
</svelte:boundary>
{/snippet}

{@render pyramid(0)}
`;
} else {
pyramid = dedent`
${
isSvelte5Plus()
? `<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} params={page.params} />`
: `<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} {form} params={page.params} />`
}`;

while (l--) {
pyramid = dedent`
while (l--) {
pyramid = dedent`
{#if constructors[${l + 1}]}
${
isSvelte5Plus()
Expand All @@ -57,6 +90,7 @@ export function write_root(manifest_data, output) {

{/if}
`;
}
}

write_if_changed(
Expand All @@ -72,9 +106,10 @@ export function write_root(manifest_data, output) {
${
isSvelte5Plus()
? dedent`
let { stores, page, constructors, components = [], form, ${levels
let { stores, page, constructors, components = [], form, ${use_boundaries ? 'errors = [], error, ' : ''}${levels
.map((l) => `data_${l} = null`)
.join(', ')} } = $props();
${use_boundaries ? `let data = $derived({${levels.map((l) => `'${l}': data_${l}`).join(', ')}})` : ''}
`
: dedent`
export let stores;
Expand Down Expand Up @@ -108,7 +143,7 @@ export function write_root(manifest_data, output) {
isSvelte5Plus()
? dedent`
$effect(() => {
stores;page;constructors;components;form;${levels.map((l) => `data_${l}`).join(';')};
stores;page;constructors;components;form;${use_boundaries ? 'errors;error;' : ''}${levels.map((l) => `data_${l}`).join(';')};
stores.page.notify();
});
`
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/core/sync/write_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export const options = {
root,
service_worker: ${has_service_worker},
service_worker_options: ${config.kit.serviceWorker.register ? s(config.kit.serviceWorker.options) : 'null'},
server_error_boundaries: ${s(!!config.kit.experimental.handleRenderingErrors)},
templates: {
app: ({ head, body, assets, nonce, env }) => ${s(template)
.replace('%sveltekit.head%', '" + head + "')
Expand Down
9 changes: 9 additions & 0 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,15 @@ export interface KitConfig {
* @default false
*/
forkPreloads?: boolean;

/**
* Whether to enable the experimental handling of rendering errors.
* When enabled, `<svelte:boundary>` is used to wrap components at each level
* where there's an `+error.svelte`, rendering the error page if the component fails.
* In addition, error boundaries also work on the server and the error object goes through `handleError`.
* @default false
*/
handleRenderingErrors?: boolean;
};
/**
* Where to find various files within your project.
Expand Down
3 changes: 2 additions & 1 deletion packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,8 @@ async function kit({ svelte_config }) {
__SVELTEKIT_PATHS_RELATIVE__: s(kit.paths.relative),
__SVELTEKIT_CLIENT_ROUTING__: s(kit.router.resolution === 'client'),
__SVELTEKIT_HASH_ROUTING__: s(kit.router.type === 'hash'),
__SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.tracing.server)
__SVELTEKIT_SERVER_TRACING_ENABLED__: s(kit.experimental.tracing.server),
__SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__: s(kit.experimental.handleRenderingErrors)
};

if (is_build) {
Expand Down
Loading
Loading