Skip to content

Commit a4f6407

Browse files
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 <[email protected]> --------- Co-authored-by: Simon Holthausen <[email protected]> Co-authored-by: Simon H <[email protected]>
1 parent 47a073e commit a4f6407

File tree

29 files changed

+160
-79
lines changed

29 files changed

+160
-79
lines changed

.changeset/rich-taxis-hear.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
feat: include CSS in `<head>` when `css: 'injected'`

packages/svelte/src/compiler/phases/3-transform/client/transform-client.js

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -332,22 +332,16 @@ export function client_component(source, analysis, options) {
332332
}
333333
}
334334

335-
const append_styles =
336-
analysis.inject_styles && analysis.css.ast
337-
? () =>
338-
component_block.body.push(
339-
b.stmt(
340-
b.call(
341-
'$.append_styles',
342-
b.id('$$anchor'),
343-
b.literal(analysis.css.hash),
344-
b.literal(render_stylesheet(source, analysis, options).code)
345-
)
346-
)
347-
)
348-
: () => {};
335+
if (analysis.css.ast !== null && analysis.inject_styles) {
336+
const hash = b.literal(analysis.css.hash);
337+
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
349338

350-
append_styles();
339+
state.hoisted.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));
340+
341+
component_block.body.unshift(
342+
b.stmt(b.call('$.append_styles', b.id('$$anchor'), b.id('$$css')))
343+
);
344+
}
351345

352346
const should_inject_context =
353347
analysis.needs_context ||
@@ -423,17 +417,34 @@ export function client_component(source, analysis, options) {
423417
);
424418

