Repository: sveltejs/svelte (JavaScript) — Feature

Issue:
Implement RFC 13
https://github.com/sveltejs/rfcs/pull/13


Discussion:
Honestly, looks bad. It will wrap my component markup with extra `div` which will break selectors like `a > b`. I will just not use this feature.
@zamanruhy The RFC addresses this, essentially it comes down to using `display: contents`. Specifically this bit explains it:

> display: contents essentially removes the wrapper element from the DOM, but allows it to set inheritable styles including custom properties.[ It's easier to show than tell](https://svelte.dev/repl/ea454b5d951141ce989bf9ce46767c71?version=3.29.4). It's supported in all modern browsers (ignore notes 2 and 3, they don't apply in this situation), including Edge when the Chromium version ships.

Edit: I see now that this is not at all what you were commenting on. The times you would run into this is when you would do `.parentClass > :global(.childClass)`. Or if you had a global stylesheet that had a selector like that. A workaround to this is to use `a b` to select things.
@kevmodrome Yes, that's exactly what I meant. Regardless of any workaround, I don't like the idea of adding an extra node without my knowledge. The good thing is this is an optional feature and doesn't add any code to runtime ...
Notice that this will make targeting a component that doesn't want to be targeted even easier:

```html
<style>
    [style*=--starRating] > * {}
</style>
<StarRating --starRating />
```

**Edit:** The RFC mentions under drawbacks the idea of allowing a component to explicitly expose its "style properties" for validation and completions. that would help reduce marking an instance like this, and potentially you could then namespace the properties (although with namespace you would lose global theming - that can probably be handled differently anyhow in order to achieve validation and completions as well).
This feature would definitely be useful but something feels odd/bad about this approach.
I know the RFC already been merged, and I don't mean to drag this on. I do really like the idea of embracing CSS variables for this, but at the same time I share the concern about the use of `display: contents` and adding an extra wrapper element.

Wouldn't treating `--prop={value}` as syntactic sugar for ```style={`--prop: ${value};`}``` and limit the usage to actual elements hit a nice middle ground? To target a (third party) component you would then have to introduce a wrapping element yourself. This mirrors how classes already work and avoids the issues with introducing an unexpected DOM node.

Implementing the above would also make it possible to achieve behavior described in the RFC as part of a theming or design system library like this:

```svelte
<script>
  let vars = {}
  $: spread = Object.fromEntries(
    Object.entries(vars).map([key, val] => ['--' + key, val])
  )
</script>
<style>
  theme-context { display: contents; }
</style>
<theme-context {...spread}>
  <slot />
</theme-context>
```

This has the advantage of making the use of an extra element a bit more explicit.

Support for targeting components directly could be also achieved by exposing passed css vars as a special prop (eg `$$vars`) that then can be spread on to the appropriate element inside the component. Although, really any way to spread an object as css vars would solve that.

**Edit:** Something like the `Theme` component above could also be added to Svelte as say `<svelte:theme vars={...}>` or similar if you want to even further embrace this as a part of Svelte itself.
I believe that keeping the status quo is better than implementing this RFC. But if it's inevitable, I prefer the first variant of the implementation described [here](https://gist.github.com/Rich-Harris/6ee465dca7e86e5743cb367ba0ae3bee). The previous variant looks good in comparison with the current one. At least it doesn't imply implicit creation of additional elements and a huge number of side effects.
I too have some serious doubts about syntax of this RFC, write `--bg-color="#000"` on a component doesn't feel right and i don't understand what benefits it has against using a style prop (eg: `style="--bg-color=#000"`), in fact I would have been much more inclined to go for "Do nothing, but encourage the use of custom properties" way.

Moreover this extra wrapper div seems very bad, as others appointed, it breaks css selectors.
I think that if someone want a wrapper div around it's component, this should be accomplished through declaring clearly with a svelte:option like configuration similarly to how custom components are handled. For example this configuration could create a wrapper div even if `customElement: false`:
```
<svelte:options tag="my-comp" />
```
This would allow someone else to write something like this (without :global):
```
<style>
  my-comp {
    background-color: #000;
  }
</style>

<MyComp />
```
Of course the compiler should do some work to understand that `my-comp` and `MyComp` refer to the same thing.

Getting back to what stated in the RFC, another nice feature is to have props working in style tag, this would be great but i understand the difficulties on implement such a thing.
I feel something like this would be better

```html
<script>
export let color = 'red';
</script>

<style>
div {color: var(color);}
span {color: var(--color)}
</style>

<div>
  <span />
</div>
```

**OUTPUT**
```html
<div class="svelte-nerdboi" style="--color: red;">
  <span class="svelte-nerdboi" />
</div>

<style>
div {color: var(--color);}
span {color: var(--color);}
</style>
```

I'm going to lock this conversation as it's unproductive. The RFC repo (where the reaction was strongly positive) is the place for discussion about what features to add; the decision has been made.

I will quickly address some of the feedback here though:

> It will wrap my component markup with extra `div` which will break selectors like `a > b`

This is untrue. Since this syntax applies to components, not elements, we're talking about selectors that cross a component boundary. The only way CSS selectors are affected is if you have one like this `a > :global(b)`, which frankly is a bad selector anyway, since the parent is making assumptions about the structure of a child — that kind of tight coupling will always lead to breakage eventually.

The *whole point* of this RFC is it gives us an idiomatic way to avoid the need for those sorts of selectors in the first place.

> Wouldn't treating `--prop={value}` as syntactic sugar for `` style={`--prop: ${value};`} `` and limit the usage to actual elements hit a nice middle ground?

Elements can already be styled. There's much less value to adding syntactic sugar for element styling than for components, where `style` attributes aren't a thing. That said, supporting this syntax on elements *as well as* components could make sense.

> I feel something like this would be better

That's invalid CSS. We don't want to write our own bootleg CSS parser to handle non-standard syntax unless it's absolutely necessary, and it's not at all clear that there's an advantage here (on the contrary, making `var(color)` and `var(--color)` the same is confusing). As discussed in the RFC, using props introduces overhead (because the component has to anticipate that `color` might change, whereas using CSS custom properties natively allows them to be driven by even static .css files), and because the property is handled *inside* the component, it shadows properties declared higher up the tree.

> a huge number of side effects

Don't be silly.

Resolving patch:
diff --git a/src/compiler/compile/nodes/InlineComponent.ts b/src/compiler/compile/nodes/InlineComponent.ts
--- a/src/compiler/compile/nodes/InlineComponent.ts
+++ b/src/compiler/compile/nodes/InlineComponent.ts
@@ -18,6 +18,7 @@ export default class InlineComponent extends Node {
 	bindings: Binding[] = [];
 	handlers: EventHandler[] = [];
 	lets: Let[] = [];
+	css_custom_properties: Attribute[] = [];
 	children: INode[];
 	scope: TemplateScope;
 
@@ -46,6 +47,10 @@ export default class InlineComponent extends Node {
 					});
 
 				case 'Attribute':
+					if (node.name.startsWith('--')) {
+						this.css_custom_properties.push(new Attribute(component, this, scope, node));
+						break;
+					}
 					// fallthrough
 				case 'Spread':
 					this.attributes.push(new Attribute(component, this, scope, node));
diff --git a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts
--- a/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts
+++ b/src/compiler/compile/render_dom/wrappers/InlineComponent/index.ts
@@ -18,6 +18,7 @@ import { extract_names } from 'periscopic';
 import mark_each_block_bindings from '../shared/mark_each_block_bindings';
 import { string_to_member_expression } from '../../../utils/string_to_member_expression';
 import SlotTemplate from '../../../nodes/SlotTemplate';
+import { is_head } from '../shared/is_head';
 
 type SlotDefinition = { block: Block; scope: TemplateScope; get_context?: Node; get_changes?: Node };
 
@@ -63,6 +64,10 @@ export default class InlineComponentWrapper extends Wrapper {
 			}
 		});
 
