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 = {