Skip to content
Merged
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/rich-taxis-hear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'svelte': patch
---

feat: include CSS in `<head>` when `css: 'injected'`
Original file line number Diff line number Diff line change
Expand Up @@ -332,22 +332,16 @@ export function client_component(source, analysis, options) {
}
}

const append_styles =
analysis.inject_styles && analysis.css.ast
? () =>
component_block.body.push(
b.stmt(
b.call(
'$.append_styles',
b.id('$$anchor'),
b.literal(analysis.css.hash),
b.literal(render_stylesheet(source, analysis, options).code)
)
)
)
: () => {};
if (analysis.css.ast !== null && analysis.inject_styles) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);

append_styles();
state.hoisted.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));

component_block.body.unshift(
b.stmt(b.call('$.append_styles', b.id('$$anchor'), b.id('$$css')))
);
}

const should_inject_context =
analysis.needs_context ||
Expand Down Expand Up @@ -423,17 +417,34 @@ export function client_component(source, analysis, options) {
);

if (options.hmr) {
const accept_fn = b.arrow(
[b.id('module')],
b.block([b.stmt(b.call('$.set', b.id('s'), b.member(b.id('module'), b.id('default'))))])
);
const accept_fn_body = [
b.stmt(b.call('$.set', b.id('s'), b.member(b.id('module'), b.id('default'))))
];

if (analysis.css.hash) {
// remove existing `<style>` element, in case CSS changed
accept_fn_body.unshift(
b.stmt(
b.call(
b.member(
b.call('document.querySelector', b.literal('#' + analysis.css.hash)),
b.id('remove'),
false,
true
)
)
)
);
}

body.push(
component,
b.if(
b.id('import.meta.hot'),
b.block([
b.const(b.id('s'), b.call('$.source', b.id(analysis.name))),
b.const(b.id('filename'), b.member(b.id(analysis.name), b.id('filename'))),
b.const(b.id('accept'), b.arrow([b.id('module')], b.block(accept_fn_body))),
b.stmt(b.assignment('=', b.id(analysis.name), b.call('$.hmr', b.id('s')))),
b.stmt(
b.assignment('=', b.member(b.id(analysis.name), b.id('filename')), b.id('filename'))
Expand All @@ -442,10 +453,14 @@ export function client_component(source, analysis, options) {
b.id('import.meta.hot.acceptExports'),
b.block([
b.stmt(
b.call('import.meta.hot.acceptExports', b.array([b.literal('default')]), accept_fn)
b.call(
'import.meta.hot.acceptExports',
b.array([b.literal('default')]),
b.id('accept')
)
)
]),
b.block([b.stmt(b.call('import.meta.hot.accept', accept_fn))])
b.block([b.stmt(b.call('import.meta.hot.accept', b.id('accept')))])
)
])
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
BLOCK_OPEN_ELSE
} from '../../../../internal/server/hydration.js';
import { filename, locator } from '../../../state.js';
import { render_stylesheet } from '../css/index.js';

/** Opens an if/each block, so that we can remove nodes in the case of a mismatch */
const block_open = b.literal(BLOCK_OPEN);
Expand Down Expand Up @@ -2158,6 +2159,14 @@ export function server_component(analysis, options) {

const body = [...state.hoisted, ...module.body];

if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) {
const hash = b.literal(analysis.css.hash);
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);

body.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));
component_block.body.unshift(b.stmt(b.call('$$payload.css.add', b.id('$$css'))));
}

let should_inject_props =
should_inject_context ||
props.length > 0 ||
Expand Down
4 changes: 2 additions & 2 deletions packages/svelte/src/compiler/types/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export interface CompileOptions extends ModuleCompileOptions {
*/
immutable?: boolean;
/**
* - `'injected'`: styles will be included in the JavaScript class and injected at runtime for the components actually rendered.
* - `'external'`: the CSS will be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* - `'injected'`: styles will be included in the `head` when using `render(...)`, and injected into the document (if not already present) when the component mounts. For components compiled as custom elements, styles are injected to the shadow root.
* - `'external'`: the CSS will only be returned in the `css` field of the compilation result. Most Svelte bundler plugins will set this to `'external'` and use the CSS that is statically generated for better performance, as it will result in smaller JavaScript bundles and the output can be served as cacheable `.css` files.
* This is always `'injected'` when compiling with `customElement` mode.
*/
css?: 'injected' | 'external';
Expand Down
33 changes: 33 additions & 0 deletions packages/svelte/src/internal/client/dom/css.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DEV } from 'esm-env';
import { queue_micro_task } from './task.js';