+		this.node.css_custom_properties.forEach(attr => {
+			block.add_dependencies(attr.dependencies);
+		});
+
 		this.var = {
 			type: 'Identifier',
 			name: (
@@ -146,6 +151,12 @@ export default class InlineComponentWrapper extends Wrapper {
 			}
 		}
 
+		const has_css_custom_properties = this.node.css_custom_properties.length > 0;
+		const css_custom_properties_wrapper = has_css_custom_properties ? block.get_unique_name('div') : null;
+		if (has_css_custom_properties) {
+			block.add_variable(css_custom_properties_wrapper);
+		}
+
 		const initial_props = this.slots.size > 0
 			? [
 				p`$$slots: {
@@ -491,17 +502,64 @@ export default class InlineComponentWrapper extends Wrapper {
 				${munged_handlers}
 			`);
 
+			if (has_css_custom_properties) {
+				block.chunks.create.push(b`${css_custom_properties_wrapper} = @element("div");`);
+				block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "display", "contents");`);
+				this.node.css_custom_properties.forEach(attr => {
+					const dependencies = attr.get_dependencies();
+					const should_cache = attr.should_cache();
+					const last = should_cache && block.get_unique_name(`${attr.name.replace(/[^a-zA-Z_$]/g, '_')}_last`);
+					if (should_cache) block.add_variable(last);
+					const value = attr.get_value(block);
+					const init = should_cache ? x`${last} = ${value}` : value;
+
+					block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "${attr.name}", ${init});`);
+					if (dependencies.length > 0) {
+						let condition = block.renderer.dirty(dependencies);
+						if (should_cache) condition = x`${condition} && (${last} !== (${last} = ${value}))`;
+
+						block.chunks.update.push(b`
+							if (${condition}) {
+								@set_style(${css_custom_properties_wrapper}, "${attr.name}", ${should_cache ? last : value});
+							}
+						`);
+					}
+				});
+			}
 			block.chunks.create.push(b`@create_component(${name}.$$.fragment);`);
 
 			if (parent_nodes && this.renderer.options.hydratable) {
+				let nodes = parent_nodes;
+				if (has_css_custom_properties) {
+					nodes = block.get_unique_name(`${css_custom_properties_wrapper.name}_nodes`);
+					block.chunks.claim.push(b`
+						${css_custom_properties_wrapper} = @claim_element(${parent_nodes}, "DIV", { style: true })
+						var ${nodes} = @children(${css_custom_properties_wrapper});
+					`);
+				}
 				block.chunks.claim.push(
-					b`@claim_component(${name}.$$.fragment, ${parent_nodes});`
+					b`@claim_component(${name}.$$.fragment, ${nodes});`
 				);
 			}
 
