Skip to content

Commit 6c44239

Browse files
authored
fix: include components dynamically imported in the universal load function when inlining CSS (#13723)
* add test * map client to server stylesheets using rollup bundle data * changeset * avoid duplicate filenames * skip bundle mapping if inline style threshold is not enabled * optimise mapping and skip if no inline threshold
1 parent 6a6538c commit 6c44239

File tree

10 files changed

+147
-89
lines changed

10 files changed

+147
-89
lines changed

.changeset/spicy-cougars-wave.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+
fix: correctly inline stylesheets of components dynamically imported in a universal load function if they are below the configured inlineStyleThreshold

packages/kit/src/core/postbuild/analyse.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ async function analyse({
7474
server_manifest,
7575
null,
7676
null,
77+
null,
7778
output_config,
7879
static_exports
7980
);

packages/kit/src/exports/public.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -468,7 +468,7 @@ export interface KitConfig {
468468
errorTemplate?: string;
469469
};
470470
/**
471-
* Inline CSS inside a `<style>` block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) property, to be inlined. All CSS files needed for the page and smaller than this value are merged and inlined in a `<style>` block.
471+
* Inline CSS inside a `<style>` block at the head of the HTML. This option is a number that specifies the maximum length of a CSS file in UTF-16 code units, as specified by the [String.length](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/length) property, to be inlined. All CSS files needed for the page that are smaller than this value are merged and inlined in a `<style>` block.
472472
*
473473
* > [!NOTE] This results in fewer initial requests and can improve your [First Contentful Paint](https://web.dev/first-contentful-paint) score. However, it generates larger HTML output and reduces the effectiveness of browser caches. Use it advisedly.
474474
* @default 0

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

Lines changed: 94 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -13,66 +13,49 @@ import { create_node_analyser } from '../static_analysis/index.js';
1313
* @param {import('types').ManifestData} manifest_data
1414
* @param {import('vite').Manifest} server_manifest
1515
* @param {import('vite').Manifest | null} client_manifest
16-
* @param {import('vite').Rollup.OutputAsset[] | null} css
16+
* @param {import('vite').Rollup.OutputBundle | null} server_bundle
17+
* @param {import('vite').Rollup.RollupOutput['output'] | null} client_bundle
1718
* @param {import('types').RecursiveRequired<import('types').ValidatedConfig['kit']['output']>} output_config
1819
* @param {Map<string, { page_options: Record<string, any> | null, children: string[] }>} static_exports
1920
*/
20-
export async function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css, output_config, static_exports) {
21+
export async function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, server_bundle, client_bundle, output_config, static_exports) {
2122
mkdirp(`${out}/server/nodes`);
2223
mkdirp(`${out}/server/stylesheets`);
2324

2425
/** @type {Map<string, string>} */
25-
const stylesheet_lookup = new Map();
26-
27-
if (css) {
28-
/** @type {Set<string>} */
29-
const client_stylesheets = new Set();
30-
for (const key in client_manifest) {
31-
client_manifest[key].css?.forEach((filename) => {
32-
client_stylesheets.add(filename);
33-
});
34-
}
26+
const stylesheets_to_inline = new Map();
3527

36-
/** @type {Map<number, string[]>} */
37-
const server_stylesheets = new Map();
38-
manifest_data.nodes.forEach((node, i) => {
39-
if (!node.component || !server_manifest[node.component]) return;
28+
if (server_bundle && client_bundle && kit.inlineStyleThreshold > 0) {
29+
const client = get_stylesheets(client_bundle);
4030

41-
const { stylesheets } = find_deps(server_manifest, node.component, false);
31+
const server_chunks = Object.values(server_bundle);
32+
const server = get_stylesheets(server_chunks);
4233

43-
if (stylesheets.length) {
44-
server_stylesheets.set(i, stylesheets);
45-
}
46-
});
47-
48-
for (const asset of css) {
49-
// ignore dynamically imported stylesheets since we don't need to inline those
50-
if (!client_stylesheets.has(asset.fileName) || asset.source.length >= kit.inlineStyleThreshold) {
34+
// map server stylesheet name to the client stylesheet name
35+
for (const [id, client_stylesheet] of client.stylesheets_used) {
36+
const server_stylesheet = server.stylesheets_used.get(id);
37+
if (!server_stylesheet) {
5138
continue;
5239
}
40+
client_stylesheet.forEach((file, i) => {
41+
stylesheets_to_inline.set(file, server_stylesheet[i]);
42+
})
43+
}
5344

54-
// We know that the names for entry points are numbers.
55-
const [index] = basename(asset.fileName).split('.');
56-
// There can also be other CSS files from shared components
57-
// for example, which we need to ignore here.
58-
if (isNaN(+index)) continue;
59-
60-
const file = `${out}/server/stylesheets/${index}.js`;
61-
62-
// we need to inline the server stylesheet instead of the client one
63-
// so that asset paths are correct on document load
64-
const filenames = server_stylesheets.get(+index);
65-
66-
if (!filenames) {
67-
throw new Error('This should never happen, but if it does, it means we failed to find the server stylesheet for a node.');
45+
// filter out stylesheets that should not be inlined
46+
for (const [fileName, content] of client.stylesheet_content) {
47+
if (content.length >= kit.inlineStyleThreshold) {
48+
stylesheets_to_inline.delete(fileName);
6849
}
50+
}
6951

70-
const sources = filenames.map((filename) => {
71-
return fs.readFileSync(`${out}/server/${filename}`, 'utf-8');
72-
});
73-
fs.writeFileSync(file, `// ${filenames.join(', ')}\nexport default ${s(sources.join('\n'))};`);
74-
75-
stylesheet_lookup.set(asset.fileName, index);
52+
// map server stylesheet source to the client stylesheet name
53+
for (const [client_file, server_file] of stylesheets_to_inline) {
54+
const source = server.stylesheet_content.get(server_file);
55+
if (!source) {
56+
throw new Error(`Server stylesheet source not found for client stylesheet ${client_file}`);
57+
}
58+
stylesheets_to_inline.set(client_file, source);
7659
}
7760
}
7861

@@ -139,8 +122,9 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
139122
const entry_path = `${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`;
140123
const entry = find_deps(client_manifest, entry_path, true);
141124

142-
// eagerly load stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
143-
// If it is not used during SSR, it can be lazily loaded in the browser.
125+
// eagerly load client stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
126+
// However, if it is not used during SSR (not present in the server manifest),
127+
// then it can be lazily loaded in the browser.
144128

145129
/** @type {import('types').AssetDependencies | undefined} */
146130
let component;
@@ -155,26 +139,26 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
155139
}
156140

157141
/** @type {Set<string>} */
158-
const css_used_by_server = new Set();
142+
const eager_css = new Set();
159143
/** @type {Set<string>} */
160-
const assets_used_by_server = new Set();
144+
const eager_assets = new Set();
161145

162-
entry.stylesheet_map.forEach((value, key) => {
163-
// pages and layouts are named as node indexes in the client manifest
164-
// so we need to use the original filename when checking against the server manifest
165-
if (key === entry_path) {
166-
key = node.component ?? key;
146+
entry.stylesheet_map.forEach((value, filepath) => {
147+
// pages and layouts are renamed to node indexes when optimised for the client
148+
// so we use the original filename instead to check against the server manifest
149+
if (filepath === entry_path) {
150+
filepath = node.component ?? filepath;
167151
}
168152

169-
if (component?.stylesheet_map.has(key) || universal?.stylesheet_map.has(key)) {
170-
value.css.forEach(file => css_used_by_server.add(file));
171-
value.assets.forEach(file => assets_used_by_server.add(file));
153+
if (component?.stylesheet_map.has(filepath) || universal?.stylesheet_map.has(filepath)) {
154+
value.css.forEach(file => eager_css.add(file));
155+
value.assets.forEach(file => eager_assets.add(file));
172156
}
173157
});
174158

175159
imported = entry.imports;
176-
stylesheets = Array.from(css_used_by_server);
177-
fonts = filter_fonts(Array.from(assets_used_by_server));
160+
stylesheets = Array.from(eager_css);
161+
fonts = filter_fonts(Array.from(eager_assets));
178162
}
179163

180164
exports.push(
@@ -184,19 +168,26 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
184168
);
185169

186170
/** @type {string[]} */
187-
const styles = [];
188-
189-
stylesheets.forEach((file) => {
190-
if (stylesheet_lookup.has(file)) {
191-
const index = stylesheet_lookup.get(file);
192-
const name = `stylesheet_${index}`;
193-
imports.push(`import ${name} from '../stylesheets/${index}.js';`);
194-
styles.push(`\t${s(file)}: ${name}`);
171+
const inline_styles = [];
172+
173+
stylesheets.forEach((file, i) => {
174+
if (stylesheets_to_inline.has(file)) {
175+
const filename = basename(file);
176+
const dest = `${out}/server/stylesheets/${filename}.js`;
177+
const source = stylesheets_to_inline.get(file);
178+
if (!source) {
179+
throw new Error(`Server stylesheet source not found for client stylesheet ${file}`);
180+
}
181+
fs.writeFileSync(dest, `// ${filename}\nexport default ${s(source)};`);
182+
183+
const name = `stylesheet_${i}`;
184+
imports.push(`import ${name} from '../stylesheets/${filename}.js';`);
185+
inline_styles.push(`\t${s(file)}: ${name}`);
195186
}
196187
});
197188

198-
if (styles.length > 0) {
199-
exports.push(`export const inline_styles = () => ({\n${styles.join(',\n')}\n});`);
189+
if (inline_styles.length > 0) {
190+
exports.push(`export const inline_styles = () => ({\n${inline_styles.join(',\n')}\n});`);
200191
}
201192

202193
fs.writeFileSync(
@@ -205,3 +196,37 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
205196
);
206197
}
207198
}
199+
200+
/**
201+
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
202+
*/
203+
function get_stylesheets(chunks) {
204+
/**
205+
* A map of module IDs and the stylesheets they use.
206+
* @type {Map<string, string[]>}
207+
*/
208+
const stylesheets_used = new Map();
209+
210+
/**
211+
* A map of stylesheet names and their content.
212+
* @type {Map<string, string>}
213+
*/
214+
const stylesheet_content = new Map();
215+
216+
for (const chunk of chunks) {
217+
if (chunk.type === 'asset') {
218+
if (chunk.fileName.endsWith('.css')) {
219+
stylesheet_content.set(chunk.fileName, chunk.source.toString());
220+
}
221+
continue;
222+
}
223+
224+
if (chunk.viteMetadata?.importedCss.size) {
225+
const css = Array.from(chunk.viteMetadata.importedCss);
226+
for (const id of chunk.moduleIds) {
227+
stylesheets_used.set(id, css );
228+
}
229+
}
230+
}
231+
return { stylesheets_used, stylesheet_content };
232+
}

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

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -787,7 +787,7 @@ Tips:
787787
*/
788788
writeBundle: {
789789
sequential: true,
790-
async handler(_options) {
790+
async handler(_options, bundle) {
791791
if (secondary_build_started) return; // only run this once
792792

793793
const verbose = vite_config.logLevel === 'info';
@@ -843,7 +843,7 @@ Tips:
843843

844844
secondary_build_started = true;
845845

846-
const { output } = /** @type {import('vite').Rollup.RollupOutput} */ (
846+
const { output: client_chunks } = /** @type {import('vite').Rollup.RollupOutput} */ (
847847
await vite.build({
848848
configFile: vite_config.configFile,
849849
// CLI args
@@ -886,7 +886,7 @@ Tips:
886886
imports: [...start.imports, ...app.imports],
887887
stylesheets: [...start.stylesheets, ...app.stylesheets],
888888
fonts: [...start.fonts, ...app.fonts],
889-
uses_env_dynamic_public: output.some(
889+
uses_env_dynamic_public: client_chunks.some(
890890
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
891891
)
892892
};
@@ -935,14 +935,14 @@ Tips:
935935
imports: start.imports,
936936
stylesheets: start.stylesheets,
937937
fonts: start.fonts,
938-
uses_env_dynamic_public: output.some(
938+
uses_env_dynamic_public: client_chunks.some(
939939
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
940940
)
941941
};
942942

943943
if (svelte_config.kit.output.bundleStrategy === 'inline') {
944944
const style = /** @type {import('rollup').OutputAsset} */ (
945-
output.find(
945+
client_chunks.find(
946946
(chunk) =>
947947
chunk.type === 'asset' &&
948948
chunk.names.length === 1 &&
@@ -957,11 +957,6 @@ Tips:
957957
}
958958
}
959959

960-
const css = output.filter(
961-
/** @type {(value: any) => value is import('vite').Rollup.OutputAsset} */
962-
(value) => value.type === 'asset' && value.fileName.endsWith('.css')
963-
);
964-
965960
// regenerate manifest now that we have client entry...
966961
fs.writeFileSync(
967962
manifest_path,
@@ -980,7 +975,8 @@ Tips:
980975
manifest_data,
981976
server_manifest,
982977
client_manifest,
983-
css,
978+
bundle,
979+
client_chunks,
984980
svelte_config.kit.output,
985981
static_exports
986982
);
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
export async function load() {
2+
return {
3+
Thing: (await import('./Thing.svelte')).default
4+
};
5+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
export let data;
3+
</script>
4+
5+
<svelte:component this={data.Thing} />
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<p>I'm dynamically imported</p>
2+
3+
<style>
4+
p {
5+
color: blue;
6+
font-size: 20px;
7+
}
8+
</style>

packages/kit/test/apps/options/test/test.js

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ test.describe('assets path', () => {
107107
await page.goto('/path-base/');
108108
const href = await page.locator('link[rel="icon"]').getAttribute('href');
109109

110-
const response = await request.get(href);
110+
const response = await request.get(href ?? '');
111111
expect(response.status()).toBe(200);
112112
});
113113
});
@@ -133,15 +133,15 @@ test.describe('CSP', () => {
133133
test('ensure CSP header in stream response', async ({ page, javaScriptEnabled }) => {
134134
if (!javaScriptEnabled) return;
135135
const response = await page.goto('/path-base/csp-with-stream');
136-
expect(response.headers()['content-security-policy']).toMatch(
136+
expect(response?.headers()['content-security-policy']).toMatch(
137137
/require-trusted-types-for 'script'/
138138
);
139139
expect(await page.textContent('h2')).toBe('Moo Deng!');
140140
});
141141

142142
test("quotes 'script'", async ({ page }) => {
143143
const response = await page.goto('/path-base');
144-
expect(response.headers()['content-security-policy']).toMatch(
144+
expect(response?.headers()['content-security-policy']).toMatch(
145145
/require-trusted-types-for 'script'/
146146
);
147147
});
@@ -305,19 +305,32 @@ if (!process.env.DEV) {
305305
});
306306

307307
test.describe('inlineStyleThreshold', () => {
308-
test('loads asset', async ({ page }) => {
308+
test('loads assets', async ({ page }) => {
309309
let fontLoaded = false;
310-
311310
page.on('response', (response) => {
312311
if (response.url().endsWith('.woff2') || response.url().endsWith('.woff')) {
313312
fontLoaded = response.ok();
314313
}
315314
});
316-
317315
await page.goto('/path-base/inline-assets');
318-
319316
expect(fontLoaded).toBeTruthy();
320317
});
318+
319+
test('includes components dynamically imported in universal load', async ({
320+
page,
321+
get_computed_style
322+
}) => {
323+
let loaded_css = false;
324+
page.on('response', (response) => {
325+
if (response.url().endsWith('.css')) {
326+
loaded_css = true;
327+
}
328+
});
329+
await page.goto('/path-base/inline-assets/dynamic-import');
330+
await expect(page.locator('p')).toHaveText("I'm dynamically imported");
331+
expect(loaded_css).toBe(false);
332+
expect(await get_computed_style('p', 'color')).toEqual('rgb(0, 0, 255)');
333+
});
321334
});
322335
}
323336

0 commit comments

Comments
 (0)