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/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md index 17d7eabc8268..330f39cbfe50 100644 --- a/documentation/docs/30-advanced/25-errors.md +++ b/documentation/docs/30-advanced/25-errors.md @@ -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 ` + +

{error.message}

+``` + +The same applies for other error boundaries you define in your code: + +```svelte + + ... + {#snippet failed(error)} + + {error.message} + {/snippet} + +``` + ## 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. @@ -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'): diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 14ac3361701b..6b0e9808affc 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, + handleRenderingErrors: 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..5734e6abea78 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), + handleRenderingErrors: boolean(false) }), files: object({ diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index d8643e989128..d9680e8ab65f 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -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); diff --git a/packages/kit/src/core/sync/write_root.js b/packages/kit/src/core/sync/write_root.js index 5866b3d8e783..445c17e45dbc 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.handleRenderingErrors && isSvelte5Plus(); + const max_depth = Math.max( ...manifest_data.routes.map((route) => route.page ? route.page.layouts.filter(Boolean).length + 1 : 0 @@ -20,8 +23,38 @@ 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]} + + {/snippet} + + {#if constructors[depth + 1]} + {@const d = data[depth]} + + + {@render pyramid(depth + 1)} + + {:else} + {@const d = data[depth]} + + + {/if} + + {/snippet} + + {@render pyramid(0)} + `; + } else { + pyramid = dedent` ${ isSvelte5Plus() ? ` @@ -29,8 +62,8 @@ export function write_root(manifest_data, output) { : `` }`; - while (l--) { - pyramid = dedent` + while (l--) { + pyramid = dedent` {#if constructors[${l + 1}]} ${ isSvelte5Plus() @@ -57,6 +90,7 @@ export function write_root(manifest_data, output) { {/if} `; + } } write_if_changed( @@ -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; @@ -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(); }); ` diff --git a/packages/kit/src/core/sync/write_server.js b/packages/kit/src/core/sync/write_server.js index ea7d9dfe0c99..711a007345e0 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.handleRenderingErrors)}, 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..0e4d5a512b2f 100644 --- a/packages/kit/src/exports/public.d.ts +++ b/packages/kit/src/exports/public.d.ts @@ -512,6 +512,15 @@ export interface KitConfig { * @default false */ forkPreloads?: boolean; + + /** + * Whether to enable the experimental handling of rendering errors. + * When enabled, `` 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. diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 95881be7aa86..1d8939798c00 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -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) { diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 0faada3d4b01..2d2631721298 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -238,12 +238,14 @@ const on_navigate_callbacks = new Set(); /** @type {Set<(navigation: import('@sveltejs/kit').AfterNavigate) => void>} */ const after_navigate_callbacks = new Set(); -/** @type {import('./types.js').NavigationState} */ +/** @type {import('./types.js').NavigationState & { nav: import('@sveltejs/kit').NavigationEvent }} */ let current = { branch: [], error: null, // @ts-ignore - we need the initial value to be null - url: null + url: null, + // @ts-ignore - we need the initial value to be null + nav: null }; /** this being true means we SSR'd */ @@ -415,7 +417,7 @@ async function _invalidate(include_load_functions = true, reset_page_state = tru navigation_result.props.page.state = prev_state; } update(navigation_result.props.page); - current = navigation_result.state; + current = { ...navigation_result.state, nav: current.nav }; reset_invalidation(); root.$set(navigation_result.props); } else { @@ -581,7 +583,17 @@ async function _preload_code(url) { async function initialize(result, target, hydrate) { if (DEV && result.state.error && document.querySelector('vite-error-overlay')) return; - current = result.state; + /** @type {import('@sveltejs/kit').NavigationEvent} */ + const nav = { + params: current.params, + route: { id: current.route?.id ?? null }, + url: new URL(location.href) + }; + + current = { + ...result.state, + nav + }; const style = document.querySelector('style[data-sveltekit]'); if (style) style.remove(); @@ -593,7 +605,11 @@ 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) => handle_error(error, current.nav) + : undefined }); // Wait for a microtask in case svelte experimental async is enabled, @@ -607,9 +623,7 @@ async function initialize(result, target, hydrate) { const navigation = { from: null, to: { - params: current.params, - route: { id: current.route?.id ?? null }, - url: new URL(location.href), + ...nav, scroll: scroll_positions[current_history_index] ?? scroll_state() }, willUnload: false, @@ -629,13 +643,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'; @@ -670,6 +694,32 @@ 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( + // eslint-disable-next-line @typescript-eslint/await-thenable + branch + .map((b, i) => { + if (i === 0) return undefined; // root layout wraps root error component, not the other way around + if (!b) return null; + + i--; + // Find the closest error component up to the previous branch + while (i > last_idx + 1 && !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 && __SVELTEKIT_EXPERIMENTAL_USE_TRANSFORM_ERROR__) { + result.props.error = error; + } + if (form !== undefined) { result.props.form = form; } @@ -1201,6 +1251,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 @@ -1220,6 +1271,7 @@ async function load_route({ id, invalidating, url, params, route, preload }) { url, params, branch, + errors, status: 200, error: null, route, @@ -1325,6 +1377,7 @@ async function load_root_error_page({ status, error, url, route }) { branch: [root_layout, root_error], status, error, + errors: [], route: null }); } catch (error) { @@ -1732,7 +1785,16 @@ async function navigate({ }); } - current = navigation_result.state; + // Type-casts are save because we know this resolved a proper SvelteKit route + const target = /** @type {import('@sveltejs/kit').NavigationTarget} */ (nav.navigation.to); + current = { + ...navigation_result.state, + nav: { + params: /** @type {Record} */ (target.params), + route: target.route, + url: target.url + } + }; // reset url before updating page store if (navigation_result.props.page) { @@ -2401,16 +2463,17 @@ 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 }); - current = navigation_result.state; + current = { ...navigation_result.state, nav: current.nav }; root.$set(navigation_result.props); update(navigation_result.props.page); @@ -2829,12 +2892,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..f5bb852a68aa 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -285,6 +285,11 @@ export async function render_page( const layouts = compact(branch.slice(0, j + 1)); const nodes = new PageNodes(layouts.map((layout) => layout.node)); + const error_branch = layouts.concat({ + node, + data: null, + server_data: null + }); return await render_response({ event, @@ -299,11 +304,14 @@ export async function render_page( }, status, error, - branch: layouts.concat({ - node, - data: null, - server_data: null - }), + error_components: await load_error_components( + options, + ssr, + error_branch, + page, + manifest + ), + branch: error_branch, fetched, data_serializer }); @@ -350,11 +358,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: await load_error_components(options, ssr, branch, page, manifest) }); } catch (e) { // a remote function could have thrown a redirect during render @@ -376,3 +384,44 @@ export async function render_page( }); } } + +/** + * + * @param {import('types').SSROptions} options + * @param {boolean} ssr + * @param {Array} branch + * @param {import('types').PageNodeIndexes} page + * @param {import('@sveltejs/kit').SSRManifest} manifest + */ +async function load_error_components(options, ssr, branch, page, manifest) { + /** @type {Array | undefined} */ + let error_components; + + if (options.server_error_boundaries && ssr) { + let last_idx = -1; + error_components = await Promise.all( + // eslint-disable-next-line @typescript-eslint/await-thenable + branch + .map((b, i) => { + if (i === 0) return undefined; // root layout wraps root error component, not the other way around + if (!b) return null; + + i--; + // Find the closest error component up to the previous branch + while (i > last_idx + 1 && 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 error_components; +} 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 3c761b2635f9..7835bf96418c 100644 --- a/packages/kit/src/types/internal.d.ts +++ b/packages/kit/src/types/internal.d.ts @@ -468,6 +468,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/hooks.client.js b/packages/kit/test/apps/async/src/hooks.client.js new file mode 100644 index 000000000000..af343e2d1022 --- /dev/null +++ b/packages/kit/test/apps/async/src/hooks.client.js @@ -0,0 +1,13 @@ +import { isRedirect } from '@sveltejs/kit'; + +/** @type {import('@sveltejs/kit').HandleClientError} */ +export const handleError = ({ error: e, event, status, message }) => { + // helps us catch sveltekit redirects thrown in component code + if (isRedirect(e)) { + throw new Error("Redirects shouldn't trigger the handleError hook"); + } + + const error = /** @type {Error} */ (e); + + return { message: `${error.message} (${status} ${message}, on ${event.url.pathname})` }; +}; diff --git a/packages/kit/test/apps/async/src/hooks.server.js b/packages/kit/test/apps/async/src/hooks.server.js index 943f66f48a40..8cd4c20ccac1 100644 --- a/packages/kit/test/apps/async/src/hooks.server.js +++ b/packages/kit/test/apps/async/src/hooks.server.js @@ -6,7 +6,7 @@ export const handleValidationError = ({ issues }) => { }; /** @type {import('@sveltejs/kit').HandleServerError} */ -export const handleError = ({ error: e, status, message }) => { +export const handleError = ({ error: e, event, status, message }) => { // helps us catch sveltekit redirects thrown in component code if (isRedirect(e)) { throw new Error("Redirects shouldn't trigger the handleError hook"); @@ -14,7 +14,7 @@ export const handleError = ({ error: e, status, message }) => { const error = /** @type {Error} */ (e); - return { message: `${error.message} (${status} ${message})` }; + return { message: `${error.message} (${status} ${message}, on ${event.url.pathname})` }; }; // @ts-ignore this doesn't exist in old Node TODO remove SvelteKit 3 (same in test-basics) 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}"