Skip to content

Commit 2e6527b

Browse files
authored
fix: load CSS when using server-side route resolution (#13498)
When implementing server-side route resolution we didn't think of the CSS associated with the entry points of the pages. Since Vite isn't involved in that chain, it can't automatically wire up the imports correctly, so we gotta add code to load them ourselves. Fixes #13491
1 parent 51fc810 commit 2e6527b

File tree

11 files changed

+130
-12
lines changed

11 files changed

+130
-12
lines changed

Diff for: .changeset/stupid-clocks-turn.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
fix: load CSS when using server-side route resolution

Diff for: packages/adapter-netlify/index.js

+1-1
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ async function generate_edge_functions({ builder }) {
143143
writeFileSync(`${tmp}/manifest.js`, `export const manifest = ${manifest};\n`);
144144

145145
/** @type {{ assets: Set<string> }} */
146-
// we have to prepend the file:// protocol because Windows doesn't support absolute path imports
146+
// we have to prepend the file:// protocol because Windows doesn't support absolute path imports
147147
const { assets } = (await import(`file://${tmp}/manifest.js`)).manifest;
148148

149149
const path = '/*';

Diff for: packages/kit/src/exports/vite/dev/index.js

+1
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ export async function dev(vite, vite_config, svelte_config) {
145145
return `${svelte_config.kit.paths.base}${to_fs(svelte_config.kit.outDir)}/generated/client/nodes/${i}.js`;
146146
}
147147
}),
148+
// `css` is not necessary in dev, as the JS file from `nodes` will reference the CSS file
148149
routes:
149150
svelte_config.kit.router.resolution === 'client'
150151
? undefined

Diff for: packages/kit/src/exports/vite/index.js

+14-4
Original file line numberDiff line numberDiff line change
@@ -866,8 +866,12 @@ Tips:
866866
/** @type {import('vite').Manifest} */
867867
const client_manifest = JSON.parse(read(`${out}/client/${vite_config.build.manifest}`));
868868

869-
const deps_of = /** @param {string} f */ (f) =>
870-
find_deps(client_manifest, posixify(path.relative('.', f)), false);
869+
/**
870+
* @param {string} entry
871+
* @param {boolean} [add_dynamic_css]
872+
*/
873+
const deps_of = (entry, add_dynamic_css = false) =>
874+
find_deps(client_manifest, posixify(path.relative('.', entry)), add_dynamic_css);
871875

