feat: universal injected css (#12374)

* chore: reenable server CSS output through a compiler option

There are various use cases where this continues to be necessary/nice to have:
- rendering OG cards
- rendering emails
- basically anything where you use `render` manually and want to quickly stitch together the CSS without setting up an elaborate tooling chain

* cssRenderOnServer -> css: 'injected'

* update tests

* move append_styles into new module, update implementation

* get HMR working

* don't append styles to head when compiling as a custom element

* update changeset

* tweak

* tweak

* tweak wording

* update test

* fix

* reinstate optimisation, but without the bug

* fix sourcemap test

* move breaking change note

* Update packages/svelte/src/internal/server/index.js

Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>

---------

Co-authored-by: Simon Holthausen <simon.holthausen@vercel.com>
Co-authored-by: Simon H <5968653+dummdidumm@users.noreply.github.com>
pull/12366/head
Rich Harris 1 year ago committed by GitHub
parent 47a073e0db
commit a4f6407144
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
feat: include CSS in `<head>` when `css: 'injected'`

@ -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 ||
@ -423,10 +417,26 @@ 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(
@ -434,6 +444,7 @@ export function client_component(source, analysis, options) {
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'))
@ -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')))])
)
])
),

@ -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);
@ -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 ||

@ -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';

@ -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);
}
});
}

@ -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,
@ -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,

@ -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();
@ -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);
}

@ -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
@ -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 = [];
@ -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
};

@ -13,6 +13,7 @@ export interface Component {
export interface Payload {
out: string;
css: Set<{ hash: string; code: string }>;
head: {
title: string;
out: string;

@ -0,0 +1,4 @@
import { test } from '../../test';
// Test validates that by default no CSS is rendered on the server
export default test({});

@ -0,0 +1,7 @@
<div class="foo">foo</div>
<style>
.foo {
color: red;
}
</style>

@ -0,0 +1,7 @@
import { test } from '../../test';
export default test({
compileOptions: {
css: 'injected'
}
});

@ -0,0 +1,5 @@
<style id="svelte-sg04hs">
.foo.svelte-sg04hs {
color: red;
}
</style>

@ -0,0 +1,7 @@
<div class="foo">foo</div>
<style>
.foo {
color: red;
}
</style>

@ -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';
@ -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 || '', {
@ -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;

@ -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);
}
}

@ -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

@ -760,8 +760,8 @@ declare module 'svelte/compiler' {
*/
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';
@ -2568,8 +2568,8 @@ declare module 'svelte/types/compiler/interfaces' {
*/
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';

@ -155,6 +155,8 @@ const result = render(App, {
});
```
If the `css` compiler option was set to `'injected'`, `<style>` elements will be included in the `head`.
## `svelte/elements`
Svelte provides built-in [DOM types](https://github.com/sveltejs/svelte/blob/master/packages/svelte/elements.d.ts). A common use case for DOM types is forwarding props to an HTML element. To properly type your props and get full intellisense, your props interface should extend the attributes type for your HTML element:

@ -95,7 +95,7 @@ import App from './App.svelte';
+ const { html, head } = render(App, { props: { message: 'hello' } });
```
`render` also no longer returns CSS; it should be served separately from a CSS file.
In Svelte 4, rendering a component to a string also returned the CSS of all components. In Svelte 5, this is no longer the case by default because most of the time you're using a tooling chain that takes care of it in other ways (like SvelteKit). If you need CSS to be returned from `render`, you can set the `css` compiler option to `'injected'` and it will add `<style>` elements to the `head`.
### Component typing changes

Loading…
Cancel
Save