425419
if (options.hmr) {
426-
const accept_fn = b.arrow(
427-
[b.id('module')],
428-
b.block([b.stmt(b.call('$.set', b.id('s'), b.member(b.id('module'), b.id('default'))))])
429-
);
420+
const accept_fn_body = [
421+
b.stmt(b.call('$.set', b.id('s'), b.member(b.id('module'), b.id('default'))))
422+
];
423+
424+
if (analysis.css.hash) {
425+
// remove existing `<style>` element, in case CSS changed
426+
accept_fn_body.unshift(
427+
b.stmt(
428+
b.call(
429+
b.member(
430+
b.call('document.querySelector', b.literal('#' + analysis.css.hash)),
431+
b.id('remove'),
432+
false,
433+
true
434+
)
435+
)
436+
)
437+
);
438+
}
439+
430440
body.push(
431441
component,
432442
b.if(
433443
b.id('import.meta.hot'),
434444
b.block([
435445
b.const(b.id('s'), b.call('$.source', b.id(analysis.name))),
436446
b.const(b.id('filename'), b.member(b.id(analysis.name), b.id('filename'))),
447+
b.const(b.id('accept'), b.arrow([b.id('module')], b.block(accept_fn_body))),
437448
b.stmt(b.assignment('=', b.id(analysis.name), b.call('$.hmr', b.id('s')))),
438449
b.stmt(
439450
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) {
442453
b.id('import.meta.hot.acceptExports'),
443454
b.block([
444455
b.stmt(
445-
b.call('import.meta.hot.acceptExports', b.array([b.literal('default')]), accept_fn)
456+
b.call(
457+
'import.meta.hot.acceptExports',
458+
b.array([b.literal('default')]),
459+
b.id('accept')
460+
)
446461
)
447462
]),
448-
b.block([b.stmt(b.call('import.meta.hot.accept', accept_fn))])
463+
b.block([b.stmt(b.call('import.meta.hot.accept', b.id('accept')))])
449464
)
450465
])
451466
),

packages/svelte/src/compiler/phases/3-transform/server/transform-server.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
BLOCK_OPEN_ELSE
4040
} from '../../../../internal/server/hydration.js';
4141
import { filename, locator } from '../../../state.js';
42+
import { render_stylesheet } from '../css/index.js';
4243

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

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

2162+
if (analysis.css.ast !== null && options.css === 'injected' && !options.customElement) {
2163+
const hash = b.literal(analysis.css.hash);
2164+
const code = b.literal(render_stylesheet(analysis.source, analysis, options).code);
2165+
2166+
body.push(b.const('$$css', b.object([b.init('hash', hash), b.init('code', code)])));
2167+
component_block.body.unshift(b.stmt(b.call('$$payload.css.add', b.id('$$css'))));
2168+
}
2169+
21612170
let should_inject_props =
21622171
should_inject_context ||
21632172
props.length > 0 ||

packages/svelte/src/compiler/types/index.d.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ export interface CompileOptions extends ModuleCompileOptions {
9999
*/
100100
immutable?: boolean;
101101
/**
102-
* - `'injected'`: styles will be included in the JavaScript class and injected at runtime for the components actually rendered.
103-
* - `'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.
102+
* - `'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.
103+
* - `'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.
104104
* This is always `'injected'` when compiling with `customElement` mode.
105105
*/
106106
css?: 'injected' | 'external';
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { DEV } from 'esm-env';
2+
import { queue_micro_task } from './task.js';
3+
4+
var seen = new Set();
5+
6+
/**
7+
* @param {Node} anchor
8+
* @param {{ hash: string, code: string }} css
9+
*/
10+
export function append_styles(anchor, css) {
11+
// in dev, always check the DOM, so that styles can be replaced with HMR
12+
if (!DEV) {
13+
if (seen.has(css)) return;
14+
seen.add(css);
15+
}
16+
17+
// Use `queue_micro_task` to ensure `anchor` is in the DOM, otherwise getRootNode() will yield wrong results
18+
queue_micro_task(() => {
19+
var root = anchor.getRootNode();
20+
21+
var target = /** @type {ShadowRoot} */ (root).host
22+
? /** @type {ShadowRoot} */ (root)
23+
: /** @type {Document} */ (root).head;
24+
25+
if (!target.querySelector('#' + css.hash)) {
26+
const style = document.createElement('style');
27+
style.id = css.hash;
28+
style.textContent = css.code;
29+
30+
target.appendChild(style);
31+
}
32+
});
33+
}

packages/svelte/src/internal/client/index.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export { snippet, wrap_snippet } from './dom/blocks/snippet.js';
2020
export { component } from './dom/blocks/svelte-component.js';
2121
export { element } from './dom/blocks/svelte-element.js';
2222
export { head } from './dom/blocks/svelte-head.js';
23+
export { append_styles } from './dom/css.js';
2324
export { action } from './dom/elements/actions.js';
2425
export {
2526
remove_input_defaults,
@@ -120,7 +121,7 @@ export {
120121
update_pre_store,
121122
update_store
122123
} from './reactivity/store.js';
123-
export { append_styles, set_text } from './render.js';
124+
export { set_text } from './render.js';
124125
export {
125126
get,
126127
invalidate_inner_signals,

packages/svelte/src/internal/client/render.js

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import * as w from './warnings.js';
2222
import * as e from './errors.js';
2323
import { validate_component } from '../shared/validate.js';
2424
import { assign_nodes } from './dom/template.js';
25+
import { queue_micro_task } from './dom/task.js';
2526

2627
/** @type {Set<string>} */
2728
export const all_registered_events = new Set();
@@ -294,35 +295,3 @@ export function unmount(component) {
294295
}
295296
fn?.();
296297
}
297-
298-
/**
299-
* @param {Node} target
300-
* @param {string} style_sheet_id
301-
* @param {string} styles
302-
*/
303-
export async function append_styles(target, style_sheet_id, styles) {
304-
// Wait a tick so that the template is added to the dom, else getRootNode() will yield wrong results
305-
// If it turns out that this results in noticeable flickering, we need to do something like doing the
306-
// append outside and adding code in mount that appends all stylesheets (similar to how we do it with event delegation)
307-
await Promise.resolve();
308-
const append_styles_to = get_root_for_style(target);
309-
if (!append_styles_to.getElementById(style_sheet_id)) {
310-
const style = document.createElement('style');
311-
style.id = style_sheet_id;
312-
style.textContent = styles;
313-
const target = /** @type {Document} */ (append_styles_to).head || append_styles_to;
314-
target.appendChild(style);
315-
}
316-
}
317-
318-
/**
319-
* @param {Node} node
320-
*/
321-
function get_root_for_style(node) {
322-
if (!node) return document;
323-
const root = node.getRootNode ? node.getRootNode() : node.ownerDocument;
324-
if (root && /** @type {ShadowRoot} */ (root).host) {
325-
return /** @type {ShadowRoot} */ (root);
326-
}
327-
return /** @type {Document} */ (node.ownerDocument);
328-
}

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

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -43,9 +43,10 @@ export const VoidElements = new Set([
4343
* @param {Payload} to_copy
4444
* @returns {Payload}
4545
*/
46-
export function copy_payload({ out, head }) {
46+
export function copy_payload({ out, css, head }) {
4747
return {
4848
out,
49+
css: new Set(css),
4950
head: {
5051
title: head.title,
5152
out: head.out
@@ -107,7 +108,7 @@ export let on_destroy = [];
107108
*/
108109
export function render(component, options = {}) {
109110
/** @type {Payload} */
110-
const payload = { out: '', head: { title: '', out: '' } };
111+
const payload = { out: '', css: new Set(), head: { title: '', out: '' } };
111112

112113
const prev_on_destroy = on_destroy;
113114
on_destroy = [];
@@ -129,8 +130,14 @@ export function render(component, options = {}) {
129130
for (const cleanup of on_destroy) cleanup();
130131
on_destroy = prev_on_destroy;
131132

133+
let head = payload.head.out + payload.head.title;
134+
135+
for (const { hash, code } of payload.css) {
136+
head += `<style id="${hash}">${code}</style>`;
137+
}
138+
132139
return {
133-
head: payload.head.out || payload.head.title ? payload.head.out + payload.head.title : '',
140+
head,
134141
html: payload.out,
135142
body: payload.out
136143
};

packages/svelte/src/internal/server/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface Component {
1313

1414
export interface Payload {
1515
out: string;
16+
css: Set<{ hash: string; code: string }>;
1617
head: {
1718
title: string;
1819
out: string;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
import { test } from '../../test';
2+
3+
// Test validates that by default no CSS is rendered on the server
4+
export default test({});

0 commit comments

Comments
 (0)