Skip to content

Commit 431fb02

Browse files
committed
feat: enhance CSS chunking and similarity detection in server builds
- Implement consistent CSS chunking between client and server builds to improve inlining. - Add functions to calculate CSS content similarity and extract base names from filenames. - Introduce a mapping strategy for client-to-server stylesheet matching based on content similarity and filename fallback. - Update logic to filter out stylesheets based on size and verify content similarity to prevent mapping errors. This improves the handling of stylesheets in SvelteKit, ensuring better performance and consistency.
1 parent 497f616 commit 431fb02

File tree

3 files changed

+147
-15
lines changed

3 files changed

+147
-15
lines changed

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

Lines changed: 125 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,38 @@ import { normalizePath } from 'vite';
66
import { basename, join } from 'node:path';
77
import { create_node_analyser } from '../static_analysis/index.js';
88

9+
/**
10+
* Calculate similarity between two CSS content strings
11+
* @param {string} content1
12+
* @param {string} content2
13+
* @returns {number} Similarity score between 0 and 1
14+
*/
15+
function calculateCSSContentSimilarity(content1, content2) {
16+
if (content1 === content2) return 1;
17+
18+
// Normalize CSS content for comparison
19+
const normalize = (/** @type {string} */ css) => css.replace(/\s+/g, ' ').replace(/;\s*}/g, '}').trim();
20+
const norm1 = normalize(content1);
21+
const norm2 = normalize(content2);
22+
23+
if (norm1 === norm2) return 1;
24+
25+
// Simple length-based similarity
26+
const lengthDiff = Math.abs(norm1.length - norm2.length);
27+
const maxLength = Math.max(norm1.length, norm2.length);
28+
return maxLength > 0 ? 1 - (lengthDiff / maxLength) : 0;
29+
}
30+
31+
/**
32+
* Extract base name from CSS filename
33+
* @param {string} filename
34+
* @returns {string}
35+
*/
36+
function extractCSSBaseName(filename) {
37+
const basename = filename.split('/').pop() || '';
38+
return basename.split('.')[0] || basename;
39+
}
40+
941

1042
/**
1143
* @param {string} out
@@ -29,31 +61,110 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
2961
const client = get_stylesheets(client_chunks);
3062
const server = get_stylesheets(Object.values(server_bundle));
3163

32-
// map server stylesheet name to the client stylesheet name
64+
65+
66+
// Create a separate map for client-to-server file mapping
67+
/** @type {Map<string, string>} */
68+
const client_to_server_files = new Map();
69+
70+
// Enhanced mapping strategy with multiple fallback mechanisms
3371
for (const [id, client_stylesheet] of client.stylesheets_used) {
3472
const server_stylesheet = server.stylesheets_used.get(id);
3573
if (!server_stylesheet) {
74+
// Try to find CSS files with the same content in server build
75+
for (const client_file of client_stylesheet) {
76+
const client_content = client.stylesheet_content.get(client_file);
77+
if (client_content) {
78+
// Find server file with matching content
79+
for (const [server_file, server_content] of server.stylesheet_content) {
80+
if (client_content === server_content) {
81+
client_to_server_files.set(client_file, server_file);
82+
break;
83+
}
84+
}
85+
}
86+
}
3687
continue;
3788
}
38-
client_stylesheet.forEach((file, i) => {
39-
stylesheets_to_inline.set(file, server_stylesheet[i]);
40-
})
89+
90+
// Strategy 1: Direct index mapping (works when chunking is consistent)
91+
if (client_stylesheet.length === server_stylesheet.length) {
92+
client_stylesheet.forEach((client_file, i) => {
93+
if (server_stylesheet[i]) {
94+
client_to_server_files.set(client_file, server_stylesheet[i]);
95+
}
96+
});
97+
} else {
98+
// Strategy 2: Content-based matching (most reliable)
99+
for (const client_file of client_stylesheet) {
100+
const client_content = client.stylesheet_content.get(client_file);
101+
if (!client_content) continue;
102+
103+
let best_match = null;
104+
let best_similarity = 0;
105+
106+
for (const server_file of server_stylesheet) {
107+
const server_content = server.stylesheet_content.get(server_file);
108+
if (!server_content) continue;
109+
110+
// Calculate content similarity
111+
const similarity = calculateCSSContentSimilarity(client_content, server_content);
112+
if (similarity > best_similarity && similarity > 0.8) {
113+
best_similarity = similarity;
114+
best_match = server_file;
115+
}
116+
}
117+
118+
if (best_match) {
119+
client_to_server_files.set(client_file, best_match);
120+
} else {
121+
// Strategy 3: Filename-based fallback
122+
const client_base = extractCSSBaseName(client_file);
123+
const matching_server_file = server_stylesheet.find(server_file => {
124+
const server_base = extractCSSBaseName(server_file);
125+
return client_base === server_base;
126+
});
127+
128+
if (matching_server_file) {
129+
client_to_server_files.set(client_file, matching_server_file);
130+
} else {
131+
console.warn(`[SvelteKit CSS] No matching server stylesheet found for client file: ${client_file} (module: ${id})`);
132+
}
133+
}
134+
}
135+
}
41136
}
42137