-			block.chunks.mount.push(
-				b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});`
-			);
+			if (has_css_custom_properties) {
+				if (parent_node) {
+					block.chunks.mount.push(b`@append(${parent_node}, ${css_custom_properties_wrapper})`);
+					if (is_head(parent_node)) {
+						block.chunks.destroy.push(b`@detach(${css_custom_properties_wrapper});`);
+					}
+				} else {
+					block.chunks.mount.push(b`@insert(#target, ${css_custom_properties_wrapper}, #anchor);`);
+					// TODO we eventually need to consider what happens to elements
+					// that belong to the same outgroup as an outroing element...
+					block.chunks.destroy.push(b`if (detaching) @detach(${css_custom_properties_wrapper});`);
+				}
+				block.chunks.mount.push(b`@mount_component(${name}, ${css_custom_properties_wrapper}, null);`);
+			} else {
+				block.chunks.mount.push(
+					b`@mount_component(${name}, ${parent_node || '#target'}, ${parent_node ? 'null' : '#anchor'});`
+				);
+			}
 
 			block.chunks.intro.push(b`
 				@transition_in(${name}.$$.fragment, #local);
diff --git a/src/compiler/compile/render_ssr/handlers/InlineComponent.ts b/src/compiler/compile/render_ssr/handlers/InlineComponent.ts
--- a/src/compiler/compile/render_ssr/handlers/InlineComponent.ts
+++ b/src/compiler/compile/render_ssr/handlers/InlineComponent.ts
@@ -1,4 +1,5 @@
 import { string_literal } from '../../utils/stringify';
+import { get_attribute_value } from './shared/get_attribute_value';
 import Renderer, { RenderOptions } from '../Renderer';
 import InlineComponent from '../../nodes/InlineComponent';
 import { p, x } from 'code-red';
@@ -86,5 +87,19 @@ export default function(node: InlineComponent, renderer: Renderer, options: Rend
 		${slot_fns}
 	}`;
 
+	if (node.css_custom_properties.length > 0) {
+		renderer.add_string('<div style="display: contents;');
+		node.css_custom_properties.forEach(attr => {
+			renderer.add_string(` ${attr.name}:`);
+			renderer.add_expression(get_attribute_value(attr));
+			renderer.add_string(';');
+		});
+		renderer.add_string('">');
+	}
+
 	renderer.add_expression(x`@validate_component(${expression}, "${node.name}").$$render($$result, ${props}, ${bindings}, ${slots})`);
+
+	if (node.css_custom_properties.length > 0) {
+		renderer.add_string('</div>');
+	}
 }


