Skip to content

fix: CSS inlining failures causing missing styles in production builds #14007

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
139 changes: 125 additions & 14 deletions packages/kit/src/exports/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ import { normalizePath } from 'vite';
import { basename, join } from 'node:path';
import { create_node_analyser } from '../static_analysis/index.js';

/**
* Calculate similarity between two CSS content strings
* @param {string} content1
* @param {string} content2
* @returns {number} Similarity score between 0 and 1
*/
function calculateCSSContentSimilarity(content1, content2) {
if (content1 === content2) return 1;

// Normalize CSS content for comparison
const normalize = (/** @type {string} */ css) => css.replace(/\s+/g, ' ').replace(/;\s*}/g, '}').trim();
const norm1 = normalize(content1);
const norm2 = normalize(content2);

if (norm1 === norm2) return 1;

// Simple length-based similarity
const lengthDiff = Math.abs(norm1.length - norm2.length);
const maxLength = Math.max(norm1.length, norm2.length);
return maxLength > 0 ? 1 - (lengthDiff / maxLength) : 0;
}

/**
* Extract base name from CSS filename
* @param {string} filename
* @returns {string}
*/
function extractCSSBaseName(filename) {
const basename = filename.split('/').pop() || '';
return basename.split('.')[0] || basename;
}


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

// map server stylesheet name to the client stylesheet name


// Create a separate map for client-to-server file mapping
/** @type {Map<string, string>} */
const client_to_server_files = new Map();

// Enhanced mapping strategy with multiple fallback mechanisms
for (const [id, client_stylesheet] of client.stylesheets_used) {
const server_stylesheet = server.stylesheets_used.get(id);
if (!server_stylesheet) {
// Try to find CSS files with the same content in server build
for (const client_file of client_stylesheet) {
const client_content = client.stylesheet_content.get(client_file);
if (client_content) {
// Find server file with matching content
for (const [server_file, server_content] of server.stylesheet_content) {
if (client_content === server_content) {
client_to_server_files.set(client_file, server_file);
break;
}
}
}
}
continue;
}
client_stylesheet.forEach((file, i) => {
stylesheets_to_inline.set(file, server_stylesheet[i]);
})

// Strategy 1: Direct index mapping (works when chunking is consistent)
if (client_stylesheet.length === server_stylesheet.length) {
client_stylesheet.forEach((client_file, i) => {
if (server_stylesheet[i]) {
client_to_server_files.set(client_file, server_stylesheet[i]);
}
});
} else {
// Strategy 2: Content-based matching (most reliable)
for (const client_file of client_stylesheet) {
const client_content = client.stylesheet_content.get(client_file);
if (!client_content) continue;

let best_match = null;
let best_similarity = 0;

for (const server_file of server_stylesheet) {
const server_content = server.stylesheet_content.get(server_file);
if (!server_content) continue;

// Calculate content similarity
const similarity = calculateCSSContentSimilarity(client_content, server_content);
if (similarity > best_similarity && similarity > 0.8) {
best_similarity = similarity;
best_match = server_file;
}
}

if (best_match) {
client_to_server_files.set(client_file, best_match);
} else {
// Strategy 3: Filename-based fallback
const client_base = extractCSSBaseName(client_file);
const matching_server_file = server_stylesheet.find(server_file => {
const server_base = extractCSSBaseName(server_file);
return client_base === server_base;
});

if (matching_server_file) {
client_to_server_files.set(client_file, matching_server_file);
} else {
console.warn(`[SvelteKit CSS] No matching server stylesheet found for client file: ${client_file} (module: ${id})`);
}
}
}
}
}

// filter out stylesheets that should not be inlined
// filter out stylesheets that should not be inlined based on size
for (const [fileName, content] of client.stylesheet_content) {
if (content.length >= kit.inlineStyleThreshold) {
stylesheets_to_inline.delete(fileName);
client_to_server_files.delete(fileName);
}
}

// map server stylesheet source to the client stylesheet name
for (const [client_file, server_file] of stylesheets_to_inline) {
const source = server.stylesheet_content.get(server_file);
if (!source) {
throw new Error(`Server stylesheet source not found for client stylesheet ${client_file}`);
// map client stylesheet name to the server stylesheet source content
for (const [client_file, server_file] of client_to_server_files) {
const client_content = client.stylesheet_content.get(client_file);
const server_content = server.stylesheet_content.get(server_file);

if (!server_content) {
console.warn(`[SvelteKit CSS] Server stylesheet source not found for: ${server_file}, skipping ${client_file}`);
continue;
}
stylesheets_to_inline.set(client_file, source);

// Verify content similarity to catch mapping errors
if (client_content && server_content) {
// Simple similarity check: compare normalized lengths
const client_normalized = client_content.replace(/\s+/g, '').length;
const server_normalized = server_content.replace(/\s+/g, '').length;
const length_diff = Math.abs(client_normalized - server_normalized) / Math.max(client_normalized, server_normalized);

if (length_diff > 0.5) {
console.warn(`[SvelteKit CSS] Content mismatch detected: ${client_file} -> ${server_file} (${Math.round(length_diff * 100)}% difference), using server content`);
}
}

stylesheets_to_inline.set(client_file, server_content);
}
}

Expand Down Expand Up @@ -123,7 +234,7 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
// eagerly load client stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
// However, if it is not used during SSR (not present in the server manifest),
// then it can be lazily loaded in the browser.

/** @type {import('types').AssetDependencies | undefined} */
let component;
if (node.component) {
Expand Down Expand Up @@ -196,7 +307,7 @@ export async function build_server_nodes(out, kit, manifest_data, server_manifes
}

/**
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
*/
function get_stylesheets(chunks) {
/**
Expand Down
21 changes: 21 additions & 0 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,27 @@ Tips:
sourcemapIgnoreList,
inlineDynamicImports: !split
},
// Ensure consistent CSS chunking between client and server builds for CSS inlining
...(kit.inlineStyleThreshold > 0
? {
manualChunks: (/** @type {string} */ id) => {
// Group CSS files by their base name to ensure consistent chunking
if (id.endsWith('.css') || id.includes('?svelte&type=style')) {
// Extract component name from file path for consistent chunking
const matches = id.match(/([^/\\]+)\.svelte/);
if (matches) {
return `css-${matches[1]}`;
}
// For other CSS files, use filename
const filename = id.split('/').pop()?.split('.')[0];
if (filename) {
return `css-${filename}`;
}
}
return null;
}
}
: {}),
preserveEntrySignatures: 'strict',
onwarn(warning, handler) {
if (
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function render_response({

const form_value =
action_result?.type === 'success' || action_result?.type === 'failure'
? (action_result.data ?? null)
? action_result.data ?? null
: null;

/** @type {string} */
Expand Down
Loading