43-
// filter out stylesheets that should not be inlined
138+
// filter out stylesheets that should not be inlined based on size
44139
for (const [fileName, content] of client.stylesheet_content) {
45140
if (content.length >= kit.inlineStyleThreshold) {
46-
stylesheets_to_inline.delete(fileName);
141+
client_to_server_files.delete(fileName);
47142
}
48143
}
49144

50-
// map server stylesheet source to the client stylesheet name
51-
for (const [client_file, server_file] of stylesheets_to_inline) {
52-
const source = server.stylesheet_content.get(server_file);
53-
if (!source) {
54-
throw new Error(`Server stylesheet source not found for client stylesheet ${client_file}`);
145+
// map client stylesheet name to the server stylesheet source content
146+
for (const [client_file, server_file] of client_to_server_files) {
147+
const client_content = client.stylesheet_content.get(client_file);
148+
const server_content = server.stylesheet_content.get(server_file);
149+
150+
if (!server_content) {
151+
console.warn(`[SvelteKit CSS] Server stylesheet source not found for: ${server_file}, skipping ${client_file}`);
152+
continue;
55153
}
56-
stylesheets_to_inline.set(client_file, source);
154+
155+
// Verify content similarity to catch mapping errors
156+
if (client_content && server_content) {
157+
// Simple similarity check: compare normalized lengths
158+
const client_normalized = client_content.replace(/\s+/g, '').length;
159+
const server_normalized = server_content.replace(/\s+/g, '').length;
160+
const length_diff = Math.abs(client_normalized - server_normalized) / Math.max(client_normalized, server_normalized);
161+
162+
if (length_diff > 0.5) {
163+
console.warn(`[SvelteKit CSS] Content mismatch detected: ${client_file} -> ${server_file} (${Math.round(length_diff * 100)}% difference), using server content`);
164+
}
165+
}
166+
167+
stylesheets_to_inline.set(client_file, server_content);
57168
}
58169
}
59170

@@ -123,7 +234,7 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
123234
// eagerly load client stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
124235
// However, if it is not used during SSR (not present in the server manifest),
125236
// then it can be lazily loaded in the browser.
126-
237+
127238
/** @type {import('types').AssetDependencies | undefined} */
128239
let component;
129240
if (node.component) {
@@ -196,7 +307,7 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
196307
}
197308

198309
/**
199-
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
310+
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
200311
*/
201312
function get_stylesheets(chunks) {
202313
/**

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -678,6 +678,27 @@ Tips:
678678
sourcemapIgnoreList,
679679
inlineDynamicImports: !split
680680
},
681+
// Ensure consistent CSS chunking between client and server builds for CSS inlining
682+
...(kit.inlineStyleThreshold > 0
683+
? {
684+
manualChunks: (/** @type {string} */ id) => {
685+
// Group CSS files by their base name to ensure consistent chunking
686+
if (id.endsWith('.css') || id.includes('?svelte&type=style')) {
687+
// Extract component name from file path for consistent chunking
688+
const matches = id.match(/([^/\\]+)\.svelte/);
689+
if (matches) {
690+
return `css-${matches[1]}`;
691+
}
692+
// For other CSS files, use filename
693+
const filename = id.split('/').pop()?.split('.')[0];
694+
if (filename) {
695+
return `css-${filename}`;
696+
}
697+
}
698+
return null;
699+
}
700+
}
701+
: {}),
681702
preserveEntrySignatures: 'strict',
682703
onwarn(warning, handler) {
683704
if (

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export async function render_response({
8181

8282
const form_value =
8383
action_result?.type === 'success' || action_result?.type === 'failure'
84-
? (action_result.data ?? null)
84+
? action_result.data ?? null
8585
: null;
8686

8787
/** @type {string} */

0 commit comments

Comments
 (0)