Test patch:
diff --git a/test/helpers.ts b/test/helpers.ts
--- a/test/helpers.ts
+++ b/test/helpers.ts
@@ -260,3 +260,11 @@ export function mkdirp(dir) {
 		// do nothing
 	}
 }
+
+export function prettyPrintPuppeteerAssertionError(message) {
+	const match = /Error: Expected "(.+)" to equal "(.+)"/.exec(message);
+
+	if (match) {
+		assert.equal(match[1], match[2]);
+	}
+}
diff --git a/test/runtime-puppeteer/assert.js b/test/runtime-puppeteer/assert.js
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/assert.js
@@ -0,0 +1,54 @@
+export function deepEqual(a, b, message) {
+	if (!is_equal(a, b)) {
+		throw new Error(message || `Expected ${JSON.stringify(a)} to equal ${JSON.stringify(b)}`);
+	}
+}
+
+function is_equal(a, b) {
+	if (a && typeof a === 'object') {
+		const is_array = Array.isArray(a);
+		if (Array.isArray(b) !== is_array) return false;
+
+		if (is_array) {
+			if (a.length !== b.length) return false;
+			return a.every((value, i) => is_equal(value, b[i]));
+		}
+
+		const a_keys = Object.keys(a).sort();
+		const b_keys = Object.keys(b).sort();
+		if (a_keys.join(',') !== b_keys.join(',')) return false;
+
+		return a_keys.every(key => is_equal(a[key], b[key]));
+	}
+
+	return a === b;
+}
+
+export function equal(a, b, message) {
+	if (a != b) throw new Error(message || `Expected ${a} to equal ${b}`);
+}
+
+export function ok(condition, message) {
+	if (!condition) throw new Error(message || `Expected ${condition} to be truthy`);
+}
+
+export function htmlEqual(actual, expected, message) {
+	return deepEqual(
+		normalizeHtml(window, actual),
+		normalizeHtml(window, expected),
+		message
+	);
+}
+
+function normalizeHtml(window, html) {
+	try {
+		const node = window.document.createElement('div');
+		node.innerHTML = html
+			.replace(/<!--.*?-->/g, '')
+			.replace(/>[\s\r\n]+</g, '><')
+			.trim();
+		return node.innerHTML.replace(/<\/?noscript\/?>/g, '');
+	} catch (err) {
+		throw new Error(`Failed to normalize HTML:\n${html}`);
+	}
+}
diff --git a/test/runtime-puppeteer/index.ts b/test/runtime-puppeteer/index.ts
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/index.ts
@@ -0,0 +1,250 @@
+import * as path from 'path';
+import * as fs from 'fs';
+import * as http from 'http';
+import { rollup } from 'rollup';
+import virtual from '@rollup/plugin-virtual';
+import puppeteer from 'puppeteer';
+
+import {
+	loadConfig,
+	loadSvelte,
+	mkdirp,
+	prettyPrintPuppeteerAssertionError
+} from '../helpers';
+import { deepEqual } from 'assert';
+
+const page = `
+<body>
+	<main></main>
+	<script src='/bundle.js'></script>
+</body>
+`;
+
+let svelte;
+let server;
+let code;
+let browser;
+
+const internal = path.resolve('internal/index.mjs');
+const index = path.resolve('index.mjs');
+
+function create_server() {
+	return new Promise((fulfil, reject) => {
+		const server = http.createServer((req, res) => {
+			if (req.url === '/') {
+				res.end(page);
+			}
+
+			if (req.url === '/bundle.js') {
+				res.end(code);
+			}
+		});
+
+		server.on('error', reject);
+
+		server.listen('6789', () => {
+			fulfil(server);
+		});
+	});
+}
+
+const assert = fs.readFileSync(`${__dirname}/assert.js`, 'utf-8');
+
+describe('runtime (puppeteer)', () => {
+	before(async () => {
+		svelte = loadSvelte(false);
+		console.log('[runtime-puppeteer] Loaded Svelte');
+		server = await create_server();
+		console.log('[runtime-puppeteer] Started server');
+		browser = await puppeteer.launch();
+		console.log('[runtime-puppeteer] Launched puppeteer browser');
+	});
+
+	after(async () => {
+		if (server) server.close();
+		if (browser) await browser.close();
+	});
+
+	const failed = new Set();
+
+	function runTest(dir, hydrate) {
+		if (dir[0] === '.') return;
+
+		const config = loadConfig(`${__dirname}/samples/${dir}/_config.js`);
+		const solo = config.solo || /\.solo/.test(dir);
+		const skip = config.skip || /\.skip/.test(dir);
+
+		if (hydrate && config.skip_if_hydrate) return;
+
+		if (solo && process.env.CI) {
+			throw new Error('Forgot to remove `solo: true` from test');
+		}
+
+		(skip ? it.skip : solo ? it.only : it)(`${dir} ${hydrate ? '(with hydration)' : ''}`, async () => {
+			if (failed.has(dir)) {
+				// this makes debugging easier, by only printing compiled output once
+				throw new Error('skipping test, already failed');
+			}
+
+			const warnings = [];
+
+			const bundle = await rollup({
+				input: 'main',
+				plugins: [
+					{
+						name: 'testing-runtime-puppeteer',
+						resolveId(importee) {
+							if (importee === 'svelte/internal' || importee === './internal') {
+								return internal;
+							}
+
+							if (importee === 'svelte') {
+								return index;
+							}
+
+							if (importee === 'main') {
+								return 'main';
+							}
+						},
+						load(id) {
+							if (id === 'main') {
+								return `
+									import SvelteComponent from ${JSON.stringify(path.join(__dirname, 'samples', dir, 'main.svelte'))};
+									import config from ${JSON.stringify(path.join(__dirname, 'samples', dir, '_config.js'))};
+									import * as assert from 'assert';
+
+									export default async function (target) {
+										let unhandled_rejection = false;
+										function unhandled_rejection_handler(event) {
+											unhandled_rejection = event.reason;
+										}
+										window.addEventListener('unhandledrejection', unhandled_rejection_handler);
+
+										try {
+											if (config.before_test) config.before_test();
+
+											const options = Object.assign({}, {
+												target,
+												hydrate: ${String(!!hydrate)},
+												props: config.props,
+												intro: config.intro
+											}, config.options || {});
+
+											const component = new SvelteComponent(options);
+
+											if (config.html) {
+												assert.htmlEqual(target.innerHTML, config.html);
+											}
+
+											if (config.test) {
+												await config.test({
+													assert,
+													component,
+													target,
+													window,
+												});
+
+												component.$destroy();
+
+												if (unhandled_rejection) {
+													throw unhandled_rejection;
+												}
+											} else {
+												component.$destroy();
+												assert.htmlEqual(target.innerHTML, '');
+
+												if (unhandled_rejection) {
+													throw unhandled_rejection;
+												}
+											}
+
+											if (config.after_test) config.after_test();
+										} catch (error) {
+											if (config.error) {
+												assert.equal(err.message, config.error);
+											} else {
+												throw error;
+											}
+										} finally {
+											window.removeEventListener('unhandledrejection', unhandled_rejection_handler);
+										}
+									}
+								`;
+							}
+							return null;
+						},
+						transform(code, id) {
+							if (id.endsWith('.svelte')) {
+								const compiled = svelte.compile(code.replace(/\r/g, ''), {
+									...config.compileOptions,
+									hydratable: hydrate,
+									immutable: config.immutable,
+									accessors: 'accessors' in config ? config.accessors : true
+								});
+
+								const out_dir = `${__dirname}/samples/${dir}/_output/${hydrate ? 'hydratable' : 'normal'}`;
+								const out = `${out_dir}/${path.basename(id).replace(/\.svelte$/, '.js')}`;
+
+								if (fs.existsSync(out)) {
+									fs.unlinkSync(out);
+								}
+
+								mkdirp(out_dir);
+								fs.writeFileSync(out, compiled.js.code, 'utf8');
+
+								compiled.warnings.forEach(w => warnings.push(w));
+
+								return compiled.js;
+							}
+						}
+					},
+					virtual({ assert })
+				]
+			});
+
+			const result = await bundle.generate({ format: 'iife', name: 'test' });
+			code = result.output[0].code;
+
+			const page = await browser.newPage();
+
+			page.on('console', (type) => {
+				console[type._type](type._text);
+			});
+
+			page.on('error', error => {
+				console.log('>>> an error happened');
+				console.error(error);
+			});
+
+			try {
+				await page.goto('http://localhost:6789');
+
+				const result = await page.evaluate(() => test(document.querySelector('main')));
+				if (result) console.log(result);
+			} catch (err) {
+				failed.add(dir);
+				prettyPrintPuppeteerAssertionError(err.message);
+				throw err;
+			} finally {
+				if (config.warnings) {
+					deepEqual(warnings.map(w => ({
+						code: w.code,
+						message: w.message,
+						pos: w.pos,
+						start: w.start,
+						end: w.end
+					})), config.warnings);
+				} else if (warnings.length) {
+					failed.add(dir);
+					/* eslint-disable no-unsafe-finally */
+					throw new Error('Received unexpected warnings');
+				}
+			}
+		});
+	}
+
+	fs.readdirSync(`${__dirname}/samples`).forEach(dir => {
+		runTest(dir, false);
+		runTest(dir, true);
+	});
+});
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/Slider.svelte
@@ -0,0 +1,17 @@
+<script>
+  export let id;
+</script>
+
+<div {id}>
+  <p>Slider</p>
+  <span>Track</span>
+</div>
+
+<style>
+  p {
+    color: var(--rail-color);
+  }
+  span {
+    color: var(--track-color);
+  }
+</style>
\ No newline at end of file
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/_config.js
@@ -0,0 +1,41 @@
+export default {
+	props: {
+		railColor1: 'black',
+		trackColor1: 'red',
+		railColor2: 'green',
+		trackColor2: 'blue'
+	},
+	html: `
+		<div style="display: contents; --rail-color:black; --track-color:red;">
+			<div id="slider-1">
+				<p class="svelte-17ay6rc">Slider</p>
+				<span class="svelte-17ay6rc">Track</span>
+			</div>
+		</div>
+		<div style="display: contents; --rail-color:green; --track-color:blue;">
+			<div id="slider-2">
+				<p class="svelte-17ay6rc">Slider</p>
+				<span class="svelte-17ay6rc">Track</span>
+			</div>
+		</div>
+	`,
+	test({ component, assert, target }) {
+		component.railColor1 = 'yellow';
+		component.trackColor2 = 'orange';
+
+		assert.htmlEqual(target.innerHTML, `
+			<div style="display: contents; --rail-color:yellow; --track-color:red;">
+				<div id="slider-1">
+					<p class="svelte-17ay6rc">Slider</p>
+					<span class="svelte-17ay6rc">Track</span>
+				</div>
+			</div>
+			<div style="display: contents; --rail-color:green; --track-color:orange;">
+				<div id="slider-2">
+					<p class="svelte-17ay6rc">Slider</p>
+					<span class="svelte-17ay6rc">Track</span>
+				</div>
+			</div>
+		`);
+	}
+};
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic/main.svelte
@@ -0,0 +1,23 @@
+<script>
+	import Slider from './Slider.svelte';
+	export let railColor1;
+	export let railColor2;
+	export let trackColor1;
+	export let trackColor2;
+
+	function identity(color) {
+		return color;
+	}
+</script>
+
+<Slider
+	id="slider-1"
+	--rail-color={railColor1}
+	--track-color={trackColor1}
+/>
+
+<Slider
+	id="slider-2"
+	--rail-color={railColor2}
+	--track-color={identity(trackColor2)}
+/>
\ No newline at end of file
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties/Slider.svelte
@@ -0,0 +1,17 @@
+<script>
+  export let id;
+</script>
+
+<div {id}>
+  <p>Slider</p>
+  <span>Track</span>
+</div>
+
+<style>
+  p {
+    color: var(--rail-color);
+  }
+  span {
+    color: var(--track-color);
+  }
+</style>
\ No newline at end of file
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js b/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties/_config.js
@@ -0,0 +1,27 @@
+export default {
+	html: `
+		<div style="display: contents; --rail-color:rgb(0, 0, 0); --track-color:rgb(255, 0, 0);">
+			<div id="slider-1">
+				<p class="svelte-17ay6rc">Slider</p>
+				<span class="svelte-17ay6rc">Track</span>
+			</div>
+		</div>
+		<div style="display: contents; --rail-color:rgb(0, 255, 0); --track-color:rgb(0, 0, 255);">
+			<div id="slider-2">
+				<p class="svelte-17ay6rc">Slider</p>
+				<span class="svelte-17ay6rc">Track</span>
+			</div>
+		</div>
+	`,
+	test({ target, window, assert }) {
+		const railColor1 = target.querySelector('#slider-1 p');
+		const trackColor1 = target.querySelector('#slider-1 span');
+		const railColor2 = target.querySelector('#slider-2 p');
+		const trackColor2 = target.querySelector('#slider-2 span');
+
+		assert.htmlEqual(window.getComputedStyle(railColor1).color, 'rgb(0, 0, 0)');
+		assert.htmlEqual(window.getComputedStyle(trackColor1).color, 'rgb(255, 0, 0)');
+		assert.htmlEqual(window.getComputedStyle(railColor2).color, 'rgb(0, 255, 0)');
+		assert.htmlEqual(window.getComputedStyle(trackColor2).color, 'rgb(0, 0, 255)');
+	}
+};
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties/main.svelte
@@ -0,0 +1,15 @@
+<script>
+	import Slider from './Slider.svelte';
+</script>
+
+<Slider
+	id="slider-1"
+	--rail-color="rgb(0, 0, 0)"
+	--track-color="rgb(255, 0, 0)"
+/>
+
+<Slider
+	id="slider-2"
+	--rail-color="rgb(0, 255, 0)"
+	--track-color="rgb(0, 0, 255)"
+/>
\ No newline at end of file
diff --git a/test/server-side-rendering/index.ts b/test/server-side-rendering/index.ts
--- a/test/server-side-rendering/index.ts
+++ b/test/server-side-rendering/index.ts
@@ -135,100 +135,105 @@ describe('ssr', () => {
 	});
 
 	// duplicate client-side tests, as far as possible
