Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
f9077f6
feat: allow error boundaries to catch errors on the server
dummdidumm Feb 12, 2026
4dc9665
better code output; pass error prop
dummdidumm Feb 13, 2026
59867d4
ensure NavigationEvent is set
dummdidumm Feb 13, 2026
ed183f7
test it
dummdidumm Feb 13, 2026
4747589
fixes/tweaks
dummdidumm Feb 17, 2026
081cc30
root.svelte snippet simplifcation
dummdidumm Feb 17, 2026
54c76be
fix data prop passing
dummdidumm Feb 17, 2026
4201313
fix test app, client/write_root/render fixes
dummdidumm Feb 17, 2026
893d207
root layout wraps root error component, not the other way around
dummdidumm Feb 17, 2026
61cfd7a
Merge branch 'main' into error-boundaries-server
dummdidumm Feb 21, 2026
2ad2d98
bump packages
dummdidumm Feb 21, 2026
46901d8
lint
dummdidumm Feb 21, 2026
8fcff53
docs
dummdidumm Feb 21, 2026
54d51d5
rename
dummdidumm Feb 21, 2026
51d4ac0
stfu eslint
dummdidumm Feb 21, 2026
2f1e4d3
missed a spot
dummdidumm Feb 22, 2026
21ee2cb
Apply suggestion from @dummdidumm
dummdidumm Feb 22, 2026
2c15482
update page.error/status
dummdidumm Feb 24, 2026
fe4aab5
Merge branch 'main' into error-boundaries-server
dummdidumm Feb 25, 2026
d738ed7
Fix: Documentation has typo in closing tag `</svelte::boundary>` with…
vercel[bot] Feb 26, 2026
6de75b6
Fix: In the `transformError` callback, `get_status(error)` is called …
vercel[bot] Feb 26, 2026
4546cb3
Fix: HTTP status codes (e.g., 403, 404) are lost and always replaced …
vercel[bot] Feb 26, 2026
0b46132
Merge branch 'main' into error-boundaries-server
Rich-Harris Mar 10, 2026
bb79369
Update documentation/docs/30-advanced/25-errors.md
dummdidumm Mar 10, 2026
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
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,
serverErrorBoundaries: 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),
serverErrorBoundaries: 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 @@ -29,7 +29,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
32 changes: 27 additions & 5 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.serverErrorBoundaries && isSvelte5Plus();

