Skip to content

Commit 001bc04

Browse files
authored
fix: lazily load CSS for CSR dynamically imported components (#13564)
fixes #13546 This PR changes server nodes from always loading dynamically imported component's styles and fonts to only loading them if they are used during SSR (to avoid FOUC). This fixes lazy loading of components on the client, only retrieving the styles when needed. We can tell which deps are used during SSR by analysing the deps of the node with both the server manifest and the client manifest, then comparing the results. If a style or font is missing from the server deps, it's probably only used on the client. Therefore, it should be safe to load lazily.
1 parent e2babbb commit 001bc04

File tree

7 files changed

+127
-22
lines changed

7 files changed

+127
-22
lines changed

.changeset/flat-cows-cough.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: lazily load CSS for dynamically imported components

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

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import fs from 'node:fs';
22
import { mkdirp } from '../../../utils/filesystem.js';
3-
import { find_deps, resolve_symlinks } from './utils.js';
3+
import { filter_fonts, find_deps, resolve_symlinks } from './utils.js';
44
import { s } from '../../../utils/misc.js';
55
import { normalizePath } from 'vite';
66
import { basename } from 'node:path';
@@ -83,13 +83,13 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
8383
const exports = [`export const index = ${i};`];
8484

8585
/** @type {string[]} */
86-
const imported = [];
86+
let imported = [];
8787

8888
/** @type {string[]} */
89-
const stylesheets = [];
89+
let stylesheets = [];
9090

9191
/** @type {string[]} */
92-
const fonts = [];
92+
let fonts = [];
9393

9494
if (node.component && client_manifest) {
9595
exports.push(
@@ -119,15 +119,45 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
119119
}
120120

121121
if (client_manifest && (node.universal || node.component) && output_config.bundleStrategy === 'split') {
122-
const entry = find_deps(
123-
client_manifest,
124-
`${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`,
125-
true
126-
);
122+
const entry_path = `${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`;
123+
const entry = find_deps(client_manifest, entry_path, true);
124+
125+
// eagerly load stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
126+
// If it is not used during SSR, it can be lazily loaded in the browser.
127+
128+
/** @type {import('types').AssetDependencies | undefined} */
129+
let component;
130+
if (node.component) {
131+
component = find_deps(server_manifest, node.component, true);
132+
}
133+
134+
/** @type {import('types').AssetDependencies | undefined} */
135+
let universal;
136+
if (node.universal) {
137+
universal = find_deps(server_manifest, node.universal, true);
138+
}
139+
140+
/** @type {Set<string>} */
141+
const css_used_by_server = new Set();
142+
/** @type {Set<string>} */
143+
const assets_used_by_server = new Set();
144+
145+
entry.stylesheet_map.forEach((value, key) => {
146+
// pages and layouts are named as node indexes in the client manifest
147+
// so we need to use the original filename when checking against the server manifest
148+
if (key === entry_path) {
149+
key = node.component ?? key;
150+
}
151+
152+
if (component?.stylesheet_map.has(key) || universal?.stylesheet_map.has(key)) {
153+
value.css.forEach(file => css_used_by_server.add(file));
154+
value.assets.forEach(file => assets_used_by_server.add(file));
155+
}
156+
});
127157

128-
imported.push(...entry.imports);
129-
stylesheets.push(...entry.stylesheets);
130-
fonts.push(...entry.fonts);
158+
imported = entry.imports;
159+
stylesheets = Array.from(css_used_by_server);
160+
fonts = filter_fonts(Array.from(assets_used_by_server));
131161
}
132162

133163
exports.push(

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

Lines changed: 43 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -22,11 +22,16 @@ export function find_deps(manifest, entry, add_dynamic_css) {
2222
/** @type {Set<string>} */
2323
const imported_assets = new Set();
2424

25+
/** @type {Map<string, { css: Set<string>; assets: Set<string> }>} */
26+
const stylesheet_map = new Map();
27+
2528
/**
2629
* @param {string} current
2730
* @param {boolean} add_js
31+
* @param {string} initial_importer
32+
* @param {number} dynamic_import_depth
2833
*/
29-
function traverse(current, add_js) {
34+
function traverse(current, add_js, initial_importer, dynamic_import_depth) {
3035
if (seen.has(current)) return;
3136
seen.add(current);
3237

@@ -35,27 +40,46 @@ export function find_deps(manifest, entry, add_dynamic_css) {
3540
if (add_js) imports.add(chunk.file);
3641

3742
if (chunk.assets) {
38-
for (const asset of chunk.assets) {
39-
imported_assets.add(asset);
40-
}
43+
chunk.assets.forEach(asset => imported_assets.add(asset));
4144
}
4245

4346
if (chunk.css) {
4447
chunk.css.forEach((file) => stylesheets.add(file));
4548
}
4649

4750
if (chunk.imports) {
48-
chunk.imports.forEach((file) => traverse(file, add_js));
51+
chunk.imports.forEach((file) => traverse(file, add_js, initial_importer, dynamic_import_depth));
52+
}
53+
54+
if (!add_dynamic_css) return;
55+
56+
if ((chunk.css || chunk.assets) && dynamic_import_depth <= 1) {
57+
// group files based on the initial importer because if a file is only ever
58+
// a transitive dependency, it doesn't have a suitable name we can map back to
59+
// the server manifest
60+
if (stylesheet_map.has(initial_importer)) {
61+
const { css, assets } = /** @type {{ css: Set<string>; assets: Set<string> }} */ (stylesheet_map.get(initial_importer));
62+
if (chunk.css) chunk.css.forEach((file) => css.add(file));
63+
if (chunk.assets) chunk.assets.forEach((file) => assets.add(file));
64+
} else {
65+
stylesheet_map.set(initial_importer, {
66+
css: new Set(chunk.css),
67+
assets: new Set(chunk.assets)
68+
});
69+
}
4970
}
5071

51-
if (add_dynamic_css && chunk.dynamicImports) {
52-
chunk.dynamicImports.forEach((file) => traverse(file, false));
72+
if (chunk.dynamicImports) {
73+
dynamic_import_depth++;
74+
chunk.dynamicImports.forEach((file) => {
75+
traverse(file, false, file, dynamic_import_depth);
76+
});
5377
}
5478
}
5579

5680
const { chunk, file } = resolve_symlinks(manifest, entry);
5781

58-
traverse(file, true);
82+
traverse(file, true, entry, 0);
5983

6084
const assets = Array.from(imported_assets);
6185

@@ -65,7 +89,8 @@ export function find_deps(manifest, entry, add_dynamic_css) {
6589
imports: Array.from(imports),
6690
stylesheets: Array.from(stylesheets),
6791
// TODO do we need this separately?
68-
fonts: assets.filter((asset) => /\.(woff2?|ttf|otf)$/.test(asset))
92+
fonts: filter_fonts(assets),
93+
stylesheet_map
6994
};
7095
}
7196

@@ -85,7 +110,15 @@ export function resolve_symlinks(manifest, file) {
85110
return { chunk, file };
86111
}
87112

88-
const method_names = new Set(['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS']);
113+
/**
114+
* @param {string[]} assets
115+
* @returns {string[]}
116+
*/
117+
export function filter_fonts(assets) {
118+
return assets.filter((asset) => /\.(woff2?|ttf|otf)$/.test(asset));
119+
}
120+
121+
const method_names = new Set((['GET', 'HEAD', 'PUT', 'POST', 'DELETE', 'PATCH', 'OPTIONS']));
89122

90123
// If we'd written this in TypeScript, it could be easy...
91124
/**

packages/kit/src/types/internal.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface AssetDependencies {
5959
imports: string[];
6060
stylesheets: string[];
6161
fonts: string[];
62+
stylesheet_map: Map<string, { css: Set<string>; assets: Set<string> }>;
6263
}
6364

6465
export interface BuildData {
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<script>
2+
let Dynamic;
3+
</script>
4+
5+
<button
6+
on:click={async () => {
7+
Dynamic = (await import('./Dynamic.svelte')).default;
8+
}}>load component</button
9+
>
10+
11+
<svelte:component this={Dynamic}></svelte:component>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
<p>I'm dynamically imported</p>
2+
3+
<style>
4+
p {
5+
color: blue;
6+
}
7+
</style>

packages/kit/test/apps/basics/test/cross-platform/client.test.js

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,24 @@ test.describe('CSS', () => {
532532

533533
expect(await get_computed_style('#svelte-announcer', 'position')).toBe('absolute');
534534
});
535+
536+
test('dynamically imported components lazily load CSS', async ({ page, get_computed_style }) => {
537+
const requests = [];
538+
page.on('request', (request) => {
539+
const url = request.url();
540+
if (url.includes('Dynamic') && url.endsWith('.css')) {
541+
requests.push(url);
542+
}
543+
});
544+
545+
await page.goto('/css/dynamic');
546+
expect(requests.length).toBe(0);
547+
548+
await page.locator('button').click();
549+
await expect(page.locator('p')).toHaveText("I'm dynamically imported");
550+
expect(await get_computed_style('p', 'color')).toBe('rgb(0, 0, 255)');
551+
expect(requests.length).toBe(1);
552+
});
535553
});
536554

537555
test.describe.serial('Errors', () => {

0 commit comments

Comments
 (0)