From f9077f6b31926629176396921f7856f100cb2a1f Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 12 Feb 2026 23:43:56 +0100 Subject: [PATCH 01/14] feat: allow error boundaries to catch errors on the server Take advantage of https://github.com/sveltejs/svelte/pull/17672 to add the `handleError` hook as `transformError` so that error boundaries run on the server. Behind an experimental flag. Closes #14808 Closes #14410 Closes #14398 --- .changeset/cyan-walls-leave.md | 5 ++ packages/kit/src/core/config/index.spec.js | 3 +- packages/kit/src/core/config/options.js | 3 +- packages/kit/src/core/sync/sync.js | 2 +- packages/kit/src/core/sync/write_root.js | 32 ++++++++-- packages/kit/src/core/sync/write_server.js | 1 + packages/kit/src/exports/public.d.ts | 8 +++ packages/kit/src/exports/vite/index.js | 3 +- packages/kit/src/runtime/client/client.js | 58 +++++++++++++++++-- packages/kit/src/runtime/client/types.d.ts | 2 + packages/kit/src/runtime/server/page/index.js | 33 ++++++++++- .../kit/src/runtime/server/page/render.js | 25 +++++++- .../runtime/server/page/respond_with_error.js | 1 + packages/kit/src/types/global-private.d.ts | 1 + packages/kit/src/types/internal.d.ts | 1 + .../test/apps/async/src/routes/+error.svelte | 7 ++- .../routes/server-error-boundary/+page.svelte | 5 ++ .../nested/+error.svelte | 5 ++ .../nested/+layout.svelte | 8 +++ .../server-error-boundary/nested/+page.svelte | 5 ++ packages/kit/test/apps/async/svelte.config.js | 3 +- packages/kit/test/apps/async/test/test.js | 14 +++++ packages/kit/types/index.d.ts | 8 +++ 23 files changed, 211 insertions(+), 22 deletions(-) create mode 100644 .changeset/cyan-walls-leave.md create mode 100644 packages/kit/test/apps/async/src/routes/server-error-boundary/+page.svelte create mode 100644 packages/kit/test/apps/async/src/routes/server-error-boundary/nested/+error.svelte create mode 100644 packages/kit/test/apps/async/src/routes/server-error-boundary/nested/+layout.svelte create mode 100644 packages/kit/test/apps/async/src/routes/server-error-boundary/nested/+page.svelte diff --git a/.changeset/cyan-walls-leave.md b/.changeset/cyan-walls-leave.md new file mode 100644 index 000000000000..dc01035227d5 --- /dev/null +++ b/.changeset/cyan-walls-leave.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: allow error boundaries to catch errors on the server diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 14ac3361701b..28b5e0ec9b2e 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -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'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index d16ba4253582..f80f25bb6a59 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -133,7 +133,8 @@ const options = object( server: boolean(false) }), remoteFunctions: boolean(false), - forkPreloads: boolean(false) + forkPreloads: boolean(false), + serverErrorBoundaries: boolean(false) }), files: object({ diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index c77bbca30f27..630af8ed870d 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -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); diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js index 5866b3d8e783..bd3c2d8acd5e 100644 --- a/packages/kit/src/core/sync/write_root.js +++ b/packages/kit/src/core/sync/write_root.js @@ -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 @@ -25,11 +28,30 @@ export function write_root(manifest_data, output) { ${ isSvelte5Plus() ? ` - ` + ` : `` }`; 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}]} + + ${pyramid} + {#snippet failed(error)} + {@const ErrorPage_${l} = errors[${l}]} + + {/snippet} + + {:else} + ${pyramid} + {/if} + `; + } + pyramid = dedent` {#if constructors[${l + 1}]} ${ @@ -37,7 +59,7 @@ export function write_root(manifest_data, output) { ? dedent`{@const Pyramid_${l} = constructors[${l}]} - ${pyramid} + ${children} ` : dedent` ${pyramid} @@ -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(); ` @@ -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(); }); ` diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index ea7d9dfe0c99..b9370d234eb0 100644 --- a/packages/kit/src/core/sync/write_server.js +++ b/packages/kit/src/core/sync/write_server.js @@ -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 + "') diff --git a/packages/kit/src/exports/public.d.ts b/packages/kit/src/exports/public.d.ts index c6bc1dcce8df..c714b90e1e42 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -512,6 +512,14 @@ export interface KitConfig { * @default false */ forkPreloads?: boolean; + + /** + * Whether to enable the experimental server error boundaries feature. + * When enabled, `` 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. diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 1f1adb8aa312..50f95073f927 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -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) { diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index c410814399ee..1a2c72efd285 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -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, @@ -625,13 +636,23 @@ async function initialize(result, target, hydrate) { * url: URL; * params: Record; * branch: Array; + * errors?: Array; * status: number; * error: App.Error | null; * route: import('types').CSRRoute | null; * form?: Record | 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'; @@ -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 = ( + 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; } @@ -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 @@ -1216,6 +1262,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) { url, params, branch, + errors, status: 200, error: null, route, @@ -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) { @@ -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 }); @@ -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 }); diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 927b5b7a1041..f91124f962c7 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -85,9 +85,11 @@ export type NavigationFinished = { state: NavigationState; props: { constructors: Array; + errors?: Array; components?: SvelteComponent[]; page: Page; form?: Record | null; + error?: App.Error; [key: `data_${number}`]: Record; }; }; diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index 33afd6ce0ba1..aa33c3378d92 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -299,6 +299,7 @@ export async function render_page( }, status, error, + // TODO error components branch: layouts.concat({ node, data: null, @@ -337,6 +338,32 @@ export async function render_page( }); } + /** @type {Array | undefined} */ + let error_components; + if (options.server_error_boundaries && ssr) { + 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, @@ -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 diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 4a6b80be0373..468a36307851 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -17,6 +17,7 @@ import { try_get_request_store, with_request_store } from '@sveltejs/kit/interna import { text_encoder } from '../../utils.js'; import { get_global_name } from '../utils.js'; import { create_remote_key } from '../../shared.js'; +import { get_message, get_status } from '../../../utils/error.js'; // TODO rename this function/module @@ -40,7 +41,8 @@ const updated = { * event_state: import('types').RequestState; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('@sveltejs/kit').ActionResult; - * data_serializer: import('./types.js').ServerDataSerializer + * data_serializer: import('./types.js').ServerDataSerializer; + * error_components?: Array * }} opts */ export async function render_response({ @@ -56,7 +58,8 @@ export async function render_response({ event_state, resolve_opts, action_result, - data_serializer + data_serializer, + error_components }) { if (state.prerendering) { if (options.csp.mode === 'nonce') { @@ -147,6 +150,13 @@ export async function render_response({ form: form_value }; + if (error_components) { + if (error) { + props.error = error; + } + props.errors = error_components; + } + let data = {}; // props_n (instead of props[n]) makes it easy to avoid @@ -176,7 +186,16 @@ export async function render_response({ } ] ]), - csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash } + csp: csp.script_needs_nonce ? { nonce: csp.nonce } : { hash: csp.script_needs_hash }, + transformError: error_components + ? /** @param {unknown} error */ (error) => + options.hooks.handleError({ + error, + event, + status: get_status(error), + message: get_message(error) + }) + : undefined }; const fetch = globalThis.fetch; diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index 0767e177d124..71717e209c4b 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -101,6 +101,7 @@ export async function respond_with_error({ status, error: await handle_error_and_jsonify(event, event_state, options, error), branch, + error_components: [], fetched, event, event_state, diff --git a/packages/kit/src/types/global-private.d.ts b/packages/kit/src/types/global-private.d.ts index 94a63ea2e010..c08d43b3de64 100644 --- a/packages/kit/src/types/global-private.d.ts +++ b/packages/kit/src/types/global-private.d.ts @@ -57,6 +57,7 @@ declare global { * to throw an error if the feature would fail in production. */ var __SVELTEKIT_TRACK__: (label: string) => void; + var __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__: boolean; var Bun: object; var Deno: object; } diff --git a/packages/kit/src/types/internal.d.ts b/packages/kit/src/types/internal.d.ts index 66f4fb30a3ea..236f771022ae 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -463,6 +463,7 @@ export interface SSROptions { root: SSRComponent['default']; service_worker: boolean; service_worker_options: RegistrationOptions; + server_error_boundaries: boolean; templates: { app(values: { head: string; diff --git a/packages/kit/test/apps/async/src/routes/+error.svelte b/packages/kit/test/apps/async/src/routes/+error.svelte index 61ad908c0b25..aa74e3a420a2 100644 --- a/packages/kit/test/apps/async/src/routes/+error.svelte +++ b/packages/kit/test/apps/async/src/routes/+error.svelte @@ -1,14 +1,17 @@ - Custom error page: {page.error.message} + Custom error page: {error.message} +

{page.status}

-

This is your custom error page saying: "{page.error.message}"

+

This is your custom error page saying: "{error.message}"