Skip to content

fix: include components dynamically imported in the universal load function when inlining CSS #13723

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

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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
5 changes: 5 additions & 0 deletions .changeset/spicy-cougars-wave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

fix: correctly inline stylesheets of components dynamically imported in a universal load function if they are below the configured inlineStyleThreshold
2 changes: 1 addition & 1 deletion packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ export interface KitConfig {
errorTemplate?: string;
};
/**
* 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.
* 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.
*
* > [!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.
* @default 0
Expand Down
163 changes: 94 additions & 69 deletions packages/kit/src/exports/vite/build/build_server.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,65 +11,48 @@ import { basename } from 'node:path';
* @param {import('types').ManifestData} manifest_data
* @param {import('vite').Manifest} server_manifest
* @param {import('vite').Manifest | null} client_manifest
* @param {import('vite').Rollup.OutputAsset[] | null} css
* @param {import('vite').Rollup.OutputBundle | null} server_bundle
* @param {import('vite').Rollup.RollupOutput['output'] | null} client_bundle
* @param {import('types').RecursiveRequired<import('types').ValidatedConfig['kit']['output']>} output_config
*/
export function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, css, output_config) {
export function build_server_nodes(out, kit, manifest_data, server_manifest, client_manifest, server_bundle, client_bundle, output_config) {
mkdirp(`${out}/server/nodes`);
mkdirp(`${out}/server/stylesheets`);

/** @type {Map<string, string>} */
const stylesheet_lookup = new Map();

if (css) {
/** @type {Set<string>} */
const client_stylesheets = new Set();
for (const key in client_manifest) {
client_manifest[key].css?.forEach((filename) => {
client_stylesheets.add(filename);
});
}
const stylesheets_to_inline = new Map();

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

const { stylesheets } = find_deps(server_manifest, node.component, false);
const server_chunks = Object.values(server_bundle);
const server = get_stylesheets(server_chunks);

if (stylesheets.length) {
server_stylesheets.set(i, stylesheets);
}
});

for (const asset of css) {
// ignore dynamically imported stylesheets since we don't need to inline those
if (!client_stylesheets.has(asset.fileName) || asset.source.length >= kit.inlineStyleThreshold) {
// map server stylesheet name to the client stylesheet name
for (const [id, client_stylesheet] of client.stylesheets_used) {
const server_stylesheet = server.stylesheets_used.get(id);
if (!server_stylesheet) {
continue;
}
client_stylesheet.forEach((file, i) => {
stylesheets_to_inline.set(file, server_stylesheet[i]);
})
}

// We know that the names for entry points are numbers.
const [index] = basename(asset.fileName).split('.');
// There can also be other CSS files from shared components
// for example, which we need to ignore here.
if (isNaN(+index)) continue;

const file = `${out}/server/stylesheets/${index}.js`;

// we need to inline the server stylesheet instead of the client one
// so that asset paths are correct on document load
const filenames = server_stylesheets.get(+index);

if (!filenames) {
throw new Error('This should never happen, but if it does, it means we failed to find the server stylesheet for a node.');
// filter out stylesheets that should not be inlined
for (const [fileName, content] of client.stylesheet_content) {
if (content.length >= kit.inlineStyleThreshold) {
stylesheets_to_inline.delete(fileName);
}
}

const sources = filenames.map((filename) => {
return fs.readFileSync(`${out}/server/${filename}`, 'utf-8');
});
fs.writeFileSync(file, `// ${filenames.join(', ')}\nexport default ${s(sources.join('\n'))};`);

stylesheet_lookup.set(asset.fileName, index);
// 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}`);
}
stylesheets_to_inline.set(client_file, source);
}
}

Expand Down Expand Up @@ -122,8 +105,9 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
const entry_path = `${normalizePath(kit.outDir)}/generated/client-optimized/nodes/${i}.js`;
const entry = find_deps(client_manifest, entry_path, true);

// eagerly load stylesheets and fonts imported by the SSR-ed page to avoid FOUC.
// If it is not used during SSR, it can be lazily loaded in the browser.
// 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;
Expand All @@ -138,26 +122,26 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
}

/** @type {Set<string>} */
const css_used_by_server = new Set();
const eager_css = new Set();
/** @type {Set<string>} */
const assets_used_by_server = new Set();
const eager_assets = new Set();