const max_depth = Math.max(
...manifest_data.routes.map((route) =>
route.page ? route.page.layouts.filter(Boolean).length + 1 : 0
Expand All @@ -25,19 +28,38 @@ export function write_root(manifest_data, output) {
${
isSvelte5Plus()
? `<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} params={page.params} />`
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} params={page.params} {error} />`
: `<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} {form} params={page.params} />`
}`;

while (l--) {
let children = pyramid;

if (use_boundaries) {
// TODO I think we can check if an +error.svelte exists at this level, and only add the boundary if it does
children = dedent`
{#if errors[${l}]}
<svelte:boundary>
${pyramid}
{#snippet failed(error)}
{@const ErrorPage_${l} = errors[${l}]}
<ErrorPage_${l} {error} />
{/snippet}
</svelte:boundary>
{:else}
${pyramid}
{/if}
`;
}

pyramid = dedent`
{#if constructors[${l + 1}]}
${
isSvelte5Plus()
? dedent`{@const Pyramid_${l} = constructors[${l}]}
<!-- svelte-ignore binding_property_non_reactive -->
<Pyramid_${l} bind:this={components[${l}]} data={data_${l}} {form} params={page.params}>
${pyramid}
${children}
</Pyramid_${l}>`
: dedent`<svelte:component this={constructors[${l}]} bind:this={components[${l}]} data={data_${l}} params={page.params}>
${pyramid}
Expand Down Expand Up @@ -72,7 +94,7 @@ 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();
`
Expand Down Expand Up @@ -108,7 +130,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.serverErrorBoundaries)},
templates: {
app: ({ head, body, assets, nonce, env }) => ${s(template)
.replace('%sveltekit.head%', '" + head + "')
Expand Down
8 changes: 8 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,14 @@ export interface KitConfig {
* @default false
*/
forkPreloads?: boolean;

/**
* Whether to enable the experimental server error boundaries feature.
* 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.
* @default false
*/
serverErrorBoundaries?: 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 @@ -350,7 +350,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.serverErrorBoundaries)
};

if (is_build) {
Expand Down
58 changes: 54 additions & 4 deletions packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,18 @@ async function initialize(result, target, hydrate) {
props: { ...result.props, stores, components },
hydrate,
// @ts-ignore Svelte 5 specific: asynchronously instantiate the component, i.e. don't call flushSync
sync: false
sync: false,
// @ts-ignore Svelte 5 specific: transformError allows to transform errors before they are passed to boundaries
transformError: __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__
? /** @param {unknown} error */ (error) =>
app.hooks.handleError({
error,
// @ts-expect-error TODO - what do we pass here? Nothing, and the types are adjusted accordingly in SvelteKit 3?
event: null,
status: get_status(error),
message: get_message(error)
})
: undefined
});

// Wait for a microtask in case svelte experimental async is enabled,
Expand Down Expand Up @@ -625,13 +636,23 @@ async function initialize(result, target, hydrate) {
* url: URL;
* params: Record<string, string>;
* branch: Array<import('./types.js').BranchNode | undefined>;
* errors?: Array<import('types').CSRPageNodeLoader | undefined>;
* status: number;
* error: App.Error | null;
* route: import('types').CSRRoute | null;
* form?: Record<string, any> | null;
* }} opts
*/
function get_navigation_result_from_branch({ url, params, branch, status, error, route, form }) {
async function get_navigation_result_from_branch({
url,
params,
branch,
errors,
status,
error,
route,
form
}) {
/** @type {import('types').TrailingSlash} */
let slash = 'never';

Expand Down Expand Up @@ -666,6 +687,30 @@ function get_navigation_result_from_branch({ url, params, branch, status, error,
}
};

if (errors && __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__) {
let last_idx = -1;
result.props.errors = (
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in an ideal world, we wouldn't need to do all this — the error boundary self-activate. that would involve turning load errors into render errors. how achievable does that sound? (if the answer is 'lol not at all' then that's fine)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

...maybe by making the data prop a getter that throws? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what you mean by this. This is about loading the +error.svelte components. We have to do this here upfront because we can't rely on using await in the template - it's still experimental and will be for a while.

await Promise.all(
branch.map((b, i) => {
if (!b) return null;

// Find the closest error component up to the previous branch
while (i > last_idx && !errors[i]) i -= 1;
last_idx = i;
return errors[i]?.()
.then((e) => e.component)
.catch(() => undefined);
})
)
)
// filter out indexes where there was no branch, but keep indexes where there was a branch but no error component
.filter((e) => e !== null);

if (error) {
result.props.error = error;
}
}

if (form !== undefined) {
result.props.form = form;
}
Expand Down Expand Up @@ -1197,6 +1242,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) {
url,
params,
branch: branch.slice(0, error_load.idx).concat(error_load.node),
errors,
status,
error,
route
Expand All @@ -1216,6 +1262,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) {
url,
params,
branch,
errors,
status: 200,
error: null,
route,
Expand Down Expand Up @@ -1321,6 +1368,7 @@ async function load_root_error_page({ status, error, url, route }) {
branch: [root_layout, root_error],
status,
error,
errors: [],
route: null
});
} catch (error) {
Expand Down Expand Up @@ -2401,12 +2449,13 @@ export async function set_nearest_error_page(error, status = 500) {

const error_load = await load_nearest_error_page(current.branch.length, branch, route.errors);
if (error_load) {
const navigation_result = get_navigation_result_from_branch({
const navigation_result = await get_navigation_result_from_branch({
url,
params: current.params,
branch: branch.slice(0, error_load.idx).concat(error_load.node),
status,
error,
// do not set errors, we haven't changed the page so the previous ones are still current
route
});

Expand Down Expand Up @@ -2829,12 +2878,13 @@ async function _hydrate(
}
}

result = get_navigation_result_from_branch({
result = await get_navigation_result_from_branch({
url,
params,
branch,
status,
error,
errors: parsed_route?.errors, // TODO load earlier?
form,
route: parsed_route ?? null
});
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/runtime/client/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ export type NavigationFinished = {
state: NavigationState;
props: {
constructors: Array<typeof SvelteComponent>;
errors?: Array<typeof SvelteComponent | undefined>;
components?: SvelteComponent[];
page: Page;
form?: Record<string, any> | null;
error?: App.Error;
[key: `data_${number}`]: Record<string, any>;
};
};
Expand Down
33 changes: 30 additions & 3 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ export async function render_page(
},
status,
error,
// TODO error components
branch: layouts.concat({
node,
data: null,
Expand Down Expand Up @@ -337,6 +338,32 @@ export async function render_page(
});
}

/** @type {Array<import('types').SSRComponent | undefined> | undefined} */
let error_components;
if (options.server_error_boundaries && ssr) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

let last_idx = -1;
error_components = (
await Promise.all(
branch.map((b, i) => {
if (!b) return null;

// Find the closest error component up to the previous branch
while (i > last_idx && page.errors[i] === undefined) i -= 1;
last_idx = i;

const idx = page.errors[i];
if (idx == null) return undefined;

return manifest._.nodes[idx]?.()
.then((e) => e.component?.())
.catch(() => undefined);
})
)
)
// filter out indexes where there was no branch, but keep indexes where there was a branch but no error component
.filter((e) => e !== null);
}

return await render_response({
event,
event_state,
Expand All @@ -350,11 +377,11 @@ export async function render_page(
},
status,
error: null,
branch: ssr === false ? [] : compact(branch),
branch: !ssr ? [] : compact(branch),
action_result,
fetched,
data_serializer:
ssr === false ? server_data_serializer(event, event_state, options) : data_serializer
data_serializer: !ssr ? server_data_serializer(event, event_state, options) : data_serializer,
error_components
});
} catch (e) {
// a remote function could have thrown a redirect during render
Expand Down
Loading
Loading