-	fs.readdirSync('test/runtime/samples').forEach(dir => {
-		if (dir[0] === '.') return;
+	runRuntimeSamples('runtime');
+	runRuntimeSamples('runtime-puppeteer');
 
-		const config = loadConfig(`./runtime/samples/${dir}/_config.js`);
-		const solo = config.solo || /\.solo/.test(dir);
+	function runRuntimeSamples(suite) {
+		fs.readdirSync(`test/${suite}/samples`).forEach(dir => {
+			if (dir[0] === '.') return;
 
-		if (solo && process.env.CI) {
-			throw new Error('Forgot to remove `solo: true` from test');
-		}
+			const config = loadConfig(`./${suite}/samples/${dir}/_config.js`);
+			const solo = config.solo || /\.solo/.test(dir);
 
-		if (config.skip_if_ssr) return;
+			if (solo && process.env.CI) {
+				throw new Error('Forgot to remove `solo: true` from test');
+			}
 
-		(config.skip ? it.skip : solo ? it.only : it)(dir, () => {
-			const cwd = path.resolve('test/runtime/samples', dir);
+			if (config.skip_if_ssr) return;
 
-			cleanRequireCache();
+			(config.skip ? it.skip : solo ? it.only : it)(dir, () => {
+				const cwd = path.resolve(`test/${suite}/samples`, dir);
 
-			delete global.window;
+				cleanRequireCache();
 
-			const compileOptions = {
-				sveltePath,
-				...config.compileOptions,
-				generate: 'ssr',
-				format: 'cjs'
-			};
-
-			require('../../register')(compileOptions);
+				delete global.window;
 
-			glob('**/*.svelte', { cwd }).forEach(file => {
-				if (file[0] === '_') return;
+				const compileOptions = {
+					sveltePath,
+					...config.compileOptions,
+					generate: 'ssr',
+					format: 'cjs'
+				};
 
-				const dir  = `${cwd}/_output/ssr`;
-				const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`;
+				require('../../register')(compileOptions);
 
-				if (fs.existsSync(out)) {
-					fs.unlinkSync(out);
-				}
+				glob('**/*.svelte', { cwd }).forEach(file => {
+					if (file[0] === '_') return;
 
-				mkdirp(dir);
+					const dir  = `${cwd}/_output/ssr`;
+					const out = `${dir}/${file.replace(/\.svelte$/, '.js')}`;
 
-				try {
-					const { js } = compile(
-						fs.readFileSync(`${cwd}/${file}`, 'utf-8'),
-						{
-							...compileOptions,
-							filename: file
-						}
-					);
+					if (fs.existsSync(out)) {
+						fs.unlinkSync(out);
+					}
 
-					fs.writeFileSync(out, js.code);
-				} catch (err) {
-					// do nothing
-				}
-			});
+					mkdirp(dir);
 
-			try {
-				if (config.before_test) config.before_test();
+					try {
+						const { js } = compile(
+							fs.readFileSync(`${cwd}/${file}`, 'utf-8'),
+							{
+								...compileOptions,
+								filename: file
+							}
+						);
 
-				const Component = require(`../runtime/samples/${dir}/main.svelte`).default;
-				const { html } = Component.render(config.props, {
-					store: (config.store !== true) && config.store
+						fs.writeFileSync(out, js.code);
+					} catch (err) {
+						// do nothing
+					}
 				});
 
-				if (config.ssrHtml) {
-					assert.htmlEqual(html, config.ssrHtml);
-				} else if (config.html) {
-					assert.htmlEqual(html, config.html);
-				}
+				try {
+					if (config.before_test) config.before_test();
 
-				if (config.test_ssr) {
-					config.test_ssr({ assert });
-				}
+					const Component = require(`../${suite}/samples/${dir}/main.svelte`).default;
+					const { html } = Component.render(config.props, {
+						store: (config.store !== true) && config.store
+					});
 
-				if (config.after_test) config.after_test();
+					if (config.ssrHtml) {
+						assert.htmlEqual(html, config.ssrHtml);
+					} else if (config.html) {
+						assert.htmlEqual(html, config.html);
+					}
 
-				if (config.show) {
-					showOutput(cwd, compileOptions);
-				}
-			} catch (err) {
-				err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`;
+					if (config.test_ssr) {
+						config.test_ssr({ assert });
+					}
+
+					if (config.after_test) config.after_test();
 
-				if (config.error) {
-					if (typeof config.error === 'function') {
-						config.error(assert, err);
+					if (config.show) {
+						showOutput(cwd, compileOptions);
+					}
+				} catch (err) {
+					err.stack += `\n\ncmd-click: ${path.relative(process.cwd(), cwd)}/main.svelte`;
+
+					if (config.error) {
+						if (typeof config.error === 'function') {
+							config.error(assert, err);
+						} else {
+							assert.equal(err.message, config.error);
+						}
 					} else {
-						assert.equal(err.message, config.error);
+						showOutput(cwd, compileOptions);
+						throw err;
 					}
-				} else {
-					showOutput(cwd, compileOptions);
-					throw err;
+				} finally {
+					set_current_component(null);
 				}
-			} finally {
-				set_current_component(null);
-			}
+			});
 		});
-	});
+	}
 });