entry.stylesheet_map.forEach((value, key) => {
// pages and layouts are named as node indexes in the client manifest
// so we need to use the original filename when checking against the server manifest
if (key === entry_path) {
key = node.component ?? key;
entry.stylesheet_map.forEach((value, filepath) => {
// pages and layouts are renamed to node indexes when optimised for the client
// so we use the original filename instead to check against the server manifest
if (filepath === entry_path) {
filepath = node.component ?? filepath;
}

if (component?.stylesheet_map.has(key) || universal?.stylesheet_map.has(key)) {
value.css.forEach(file => css_used_by_server.add(file));
value.assets.forEach(file => assets_used_by_server.add(file));
if (component?.stylesheet_map.has(filepath) || universal?.stylesheet_map.has(filepath)) {
value.css.forEach(file => eager_css.add(file));
value.assets.forEach(file => eager_assets.add(file));
}
});

imported = entry.imports;
stylesheets = Array.from(css_used_by_server);
fonts = filter_fonts(Array.from(assets_used_by_server));
stylesheets = Array.from(eager_css);
fonts = filter_fonts(Array.from(eager_assets));
}

exports.push(
Expand All @@ -167,19 +151,26 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
);

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

stylesheets.forEach((file) => {
if (stylesheet_lookup.has(file)) {
const index = stylesheet_lookup.get(file);
const name = `stylesheet_${index}`;
imports.push(`import ${name} from '../stylesheets/${index}.js';`);
styles.push(`\t${s(file)}: ${name}`);
const inline_styles = [];

stylesheets.forEach((file, i) => {
if (stylesheets_to_inline.has(file)) {
const filename = basename(file);
const dest = `${out}/server/stylesheets/${filename}.js`;
const source = stylesheets_to_inline.get(file);
if (!source) {
throw new Error(`Server stylesheet source not found for client stylesheet ${file}`);
}
fs.writeFileSync(dest, `// ${filename}\nexport default ${s(source)};`);

const name = `stylesheet_${i}`;
imports.push(`import ${name} from '../stylesheets/${filename}.js';`);
inline_styles.push(`\t${s(file)}: ${name}`);
}
});

if (styles.length > 0) {
exports.push(`export const inline_styles = () => ({\n${styles.join(',\n')}\n});`);
if (inline_styles.length > 0) {
exports.push(`export const inline_styles = () => ({\n${inline_styles.join(',\n')}\n});`);
}

fs.writeFileSync(
Expand All @@ -188,3 +179,37 @@ export function build_server_nodes(out, kit, manifest_data, server_manifest, cli
);
});
}

/**
* @param {(import('vite').Rollup.OutputAsset | import('vite').Rollup.OutputChunk)[]} chunks
*/
function get_stylesheets(chunks) {
/**
* A map of module IDs and the stylesheets they use.
* @type {Map<string, string[]>}
*/
const stylesheets_used = new Map();

/**
* A map of stylesheet names and their content.
* @type {Map<string, string>}
*/
const stylesheet_content = new Map();

for (const chunk of chunks) {
if (chunk.type === 'asset') {
if (chunk.fileName.endsWith('.css')) {
stylesheet_content.set(chunk.fileName, chunk.source.toString());
}
continue;
}

if (chunk.viteMetadata?.importedCss.size) {
const css = Array.from(chunk.viteMetadata.importedCss);
for (const id of chunk.moduleIds) {
stylesheets_used.set(id, css );
}
}
}
return { stylesheets_used, stylesheet_content };
}
20 changes: 9 additions & 11 deletions packages/kit/src/exports/vite/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,7 @@ let manifest_data;
* @return {Promise<import('vite').Plugin[]>}
*/
async function kit({ svelte_config }) {
/** @type {import('vite')} */
const vite = await resolve_peer_dependency('vite');

const { kit } = svelte_config;
Expand Down Expand Up @@ -787,7 +788,7 @@ Tips:
*/
writeBundle: {
sequential: true,
async handler(_options) {
async handler(_options, bundle) {
if (secondary_build_started) return; // only run this once

const verbose = vite_config.logLevel === 'info';
Expand Down Expand Up @@ -828,6 +829,7 @@ Tips:
server_manifest,
null,
null,
null,
svelte_config.output
);

Expand All @@ -852,7 +854,7 @@ Tips:

secondary_build_started = true;

const { output } = /** @type {import('vite').Rollup.RollupOutput} */ (
const { output: client_chunks } = /** @type {import('vite').Rollup.RollupOutput} */ (
await vite.build({
configFile: vite_config.configFile,
// CLI args
Expand Down Expand Up @@ -895,7 +897,7 @@ Tips:
imports: [...start.imports, ...app.imports],
stylesheets: [...start.stylesheets, ...app.stylesheets],
fonts: [...start.fonts, ...app.fonts],
uses_env_dynamic_public: output.some(
uses_env_dynamic_public: client_chunks.some(
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
)
};
Expand Down Expand Up @@ -944,14 +946,14 @@ Tips:
imports: start.imports,
stylesheets: start.stylesheets,
fonts: start.fonts,
uses_env_dynamic_public: output.some(
uses_env_dynamic_public: client_chunks.some(
(chunk) => chunk.type === 'chunk' && chunk.modules[env_dynamic_public]
)
};

if (svelte_config.kit.output.bundleStrategy === 'inline') {
const style = /** @type {import('rollup').OutputAsset} */ (
output.find(
client_chunks.find(
(chunk) =>
chunk.type === 'asset' &&
chunk.names.length === 1 &&
Expand All @@ -966,11 +968,6 @@ Tips:
}
}

const css = output.filter(
/** @type {(value: any) => value is import('vite').Rollup.OutputAsset} */
(value) => value.type === 'asset' && value.fileName.endsWith('.css')
);

// regenerate manifest now that we have client entry...
fs.writeFileSync(
manifest_path,
Expand All @@ -989,7 +986,8 @@ Tips:
manifest_data,
server_manifest,
client_manifest,
css,
bundle,
client_chunks,
svelte_config.kit.output
);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export async function load() {
return {
Thing: (await import('./Thing.svelte')).default
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<script>
export let data;
</script>

<svelte:component this={data.Thing} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<p>I'm dynamically imported</p>

<style>
p {
color: blue;
font-size: 20px;
}
</style>
Loading
Loading