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/quick-error-preloads.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: preload error page modules required for client rendering
28 changes: 18 additions & 10 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,8 @@ export async function render_page(event, state, page, nodes, resolve_opts) {
event,
state,
resolve_opts,
data_serializer: server_data_serializer(event, state)
data_serializer: server_data_serializer(event, state),
...(csr ? await load_error_components(false, nodes.data, page) : {})
});
}

Expand Down Expand Up @@ -295,7 +296,7 @@ export async function render_page(event, state, page, nodes, resolve_opts) {
},
status,
error,
error_components: await load_error_components(ssr, error_branch, page),
...(await load_error_components(ssr, error_branch, page)),
branch: error_branch,
fetched,
data_serializer
Expand Down Expand Up @@ -343,7 +344,7 @@ export async function render_page(event, state, page, nodes, resolve_opts) {
action_result,
fetched,
data_serializer: !ssr ? server_data_serializer(event, state) : data_serializer,
error_components: await load_error_components(ssr, branch, page)
...(await load_error_components(ssr, branch, page))
});
} catch (e) {
// a remote function could have thrown a redirect during render
Expand All @@ -364,13 +365,20 @@ export async function render_page(event, state, page, nodes, resolve_opts) {

/**
* @param {boolean} ssr
* @param {Array<import('./types.js').Loaded | null>} branch
* @param {Array<unknown>} branch
* @param {PageNodeIndexes} page
*/
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?.())
);
async function load_error_components(ssr, branch, page) {
/** @type {SSRNode[]} */
const error_nodes = [];
const error_components = await build_error_chain(branch, page.errors, async (idx) => {
const node = await manifest.nodes[idx]?.();
if (!node) return undefined;

error_nodes.push(node);
// Without SSR, only load the metadata: the component may use browser-only globals.
return ssr ? node.component?.() : undefined;
});

return { error_components: ssr ? error_components : undefined, error_nodes };
}
17 changes: 14 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,8 @@ 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<import('svelte').Component | undefined>;
* error_nodes?: import('types').SSRNode[]
* }} opts
*/
export async function render_response({
Expand All @@ -67,7 +68,8 @@ export async function render_response({
resolve_opts,
action_result,
data_serializer,
error_components
error_components,
error_nodes = []
}) {
if (state.prerendering || state.prerender_default === true) {
if (options.csp.mode === 'nonce') {
Expand Down Expand Up @@ -257,7 +259,6 @@ export async function render_response({
}

for (const { node } of branch) {
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 Expand Up @@ -367,6 +368,16 @@ export async function render_response({
}

if (!client.inline) {
// The client awaits the root error page and the route's error chain before rendering.
// Only preload their JS; unused error-page styles must not affect a successful page.
const preload_nodes = new Map(branch.map(({ node }) => [node.index, node]));
for (const node of error_nodes) preload_nodes.set(node.index, node);
if (!preload_nodes.has(1)) preload_nodes.set(1, await manifest.nodes[1]());

for (const node of preload_nodes.values()) {
for (const url of node.imports) modulepreloads.add(url);
}

for (const dep of modulepreloads) {
const path = prefixed(dep);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script module>
const title = document.title;
</script>

<h1>{title}</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>No SSR with a browser-only error page</h1>
37 changes: 37 additions & 0 deletions packages/kit/test/apps/basics/test/playwright/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,43 @@ test.describe('a11y', () => {
});
});

test.describe('Hydration preloads', () => {
for (const path of [
'/',
'/errors/nested-error-page',
'/errors/nested-error-page/nope',
'/non-existent-route',
'/no-ssr',
'/no-ssr/error-preload',
'/prerendering/env/prerendered'
]) {
test(`preloads error modules before hydration on ${path}`, async ({ page }) => {
test.skip(!!process.env.DEV, 'modulepreloads are emitted in production builds');

/** @type {Set<string>} */
const modules = new Set();
page.on('request', (request) => {
if (/\/_app\/immutable\/(nodes|chunks)\/.*\.js$/.test(request.url())) {
modules.add(request.url());
}
});

const response = await page.goto(path);
const html = await /** @type {Response} */ (response).text();
// Read the original response, not the DOM: Vite inserts preload links during startup.
const preloads = [...html.matchAll(/<link href="([^"]+)" rel="modulepreload">/g)].map(
([, href]) => new URL(href, page.url()).href
);

expect([...modules].some((url) => /\/nodes\/1\./.test(url))).toBe(true);
for (const url of modules) {
expect(preloads).toContain(url);
}
expect(new Set(preloads).size).toBe(preloads.length);
});
}
});

test.describe('Caching', () => {
test('caches __data.json requests with invalidated search param', async ({ page, app }) => {
await page.goto('/');
Expand Down
28 changes: 28 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,34 @@ test.describe('Endpoints', () => {
});

test.describe('Errors', () => {
test('does not include unused error-page styles in successful SSR responses', async ({
request
}) => {
test.skip(!!process.env.DEV, 'production builds emit stylesheet links');

/** @param {import('@playwright/test').APIResponse} response */
const stylesheets = async (response) =>
[...(await response.text()).matchAll(/<link href="([^"]+)" rel="stylesheet"[^>]*>/g)].map(
([, href]) => new URL(href, response.url()).href
);

// These successful pages share their layouts and have no page-local CSS. Only the
// second has a nested +error.svelte with styles, which must not be linked until used.
const baseline = await request.get('/errors/load-client');
const success = await request.get('/errors/nested-error-page');
expect(baseline.status()).toBe(200);
expect(success.status()).toBe(200);
const layout_styles = await stylesheets(baseline);
expect(layout_styles.length).toBeGreaterThan(0);
expect(await stylesheets(success)).toEqual(layout_styles);

const failure = await request.get('/errors/nested-error-page/nope');
expect(failure.status()).toBe(500);
const error_styles = await stylesheets(failure);
for (const href of layout_styles) expect(error_styles).toContain(href);
expect(error_styles).toHaveLength(layout_styles.length + 1);
});

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