Skip to content

Commit 4842a24

Browse files
authored
Include styles for dynamically imported components (#5138)
* add failing test for #5137 * partial fix for #5137 * defer gathering of styles * fix typechecking * try and nudge turbo into not breaking everything * include dynamically imported styles during dev SSR * changeset * rename some stuff to make it clearer * simplify, and dont follow dynamic imports from entry * tidy up * tidy up * tidy up
1 parent d63bd72 commit 4842a24

File tree

13 files changed

+165
-95
lines changed

13 files changed

+165
-95
lines changed

.changeset/wild-donuts-double.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': patch
3+
---
4+
5+
Include dynamically imported styles during SSR

packages/kit/src/runtime/server/page/load_node.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -362,7 +362,7 @@ export async function load_node({
362362

363363
if (!loaded) {
364364
// TODO do we still want to enforce this now that there's no fallthrough?
365-
throw new Error(`load function must return a value${options.dev ? ` (${node.entry})` : ''}`);
365+
throw new Error(`load function must return a value${options.dev ? ` (${node.file})` : ''}`);
366366
}
367367
} else if (shadow.body) {
368368
loaded = {

packages/kit/src/runtime/server/page/render.js

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,14 @@ export async function render_response({
5151
}
5252
}
5353

54-
const stylesheets = new Set(options.manifest._.entry.css);
55-
const modulepreloads = new Set(options.manifest._.entry.js);
54+
const { entry } = options.manifest._;
55+
56+
const stylesheets = new Set(entry.stylesheets);
57+
const modulepreloads = new Set(entry.imports);
58+
5659
/** @type {Map<string, string>} */
57-
const styles = new Map();
60+
// TODO if we add a client entry point one day, we will need to include inline_styles with the entry, otherwise stylesheets will be linked even if they are below inlineStyleThreshold
61+
const inline_styles = new Map();
5862

5963
/** @type {Array<import('./types').Fetched>} */
6064
const serialized_data = [];
@@ -72,18 +76,26 @@ export async function render_response({
7276
}
7377

7478
if (resolve_opts.ssr) {
75-
branch.forEach(({ node, props, loaded, fetched, uses_credentials }) => {
76-
if (node.css) node.css.forEach((url) => stylesheets.add(url));
77-
if (node.js) node.js.forEach((url) => modulepreloads.add(url));
78-
if (node.styles) Object.entries(node.styles).forEach(([k, v]) => styles.set(k, v));
79+
for (const { node, props, loaded, fetched, uses_credentials } of branch) {
80+
if (node.imports) {
81+
node.imports.forEach((url) => modulepreloads.add(url));
82+
}
83+
84+
if (node.stylesheets) {
85+
node.stylesheets.forEach((url) => stylesheets.add(url));
86+
}
87+
88+
if (node.inline_styles) {
89+
Object.entries(await node.inline_styles()).forEach(([k, v]) => inline_styles.set(k, v));
90+
}
7991

8092
// TODO probably better if `fetched` wasn't populated unless `hydrate`
8193
if (fetched && page_config.hydrate) serialized_data.push(...fetched);
8294
if (props) shadow_props = props;
8395

8496
cache = loaded?.cache;
8597
is_private = cache?.private ?? uses_credentials;
86-
});
98+
}
8799

88100
const session = writable($session);
89101

@@ -144,8 +156,6 @@ export async function render_response({
144156

145157
let { head, html: body } = rendered;
146158

147-
const inlined_style = Array.from(styles.values()).join('\n');
148-
149159
await csp_ready;
150160
const csp = new Csp(options.csp, {
151161
dev: options.dev,
@@ -157,7 +167,7 @@ export async function render_response({
157167

158168
// prettier-ignore
159169
const init_app = `
160-
import { start } from ${s(options.prefix + options.manifest._.entry.file)};
170+
import { start } from ${s(options.prefix + entry.file)};
161171
start({
162172
target: document.querySelector('[data-sveltekit-hydrate="${target}"]').parentNode,
163173
paths: ${s(options.paths)},
@@ -185,14 +195,16 @@ export async function render_response({
185195
}
186196
`;
187197

188-
if (inlined_style) {
198+
if (inline_styles.size > 0) {
199+
const content = Array.from(inline_styles.values()).join('\n');
200+
189201
const attributes = [];
190202
if (options.dev) attributes.push(' data-sveltekit');
191203
if (csp.style_needs_nonce) attributes.push(` nonce="${csp.nonce}"`);
192204

193-
csp.add_style(inlined_style);
205+
csp.add_style(content);
194206

195-
head += `\n\t<style${attributes.join('')}>${inlined_style}</style>`;
207+
head += `\n\t<style${attributes.join('')}>${content}</style>`;
196208
}
197209

198210
// prettier-ignore
@@ -207,7 +219,7 @@ export async function render_response({
207219
attributes.push(`nonce="${csp.nonce}"`);
208220
}
209221

210-
if (styles.has(dep)) {
222+
if (inline_styles.has(dep)) {
211223
// don't load stylesheets that are already inlined
212224
// include them in disabled state so that Vite can detect them and doesn't try to add them
213225
attributes.push('disabled', 'media="(max-width: 0)"');

packages/kit/src/vite/build/build_server.js

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -208,26 +208,22 @@ export async function build_server(options, client) {
208208
});
209209

210210
manifest_data.components.forEach((component, i) => {
211-
const file = `${output_dir}/server/nodes/${i}.js`;
212-
213-
const js = new Set();
214-
const css = new Set();
215-
find_deps(component, client.vite_manifest, js, css);
211+
const entry = find_deps(client.vite_manifest, component, true);
216212

217213
const imports = [`import * as module from '../${vite_manifest[component].file}';`];
218214

219215
const exports = [
220216
'export { module };',
221217
`export const index = ${i};`,
222-
`export const entry = '${client.vite_manifest[component].file}';`,
223-
`export const js = ${s(Array.from(js))};`,
224-
`export const css = ${s(Array.from(css))};`
218+
`export const file = '${entry.file}';`,
219+
`export const imports = ${s(entry.imports)};`,
220+
`export const stylesheets = ${s(entry.stylesheets)};`
225221
];
226222

227223
/** @type {string[]} */
228224
const styles = [];
229225

230-
css.forEach((file) => {
226+
entry.stylesheets.forEach((file) => {
231227
if (stylesheet_lookup.has(file)) {
232228
const index = stylesheet_lookup.get(file);
233229
const name = `stylesheet_${index}`;
@@ -237,10 +233,11 @@ export async function build_server(options, client) {
237233
});
238234

239235
if (styles.length > 0) {
240-
exports.push(`export const styles = {\n${styles.join(',\n')}\n};`);
236+
exports.push(`export const inline_styles = () => ({\n${styles.join(',\n')}\n});`);
241237
}
242238

243-
fs.writeFileSync(file, `${imports.join('\n')}\n\n${exports.join('\n')}\n`);
239+
const out = `${output_dir}/server/nodes/${i}.js`;
240+
fs.writeFileSync(out, `${imports.join('\n')}\n\n${exports.join('\n')}\n`);
244241
});
245242

246243
return {

packages/kit/src/vite/build/utils.js

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -30,24 +30,47 @@ export async function create_build(config) {
3030

3131
/**
3232
* Adds transitive JS and CSS dependencies to the js and css inputs.
33-
* @param {string} file
3433
* @param {import('vite').Manifest} manifest
35-
* @param {Set<string>} css
36-
* @param {Set<string>} js
34+
* @param {string} entry
35+
* @param {boolean} add_dynamic_css
3736
*/
38-
export function find_deps(file, manifest, js, css) {
39-
const chunk = manifest[file];
37+
export function find_deps(manifest, entry, add_dynamic_css) {
38+
/** @type {Set<string>} */
39+
const imports = new Set();
4040

41-
if (js.has(chunk.file)) return;
42-
js.add(chunk.file);
41+
/** @type {Set<string>} */
42+
const stylesheets = new Set();
4343

44-
if (chunk.css) {
45-
chunk.css.forEach((file) => css.add(file));
46-
}
44+
/**
45+
* @param {string} file
46+
* @param {boolean} add_js
47+
*/
48+
function traverse(file, add_js) {
49+
const chunk = manifest[file];
50+
51+
if (imports.has(chunk.file)) return;
52+
if (add_js) imports.add(chunk.file);
53+
54+
if (chunk.css) {
55+
chunk.css.forEach((file) => stylesheets.add(file));
56+
}
57+
58+
if (chunk.imports) {
59+
chunk.imports.forEach((file) => traverse(file, add_js));
60+
}
4761

48-
if (chunk.imports) {
49-
chunk.imports.forEach((file) => find_deps(file, manifest, js, css));
62+
if (add_dynamic_css && chunk.dynamicImports) {
63+
chunk.dynamicImports.forEach((file) => traverse(file, false));
64+
}
5065
}
66+
67+
traverse(entry, true);
68+
69+
return {
70+
file: manifest[entry].file,
71+
imports: Array.from(imports),
72+
stylesheets: Array.from(stylesheets)
73+
};
5174
}
5275

5376
/**

packages/kit/src/vite/dev/index.js

Lines changed: 41 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,8 @@ export async function dev(vite, svelte_config) {
5050
_: {
5151
entry: {
5252
file: `/@fs${runtime}/client/start.js`,
53-
css: [],
54-
js: []
53+
imports: [],
54+
stylesheets: []
5555
},
5656
nodes: manifest_data.components.map((id, index) => {
5757
return async () => {
@@ -60,43 +60,46 @@ export async function dev(vite, svelte_config) {
6060
const module = /** @type {import('types').SSRComponent} */ (
6161
await vite.ssrLoadModule(url, { fixStacktrace: false })
6262
);
63-
const node = await vite.moduleGraph.getModuleByUrl(url);
64-
65-
if (!node) throw new Error(`Could not find node for ${url}`);
66-
67-
const deps = new Set();
68-
await find_deps(vite, node, deps);
69-
70-
/** @type {Record<string, string>} */
71-
const styles = {};
72-
73-
for (const dep of deps) {
74-
const parsed = new URL(dep.url, 'http://localhost/');
75-
const query = parsed.searchParams;
76-
77-
if (
78-
style_pattern.test(dep.file) ||
79-
(query.has('svelte') && query.get('type') === 'style')
80-
) {
81-
try {
82-
const mod = await vite.ssrLoadModule(dep.url, { fixStacktrace: false });
83-
styles[dep.url] = mod.default;
84-
} catch {
85-
// this can happen with dynamically imported modules, I think
86-
// because the Vite module graph doesn't distinguish between
87-
// static and dynamic imports? TODO investigate, submit fix
88-
}
89-
}
90-
}
9163

9264
return {
9365
module,
9466
index,
95-
entry: url.endsWith('.svelte') ? url : url + '?import',
96-
css: [],
97-
js: [],
67+
file: url.endsWith('.svelte') ? url : url + '?import',
68+
imports: [],
69+
stylesheets: [],
9870
// in dev we inline all styles to avoid FOUC
99-
styles
71+
inline_styles: async () => {
72+
const node = await vite.moduleGraph.getModuleByUrl(url);
73+
74+
if (!node) throw new Error(`Could not find node for ${url}`);
75+
76+
const deps = new Set();
77+
await find_deps(vite, node, deps);
78+
79+
/** @type {Record<string, string>} */
80+
const styles = {};
81+
82+
for (const dep of deps) {
83+
const parsed = new URL(dep.url, 'http://localhost/');
84+
const query = parsed.searchParams;
85+
86+
if (
87+
style_pattern.test(dep.file) ||
88+
(query.has('svelte') && query.get('type') === 'style')
89+
) {
90+
try {
91+
const mod = await vite.ssrLoadModule(dep.url, { fixStacktrace: false });
92+
styles[dep.url] = mod.default;
93+
} catch {
94+
// this can happen with dynamically imported modules, I think
95+
// because the Vite module graph doesn't distinguish between
96+
// static and dynamic imports? TODO investigate, submit fix
97+
}
98+
}
99+
}
100+
101+
return styles;
102+
}
100103
};
101104
};
102105
}),
@@ -430,6 +433,10 @@ async function find_deps(vite, node, deps) {
430433
if (node.ssrTransformResult.deps) {
431434
node.ssrTransformResult.deps.forEach((url) => branches.push(add_by_url(url)));
432435
}
436+
437+
if (node.ssrTransformResult.dynamicDeps) {
438+
node.ssrTransformResult.dynamicDeps.forEach((url) => branches.push(add_by_url(url)));
439+
}
433440
} else {
434441
node.importedModules.forEach((node) => branches.push(add(node)));
435442
}

packages/kit/src/vite/index.js

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ function kit() {
195195
const verbose = vite_config.logLevel === 'info';
196196
const log = logger({ verbose });
197197

198+
fs.writeFileSync(
199+
`${paths.client_out_dir}/version.json`,
200+
JSON.stringify({ version: process.env.VITE_SVELTEKIT_APP_VERSION })
201+
);
202+
198203
/** @type {import('rollup').OutputChunk[]} */
199204
const chunks = [];
200205
/** @type {import('rollup').OutputAsset[]} */
@@ -213,25 +218,14 @@ function kit() {
213218
fs.readFileSync(`${paths.client_out_dir}/immutable/manifest.json`, 'utf-8')
214219
);
215220

216-
const entry = posixify(
221+
const entry_id = posixify(
217222
path.relative(cwd, `${get_runtime_path(svelte_config.kit)}/client/start.js`)
218223
);
219-
const entry_js = new Set();
220-
const entry_css = new Set();
221-
find_deps(entry, vite_manifest, entry_js, entry_css);
222224

223-
fs.writeFileSync(
224-
`${paths.client_out_dir}/version.json`,
225-
JSON.stringify({ version: process.env.VITE_SVELTEKIT_APP_VERSION })
226-
);
227225
const client = {
228226
assets,
229227
chunks,
230-
entry: {
231-
file: vite_manifest[entry].file,
232-
js: Array.from(entry_js),
233-
css: Array.from(entry_css)
234-
},
228+
entry: find_deps(vite_manifest, entry_id, false),
235229
vite_manifest
236230
};
237231
log.info(`Client build completed. Wrote ${chunks.length} chunks and ${assets.length} assets`);
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<p id="thing">this text is red</p>
2+
3+
<style>
4+
p {
5+
color: red;
6+
}
7+
</style>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<script context="module">
2+
export async function load() {
3+
return {
4+
props: {
5+
Thing: (await import('./_/Thing.svelte')).default
6+
}
7+
};
8+
}
9+
</script>
10+
11+
<script>
12+
export let Thing;
13+
</script>
14+
15+
<Thing />

packages/kit/test/apps/basics/test/test.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1523,6 +1523,15 @@ test.describe('Load', () => {
15231523
);
15241524
}
15251525
});
1526+
1527+
test('CSS for dynamically imported components is reflected in server render', async ({
1528+
page
1529+
}) => {
1530+
await page.goto('/load/dynamic-import-styles');
1531+
expect(
1532+
await page.evaluate(() => getComputedStyle(document.querySelector('#thing')).color)
1533+
).toBe('rgb(255, 0, 0)');
1534+
});
15261535
});
15271536

15281537
test.describe('Method overrides', () => {

0 commit comments

Comments
 (0)