872876
if (svelte_config.kit.output.bundleStrategy === 'split') {
873877
const start = deps_of(`${runtime_directory}/client/entry.js`);
@@ -888,14 +892,20 @@ Tips:
888892
// similar to that on the client, with as much information computed upfront so that we
889893
// don't need to include any code of the actual routes in the server bundle.
890894
if (svelte_config.kit.router.resolution === 'server') {
891-
build_data.client.nodes = manifest_data.nodes.map((node, i) => {
895+
const nodes = manifest_data.nodes.map((node, i) => {
892896
if (node.component || node.universal) {
893-
return resolve_symlinks(
897+
const entry = `${kit.outDir}/generated/client-optimized/nodes/${i}.js`;
898+
const deps = deps_of(entry, true);
899+
const file = resolve_symlinks(
894900
client_manifest,
895901
`${kit.outDir}/generated/client-optimized/nodes/${i}.js`
896902
).chunk.file;
903+
904+
return { file, css: deps.stylesheets };
897905
}
898906
});
907+
build_data.client.nodes = nodes.map((node) => node?.file);
908+
build_data.client.css = nodes.map((node) => node?.css);
899909

900910
build_data.client.routes = compact(
901911
manifest_data.routes.map((route) => {

Diff for: packages/kit/src/runtime/client/client.js

+4-1
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ import {
1919
origin,
2020
scroll_state,
2121
notifiable_store,
22-
create_updated_store
22+
create_updated_store,
23+
load_css
2324
} from './utils.js';
2425
import { base } from '__sveltekit/paths';
2526
import * as devalue from 'devalue';
@@ -41,6 +42,8 @@ import { writable } from 'svelte/store';
4142
import { page, update, navigating } from './state.svelte.js';
4243
import { add_data_suffix, add_resolution_suffix } from '../pathname.js';
4344

45+
export { load_css };
46+
4447
const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']);
4548

4649
let errored = false;

Diff for: packages/kit/src/runtime/client/entry.js

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
// we expose this as a separate entry point (rather than treating client.js as the entry point)
2-
// so that everything other than `start` can be treeshaken
3-
export { start } from './client.js';
2+
// so that everything other than `start`/`load_css` can be treeshaken
3+
export { start, load_css } from './client.js';

Diff for: packages/kit/src/runtime/client/utils.js

+37
Original file line numberDiff line numberDiff line change
@@ -331,3 +331,40 @@ export function is_external_url(url, base, hash_routing) {
331331

332332
return false;
333333
}
334+
335+
/** @type {Record<string, boolean>} */
336+
const seen = {};
337+
338+
/**
339+
* Used for server-side resolution, to replicate Vite's CSS loading behaviour in production.
340+
*
341+
* Closely modelled after https://github.com/vitejs/vite/blob/3dd12f4724130fdf8ba44c6d3252ebdff407fd47/packages/vite/src/node/plugins/importAnalysisBuild.ts#L214
342+
* (which ideally we could just use directly, but it's not exported)
343+
* @param {string[]} deps
344+
*/
345+
export function load_css(deps) {
346+
if (__SVELTEKIT_CLIENT_ROUTING__) return;
347+
348+
const csp_nonce_meta = /** @type {HTMLMetaElement} */ (
349+
document.querySelector('meta[property=csp-nonce]')
350+
);
351+
const csp_nonce = csp_nonce_meta?.nonce || csp_nonce_meta?.getAttribute('nonce');
352+
353+
for (const dep of deps) {
354+
if (dep in seen) continue;
355+
seen[dep] = true;
356+
357+
if (document.querySelector(`link[href="${dep}"][rel="stylesheet"]`)) {
358+
continue;
359+
}
360+
361+
const link = document.createElement('link');
362+
link.rel = 'stylesheet';
363+
link.crossOrigin = '';
364+
link.href = dep;
365+
if (csp_nonce) {
366+
link.setAttribute('nonce', csp_nonce);
367+
}
368+
document.head.appendChild(link);
369+
}
370+
}

Diff for: packages/kit/src/runtime/server/page/server_routing.js

+30-1
Original file line numberDiff line numberDiff line change
@@ -105,10 +105,39 @@ export function create_server_routing_response(route, params, url, manifest) {
105105

106106
if (route) {
107107
const csr_route = generate_route_object(route, url, manifest);
108-
const body = `export const route = ${csr_route}; export const params = ${JSON.stringify(params)};`;
108+
const body = `${create_css_import(route, url, manifest)}\nexport const route = ${csr_route}; export const params = ${JSON.stringify(params)};`;
109109

110110
return { response: text(body, { headers }), body };
111111
} else {
112112
return { response: text('', { headers }), body: '' };
113113
}
114114
}
115+
116+
/**
117+
* This function generates the client-side import for the CSS files that are
118+
* associated with the current route. Vite takes care of that when using
119+
* client-side route resolution, but for server-side resolution it does
120+
* not know about the CSS files automatically.
121+
*
122+
* @param {import('types').SSRClientRoute} route
123+
* @param {URL} url
124+
* @param {import('@sveltejs/kit').SSRManifest} manifest
125+
* @returns {string}
126+
*/
127+
function create_css_import(route, url, manifest) {
128+
const { errors, layouts, leaf } = route;
129+
130+
let css = '';
131+
132+
for (const node of [...errors, ...layouts.map((l) => l?.[1]), leaf[1]]) {
133+
if (typeof node !== 'number') continue;
134+
const node_css = manifest._.client.css?.[node];
135+
for (const css_path of node_css ?? []) {
136+
css += `'${assets || base}/${css_path}',`;
137+
}
138+
}
139+
140+
if (!css) return '';
141+
142+
return `${create_client_import(/** @type {string} */ (manifest._.client.start), url)}.then(x => x.load_css([${css}]));`;
143+
}

Diff for: packages/kit/src/types/internal.d.ts

+6
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,12 @@ export interface BuildData {
8080
* Only set in case of `router.resolution === 'server'`.
8181
*/
8282
nodes?: (string | undefined)[];
83+
/**
84+
* CSS files referenced in the entry points of the layouts/pages.
85+
* An entry is undefined if the layout/page has no component or universal file (i.e. only has a `.server.js` file) or if has no CSS.
86+
* Only set in case of `router.resolution === 'server'`.
87+
*/
88+
css?: (string[] | undefined)[];
8389
/**
8490
* Contains the client route manifest in a form suitable for the server which is used for server side route resolution.
8591
* Notably, it contains all routes, regardless of whether they are prerendered or not (those are missing in the optimized server route manifest).

Diff for: packages/kit/test/apps/basics/test/cross-platform/test.js

+24-3
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ import { test } from '../../../../utils.js';
77
test.describe.configure({ mode: 'parallel' });
88

99
test.describe('CSS', () => {
10-
test('applies styles correctly', async ({ page, get_computed_style }) => {
11-
await page.goto('/css');
12-
10+
/**
11+
* @param {(selector: string, prop: string) => Promise<string>} get_computed_style
12+
*/
13+
function check_styles(get_computed_style) {
1314
test.step('applies imported styles', async () => {
1415
expect(await get_computed_style('.styled', 'color')).toBe('rgb(255, 0, 0)');
1516
});
@@ -29,6 +30,26 @@ test.describe('CSS', () => {
2930
test.step('does not apply raw and url', async () => {
3031
expect(await get_computed_style('.not', 'color')).toBe('rgb(0, 0, 0)');
3132
});
33+
}
34+
35+
test('applies styles correctly', async ({ page, get_computed_style }) => {
36+
await page.goto('/css');
37+
38+
check_styles(get_computed_style);
39+
});
40+
41+
test('applies styles correctly after client-side navigation', async ({
42+
page,
43+
app,
44+
get_computed_style,
45+
javaScriptEnabled
46+
}) => {
47+
if (!javaScriptEnabled) return;
48+
49+
await page.goto('/');
50+
await app.goto('/css');
51+
52+
check_styles(get_computed_style);
3253
});
3354

3455
test('loads styles on routes with encoded characters', async ({ page, get_computed_style }) => {

Diff for: packages/kit/types/index.d.ts

+6
Original file line numberDiff line numberDiff line change
@@ -1727,6 +1727,12 @@ declare module '@sveltejs/kit' {
17271727
* Only set in case of `router.resolution === 'server'`.
17281728
*/
17291729
nodes?: (string | undefined)[];
1730+
/**
1731+
* CSS files referenced in the entry points of the layouts/pages.
1732+
* An entry is undefined if the layout/page has no component or universal file (i.e. only has a `.server.js` file) or if has no CSS.
1733+
* Only set in case of `router.resolution === 'server'`.
1734+
*/
1735+
css?: (string[] | undefined)[];
17301736
/**
17311737
* Contains the client route manifest in a form suitable for the server which is used for server side route resolution.
17321738
* Notably, it contains all routes, regardless of whether they are prerendered or not (those are missing in the optimized server route manifest).

0 commit comments

Comments
 (0)