Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
49 changes: 49 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 `serverErrorBoundaries` option in your config:

```js
/// file: svelte.config.js
/** @type {import('@sveltejs/kit').Config} */
const config = {
kit: {
experimental: {
serverErrorBoundaries: 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 Expand Up @@ -127,6 +175,7 @@ If the error instead occurs inside a `load` function while rendering a page, Sve

The exception is when the error occurs inside the root `+layout.js` or `+layout.server.js`, since the root layout would ordinarily _contain_ the `+error.svelte` component. In this case, SvelteKit uses the fallback error page.


## Type safety

If you're using TypeScript and need to customize the shape of errors, you can do so by declaring an `App.Error` interface in your app (by convention, in `src/app.d.ts`, though it can live anywhere that TypeScript can 'see'):
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 @@ -512,6 +512,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