Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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/quiet-error-styles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: include error page assets when rendering fails during SSR
10 changes: 4 additions & 6 deletions packages/kit/src/runtime/error-chain.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,16 @@
/** @import { Component } from 'svelte'; */

/**
* Resolves the `+error.svelte` component that guards each node of `branch`, aligned to the
* branch with its empty slots removed. A node is guarded by the closest error page declared
* at or above it, except the root layout, which wraps the root error component rather than
* being wrapped by it.
* @template T
* @template T, U
* @param {Array<unknown>} branch
* @param {Array<T | undefined | null>} errors the error page declared at each depth, if any
* @param {(error: T) => Promise<Component | undefined> | undefined} load
* @returns {Promise<Array<Component | undefined>>}
* @param {(error: T) => Promise<U | undefined> | undefined} load
* @returns {Promise<Array<U | undefined>>}
*/
export function build_error_chain(branch, errors, load) {
/** @type {Array<Promise<Component | undefined> | undefined>} */
/** @type {Array<Promise<U | undefined> | undefined>} */
const chain = [undefined];
let last_idx = -1;

Expand Down
5 changes: 4 additions & 1 deletion packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -371,6 +371,9 @@ function load_error_components(ssr, branch, page) {
if (!ssr) return undefined;

return build_error_chain(branch, page.errors, (idx) =>
manifest.nodes[idx]?.().then((e) => e.component?.())
manifest.nodes[idx]?.().then(async (node) => {
const component = await node.component?.();
return component && { node, component };
})
);
}
22 changes: 19 additions & 3 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import * as w from '../../../messages/server-warnings.js';
* resolve_opts: import('types').RequiredResolveOptions;
* action_result?: import('types').ServerActionResult;
* data_serializer: import('./types.js').ServerDataSerializer;
* error_components?: Array<import('svelte').Component | undefined>
* error_components?: Array<{ node: import('types').SSRNode; component: Component } | undefined>
* }} opts
*/
export async function render_response({
Expand All @@ -80,6 +80,7 @@ export async function render_response({
}

const client = manifest.client;
const nodes = new Set(branch.map(({ node }) => node));

const modulepreloads = new Set(client?.imports);
const stylesheets = new Set(client?.stylesheets);
Expand Down Expand Up @@ -140,6 +141,21 @@ export async function render_response({
}

if (page_config.ssr) {
const errors = error_components?.map((error) => {
if (!error) return undefined;

const { node, component: error_component } = error;

// Error pages aren't in the loaded branch. Collect their dependencies only if rendered.
/** @type {Component} */
const component = (...args) => {
nodes.add(node);
return error_component(...args);
};

return component;
});

const page = {
error,
params: /** @type {Record<string, any>} */ (event.params),
Expand Down Expand Up @@ -177,7 +193,7 @@ export async function render_response({
current_node = current_node.child = new RenderNode(
// TODO tidy up
/** @type {Component} */ (await branch[i + 1].node.component?.()),
error_components?.[i + 1]
errors?.[i + 1]
);
}
}
Expand Down Expand Up @@ -256,7 +272,7 @@ export async function render_response({
rendered = { head: '', body: '', hashes: { script: [] } };
}

for (const { node } of branch) {
for (const node of nodes) {
for (const url of node.imports) modulepreloads.add(url);
for (const url of node.stylesheets) stylesheets.add(url);
for (const font of node.fonts) fonts.set(font.file, font);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
<script>
import { page } from '$app/state';
/** @type {App.Error} */
export let error;
if (page.url.searchParams.has('fail_error')) {
// @ts-expect-error compiled components should be called without a `this` binding
if (this !== undefined) {
throw new Error('error component was called with a bound `this`');
}
throw new Error(`nested error render failed: ${error.message}`);
}
</script>

<h1>Nested error page</h1>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
function fail() {
throw new Error('render failed');
}
</script>

<p>{fail()}</p>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<script>
function fail() {
throw new Error('render failed');
}
</script>

<p>{fail()}</p>
49 changes: 49 additions & 0 deletions packages/kit/test/apps/basics/test/playwright/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,55 @@ test.describe('Endpoints', () => {
});

test.describe('Errors', () => {
for (const [route, color] of [
['/errors/render', 'rgb(255, 0, 0)'],
['/errors/nested-error-page/render', 'rgb(0, 0, 255)']
]) {
test(`includes error page CSS after an SSR render error at ${route}`, async ({ page }) => {
const response = await page.goto(route);
expect(response?.status()).toBe(500);
await expect(page.locator('h1')).toHaveCSS('color', color);
});
}

test('includes ancestor error page CSS when a nested error page fails to render', async ({
page
}) => {
const response = await page.goto('/errors/nested-error-page/render?fail_error');
expect(response?.status()).toBe(500);
await expect(page.locator('h1')).toHaveText('500');
await expect(page.locator('h1')).toHaveCSS('color', 'rgb(255, 0, 0)');
await expect(page.locator('#message')).toContainText(
'nested error render failed: render failed (500 Internal Error)'
);
const error_class = await page.locator('h1').getAttribute('class');
expect(error_class).toBeTruthy();

// Dependencies collected during a render error must not leak into later responses.
await page.goto('/errors/nested-error-page');
const css = await page.evaluate(() =>
Array.from(document.styleSheets)
.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText))
.join('\n')
);
expect(css).not.toContain(error_class);
});

test('only includes CSS for error pages that are rendered', async ({ page }) => {
await page.goto('/errors/nested-error-page/nope');
await expect(page.locator('h1')).toHaveCSS('color', 'rgb(0, 0, 255)');
const error_class = await page.locator('h1').getAttribute('class');
expect(error_class).toBeTruthy();

await page.goto('/errors/nested-error-page');
const css = await page.evaluate(() =>
Array.from(document.styleSheets)
.flatMap((sheet) => Array.from(sheet.cssRules, (rule) => rule.cssText))
.join('\n')
);
expect(css).not.toContain(error_class);
});

test('returns 400 when accessing a malformed URI', async ({ page }) => {
const response = await page.goto('/%c0%ae%c0%ae/etc/passwd');
if (process.env.DEV) {
Expand Down
Loading