var seen = new Set();

/**
* @param {Node} anchor
* @param {{ hash: string, code: string }} css
*/
export function append_styles(anchor, css) {
// in dev, always check the DOM, so that styles can be replaced with HMR
if (!DEV) {
if (seen.has(css)) return;
seen.add(css);
}

// Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
queue_micro_task(() => {
var root = anchor.getRootNode();

var target = /** @type {ShadowRoot} */ (root).host
? /** @type {ShadowRoot} */ (root)
: /** @type {Document} */ (root).head;

if (!target.querySelector('#' + css.hash)) {
const style = document.createElement('style');
style.id = css.hash;
style.textContent = css.code;

target.appendChild(style);
}
});
}
3 changes: 2 additions & 1 deletion packages/svelte/src/internal/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export { snippet, wrap_snippet } from './dom/blocks/snippet.js';
export { component } from './dom/blocks/svelte-component.js';
export { element } from './dom/blocks/svelte-element.js';
export { head } from './dom/blocks/svelte-head.js';
export { append_styles } from './dom/css.js';
export { action } from './dom/elements/actions.js';
export {
remove_input_defaults,
Expand Down Expand Up @@ -120,7 +121,7 @@ export {
update_pre_store,
update_store
} from './reactivity/store.js';
export { append_styles, set_text } from './render.js';
export { set_text } from './render.js';
export {
get,
invalidate_inner_signals,
Expand Down
33 changes: 1 addition & 32 deletions packages/svelte/src/internal/client/render.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import * as w from './warnings.js';
import * as e from './errors.js';
import { validate_component } from '../shared/validate.js';
import { assign_nodes } from './dom/template.js';
import { queue_micro_task } from './dom/task.js';

/** @type {Set<string>} */
export const all_registered_events = new Set();
Expand Down Expand Up @@ -294,35 +295,3 @@ export function unmount(component) {
}
fn?.();
}

/**
* @param {Node} target
* @param {string} style_sheet_id
* @param {string} styles
*/
export async function append_styles(target, style_sheet_id, styles) {
// Wait a tick so that the template is added to the dom, else getRootNode() will yield wrong results
// If it turns out that this results in noticeable flickering, we need to do something like doing the
// append outside and adding code in mount that appends all stylesheets (similar to how we do it with event delegation)
await Promise.resolve();
const append_styles_to = get_root_for_style(target);
if (!append_styles_to.getElementById(style_sheet_id)) {
const style = document.createElement('style');
style.id = style_sheet_id;
style.textContent = styles;
const target = /** @type {Document} */ (append_styles_to).head || append_styles_to;
target.appendChild(style);
}
}

/**
* @param {Node} node
*/
function get_root_for_style(node) {
if (!node) return document;
const root = node.getRootNode ? node.getRootNode() : node.ownerDocument;
if (root && /** @type {ShadowRoot} */ (root).host) {
return /** @type {ShadowRoot} */ (root);
}
return /** @type {Document} */ (node.ownerDocument);
}
13 changes: 10 additions & 3 deletions packages/svelte/src/internal/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ export const VoidElements = new Set([
* @param {Payload} to_copy
* @returns {Payload}
*/
export function copy_payload({ out, head }) {
export function copy_payload({ out, css, head }) {
return {
out,
css: new Set(css),
head: {
title: head.title,
out: head.out
Expand Down Expand Up @@ -107,7 +108,7 @@ export let on_destroy = [];
*/
export function render(component, options = {}) {
/** @type {Payload} */
const payload = { out: '', head: { title: '', out: '' } };
const payload = { out: '', css: new Set(), head: { title: '', out: '' } };

const prev_on_destroy = on_destroy;
on_destroy = [];
Expand All @@ -129,8 +130,14 @@ export function render(component, options = {}) {
for (const cleanup of on_destroy) cleanup();
on_destroy = prev_on_destroy;

let head = payload.head.out + payload.head.title;

for (const { hash, code } of payload.css) {
head += `<style id="${hash}">${code}</style>`;
}

return {
head: payload.head.out || payload.head.title ? payload.head.out + payload.head.title : '',
head,
html: payload.out,
body: payload.out
};
Expand Down
1 change: 1 addition & 0 deletions packages/svelte/src/internal/server/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Component {

export interface Payload {
out: string;
css: Set<{ hash: string; code: string }>;
head: {
title: string;
out: string;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { test } from '../../test';

// Test validates that by default no CSS is rendered on the server
export default test({});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="foo svelte-sg04hs">foo</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="foo">foo</div>

<style>
.foo {
color: red;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { test } from '../../test';

export default test({
compileOptions: {
css: 'injected'
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

.foo.svelte-sg04hs {
color: red;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<div class="foo svelte-sg04hs">foo</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<style id="svelte-sg04hs">
.foo.svelte-sg04hs {
color: red;
}
</style>
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
<div class="foo">foo</div>

<style>
.foo {
color: red;
}
</style>
17 changes: 10 additions & 7 deletions packages/svelte/tests/server-side-rendering/test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
// TODO: happy-dom might be faster but currently replaces quotes which fails assertions

import * as fs from 'node:fs';
import { assert } from 'vitest';
import { render } from 'svelte/server';
import { compile_directory, should_update_expected, try_read_file } from '../helpers.js';
import { assert_html_equal_with_options } from '../html_equal.js';
Expand All @@ -25,7 +26,11 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
const rendered = render(Component, { props: config.props || {} });
const { body, head } = rendered;

fs.writeFileSync(`${test_dir}/_actual.html`, body);
fs.writeFileSync(`${test_dir}/_output/rendered.html`, body);

if (head) {
fs.writeFileSync(`${test_dir}/_output/rendered_head.html`, head);
}

try {
assert_html_equal_with_options(body, expected_html || '', {
Expand All @@ -42,19 +47,17 @@ const { test, run } = suite<SSRTest>(async (config, test_dir) => {
}
}

if (fs.existsSync(`${test_dir}/_expected-head.html`)) {
fs.writeFileSync(`${test_dir}/_actual-head.html`, head);

if (fs.existsSync(`${test_dir}/_expected_head.html`)) {
try {
assert_html_equal_with_options(
head,
fs.readFileSync(`${test_dir}/_expected-head.html`, 'utf-8'),
fs.readFileSync(`${test_dir}/_expected_head.html`, 'utf-8'),
{}
);
} catch (error: any) {
if (should_update_expected()) {
fs.writeFileSync(`${test_dir}/_expected-head.html`, head);
console.log(`Updated ${test_dir}/_expected-head.html.`);
fs.writeFileSync(`${test_dir}/_expected_head.html`, head);
console.log(`Updated ${test_dir}/_expected_head.html.`);
error.message += '\n' + `${test_dir}/main.svelte`;
} else {
throw error;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,17 +13,17 @@ if (import.meta.hot) {
const s = $.source(Hmr);
const filename = Hmr.filename;

const accept = (module) => {
$.set(s, module.default);
};

Hmr = $.hmr(s);
Hmr.filename = filename;

if (import.meta.hot.acceptExports) {
import.meta.hot.acceptExports(["default"], (module) => {
$.set(s, module.default);
});
import.meta.hot.acceptExports(["default"], accept);
} else {
import.meta.hot.accept((module) => {
$.set(s, module.default);
});
import.meta.hot.accept(accept);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ export default test({
async test({ assert, code_client }) {
// Check that the css source map embedded in the js is accurate
const match = code_client.match(
/append_styles\(\$\$anchor, "svelte-.{6}", "(.*?)(?:\\n\/\*# sourceMappingURL=data:(.*?);charset=(.*?);base64,(.*?) \*\/)?"\);/
/code: "(.*?)(?:\\n\/\*# sourceMappingURL=data:(.*?);charset=(.*?);base64,(.*?) \*\/)?"/
);

assert.notEqual(match, null);
assert.ok(match);

const [css, mime_type, encoding, css_map_base64] = /** @type {RegExpMatchArray} */ (
match
Expand Down
Loading