diff --git a/.changeset/rude-tomatoes-thank.md b/.changeset/rude-tomatoes-thank.md new file mode 100644 index 000000000000..3185b3851b65 --- /dev/null +++ b/.changeset/rude-tomatoes-thank.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Allow non-root \$error.svelte components diff --git a/documentation/docs/02-layouts.md b/documentation/docs/02-layouts.md index 36a06a7bd0e3..84b653c62247 100644 --- a/documentation/docs/02-layouts.md +++ b/documentation/docs/02-layouts.md @@ -62,17 +62,32 @@ We can create a layout that only applies to pages below `/settings` (while inher ``` - ### Error pages -If your page fails to load (see [Loading](#loading)), SvelteKit will render an error page. You can customise this page by creating a file called `src/routes/$error.svelte`, which is a component that receives an `error` prop alongside a `status` code: +If a page fails to load (see [Loading](#loading)), SvelteKit will render an error page. You can customise this page by creating `$error.svelte` components alongside your layout and page components. + +For example, if `src/routes/settings/notifications/index.svelte` failed to load, SvelteKit would render `src/routes/settings/notifications/$error.svelte` in the same layout, if it existed. If not, it would render `src/routes/settings/$error.svelte` in the parent layout, or `src/routes/$error.svelte` in the root layout. + +> SvelteKit provides a default error page in case you don't supply `src/routes/$error.svelte`, but it's recommend that you bring your own. + +If an error component has a [`load`](#loading) function, it will be called with `error` and `status` properties: ```html + + -

{status}

-

{error.message}

-``` \ No newline at end of file +

{title}

+``` + +> Server-side stack traces will be removed from `error` in production, to avoid exposing privileged information to users. diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 23980c2da5a8..9a69c8cb8467 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -281,7 +281,8 @@ async function build_server( type: 'page', pattern: ${route.pattern}, params: ${params}, - parts: [${route.parts.map(file => s(file)).join(', ')}] + a: [${route.a.map(file => file && s(file)).join(', ')}], + b: [${route.b.map(file => file && s(file)).join(', ')}] }`; } else { const params = get_params(route.params); diff --git a/packages/kit/src/core/create_app/index.js b/packages/kit/src/core/create_app/index.js index acc9188b8893..cd361da5b339 100644 --- a/packages/kit/src/core/create_app/index.js +++ b/packages/kit/src/core/create_app/index.js @@ -65,6 +65,10 @@ function generate_client_manifest(manifest_data, base) { .join(',\n\t\t\t\t')} ]`.replace(/^\t/gm, ''); + /** @param {string[]} parts */ + const get_indices = (parts) => + `[${parts.map((part) => (part ? `c[${component_indexes[part]}]` : '')).join(', ')}]`; + const routes = `[ ${manifest_data.routes .map((route) => { @@ -81,15 +85,10 @@ function generate_client_manifest(manifest_data, base) { .join(', ') + '})'; - const tuple = [ - route.pattern, - `[${route.parts.map((part) => `components[${component_indexes[part]}]`).join(', ')}]`, - params - ] - .filter(Boolean) - .join(', '); + const tuple = [route.pattern, get_indices(route.a), get_indices(route.b)]; + if (params) tuple.push(params); - return `// ${route.parts[route.parts.length - 1]}\n\t\t[${tuple}]`; + return `// ${route.a[route.a.length - 1]}\n\t\t[${tuple.join(', ')}]`; } else { return `// ${route.file}\n\t\t[${route.pattern}]`; } @@ -98,13 +97,13 @@ function generate_client_manifest(manifest_data, base) { ]`.replace(/^\t/gm, ''); return trim(` - const components = ${components}; + const c = ${components}; const d = decodeURIComponent; export const routes = ${routes}; - export const fallback = [components[0](), components[1]()]; + export const fallback = [c[0](), c[1]()]; `); } @@ -117,7 +116,7 @@ function generate_app(manifest_data, base) { const max_depth = Math.max( ...manifest_data.routes.map((route) => - route.type === 'page' ? route.parts.filter(Boolean).length : 0 + route.type === 'page' ? route.a.filter(Boolean).length : 0 ) ); @@ -132,9 +131,7 @@ function generate_app(manifest_data, base) { while (l--) { pyramid = ` - + {#if components[${l + 1}]} ${pyramid.replace(/\n/g, '\n\t\t\t\t\t')} {/if} @@ -149,10 +146,6 @@ function generate_app(manifest_data, base) { + diff --git a/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/$error.svelte b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/$error.svelte new file mode 100644 index 000000000000..7614107e4c89 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/$error.svelte @@ -0,0 +1,33 @@ + + + + +

Nested error page

+

status: {status}

+

error.message: {error && error.message}

+

answer: {answer}

+ + \ No newline at end of file diff --git a/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/__tests__.js b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/__tests__.js new file mode 100644 index 000000000000..ea6f08fe5755 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/__tests__.js @@ -0,0 +1,16 @@ +import * as assert from 'uvu/assert'; + +/** @type {import('../../../../../../types').TestMaker} */ +export default function (test, is_dev) { + test( + 'renders the closest error page', + '/errors/nested-error-page', + async ({ page, clicknav }) => { + await clicknav('[href="/errors/nested-error-page/nope"]'); + + assert.equal(await page.textContent('h1'), 'Nested error page'); + assert.equal(await page.textContent('#nested-error-status'), 'status: 500'); + assert.equal(await page.textContent('#nested-error-message'), 'error.message: nope'); + } + ); +} diff --git a/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/index.svelte b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/index.svelte new file mode 100644 index 000000000000..cab115c70cae --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/index.svelte @@ -0,0 +1 @@ +nope \ No newline at end of file diff --git a/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/nope.svelte b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/nope.svelte new file mode 100644 index 000000000000..5b579b81f460 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/nested-error-page/nope.svelte @@ -0,0 +1,7 @@ + + +

should not see this

\ No newline at end of file diff --git a/packages/kit/test/apps/basics/src/routes/redirect/__tests__.js b/packages/kit/test/apps/basics/src/routes/redirect/__tests__.js index e3965b4e2a2e..0c081b595812 100644 --- a/packages/kit/test/apps/basics/src/routes/redirect/__tests__.js +++ b/packages/kit/test/apps/basics/src/routes/redirect/__tests__.js @@ -9,7 +9,7 @@ export default function (test, is_dev) { assert.equal(await page.textContent('h1'), 'c'); }); - test('prevents redirect loops', '/redirect', async ({ base, page, clicknav, js }) => { + test('prevents redirect loops', '/redirect', async ({ base, page, js }) => { await page.click('[href="/redirect/loopy/a"]'); if (js) { diff --git a/packages/kit/test/test.js b/packages/kit/test/test.js index f1513bc0e495..cf489df2c92b 100644 --- a/packages/kit/test/test.js +++ b/packages/kit/test/test.js @@ -118,6 +118,8 @@ function duplicate(test_fn, config) { start = null; } + if (process.env.FILTER && !name.includes(process.env.FILTER)) return; + test_fn(`${name} [no js]`, async (context) => { let response; diff --git a/packages/kit/types.d.ts b/packages/kit/types.d.ts index 2fa43cbbf724..3005db791638 100644 --- a/packages/kit/types.d.ts +++ b/packages/kit/types.d.ts @@ -1,5 +1,5 @@ import './types.ambient'; -import { Headers, LoadInput, LoadOutput, Logger } from './types.internal'; +import { Headers, ErrorLoadInput, LoadInput, LoadOutput, Logger } from './types.internal'; import { UserConfig as ViteConfig } from 'vite'; export type Config = { @@ -96,6 +96,8 @@ export type RequestHandler = ( export type Load = (input: LoadInput) => LoadOutput | Promise; +export type ErrorLoad = (input: ErrorLoadInput) => LoadOutput | Promise; + export type GetContext = (incoming: Incoming) => Context; export type GetSession = { diff --git a/packages/kit/types.internal.d.ts b/packages/kit/types.internal.d.ts index 6a2b05b40f79..49115bf3fa23 100644 --- a/packages/kit/types.internal.d.ts +++ b/packages/kit/types.internal.d.ts @@ -7,7 +7,7 @@ import { Load, Page, RequestHandler, - Response + Response as SSRResponse } from './types'; import { UserConfig as ViteConfig } from 'vite'; import { Response as NodeFetchResponse } from 'node-fetch'; @@ -77,7 +77,7 @@ export type App = { }; prerendering: boolean; }) => void; - render: (incoming: Incoming, options: SSRRenderOptions) => Response; + render: (incoming: Incoming, options: SSRRenderOptions) => SSRResponse; }; // TODO we want to differentiate between request headers, which @@ -88,16 +88,21 @@ export type Headers = Record; export type LoadInput = { page: Page; - fetch: (info: RequestInfo, init?: RequestInit) => Promise; + fetch: (info: RequestInfo, init?: RequestInit) => Promise; session: any; context: Record; }; +export type ErrorLoadInput = LoadInput & { + status: number; + error: Error; +}; + export type LoadOutput = { status?: number; error?: Error; redirect?: string; - props?: Record; + props?: Record | Promise>; context?: Record; maxage?: number; }; @@ -137,7 +142,12 @@ export type SSRPage = { type: 'page'; pattern: RegExp; params: GetParams; - parts: PageId[]; + // plan a is to render 1 or more layout components followed + // by a leaf component. if one of them fails in `load`, we + // backtrack until we find the nearest error component — + // plan b — and render that instead + a: PageId[]; + b: PageId[]; }; export type SSREndpoint = { @@ -151,7 +161,7 @@ export type SSREndpoint = { export type SSRRoute = SSREndpoint | SSRPage; -export type CSRPage = [RegExp, CSRComponentLoader[], GetParams?]; +export type CSRPage = [RegExp, CSRComponentLoader[], CSRComponentLoader[], GetParams?]; export type CSREndpoint = [RegExp]; @@ -194,7 +204,7 @@ export type SSRRenderOptions = { hooks?: Hooks; dev?: boolean; amp?: boolean; - dependencies?: Map; + dependencies?: Map; only_render_prerenderable_pages?: boolean; get_stack?: (error: Error) => string; get_static_file?: (file: string) => Buffer; @@ -215,7 +225,8 @@ export type PageData = { type: 'page'; pattern: RegExp; params: string[]; - parts: any[]; // TODO + a: string[]; + b: string[]; }; export type EndpointData = {