Repository: sveltejs/svelte (JavaScript)
Date: 2022-09-12 10:32:57+00:00
Task type: Bug Fix

Issue:
Style props are incompatible with SVG components
### Describe the bug

## Summary:
Passing style props to any component wraps the element in a `<div>`, which breaks the SVG spec for anything rendered inside an `<svg>`.

## Details:
If I want to create a Svelte component that renders an SVG element with custom style props:
```svelte
// Circle.svelte
<script>
	// wants to accept --circle-fill as a style prop
</script>

<circle cx=150 cy=150 r=50 />

<style>
	circle {
		fill: var(--circle-fill, red);
	}
</style>
```

When passing the style props to the component:
```svelte
<svg viewbox='0 0 300 300'>
	<Circle --circle-fill="blue" />
</svg>
```


Svelte wraps the element in a `<div>`, which breaks the SVG rendering because SVG's cannot contain `<div>` elements.
```html
<div style="display: contents; --circle-fill:blue;">
	<circle cx="150" cy="150" r="50" class="svelte-17tvy5e" />
</div>
```

Given that the options for passing styles to sub-components in Svelte [are already limited](https://github.com/sveltejs/svelte/issues/6972), and the fact that Svelte is used extensively in data visualization, I would expect that passing style props should be possible for SVG elements.

### Reproduction

https://svelte.dev/repl/b7a3f94255914044b35462234ccaea43?version=3.49.0

### Logs

_No response_

### System Info

```shell
System:
    OS: macOS 12.4
    CPU: (10) arm64 Apple M1 Pro
    Memory: 93.83 MB / 16.00 GB
    Shell: 5.8.1 - /bin/zsh
  Binaries:
    Node: 16.15.1 - ~/.nvm/versions/node/v16.15.1/bin/node
    npm: 8.11.0 - ~/.nvm/versions/node/v16.15.1/bin/npm
  Browsers:
    Chrome: 104.0.5112.101
    Safari: 15.5
```


### Severity

annoyance


Issue discussion:
Interesting enough the RFC discussion doesn't have any "svg" in it https://github.com/sveltejs/rfcs/pull/13 but the [final RFC](https://github.com/sveltejs/rfcs/blob/master/text/0006-style-properties.md) does indeed say this

> 🐃 It *would* be possible for someone to accidentally target the `<div>`, so it may be preferable to use a made-up element name instead (`<styles>`?). In SVG, there might not be any choice but to use a `<g>`. In 99.9% of cases it wouldn't matter in the least, but it *would* need to be documented.

So the second half about SVG is neither documented nor implemented.

Solution patch:
diff --git a/site/content/docs/03-template-syntax.md b/site/content/docs/03-template-syntax.md
--- a/site/content/docs/03-template-syntax.md
+++ b/site/content/docs/03-template-syntax.md
@@ -1374,6 +1374,22 @@ Desugars to this:
 
 ---
 
+For SVG namespace, the example above desugars into using `<g>` instead:
+
+```sv
+<g style="--rail-color: black; --track-color: rgb(0, 0, 255)">
+  <Slider
+    bind:value
+    min={0}
+    max={100}
+  />
+</g>
+```
+
+**Note**: Since this is an extra `<g>`, beware that your CSS structure might accidentally target this. Be mindful of this added wrapper element when using this feature.
+
+---
+
 Svelte's CSS Variables support allows for easily themable components:
 
 ```sv
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
@@ -22,6 +22,7 @@ export default class InlineComponent extends Node {
 	css_custom_properties: Attribute[] = [];
 	children: INode[];
 	scope: TemplateScope;
+	namespace: string;
 
 	constructor(component: Component, parent: Node, scope: TemplateScope, info: TemplateNode) {
 		super(component, parent, scope, info);
@@ -33,6 +34,7 @@ export default class InlineComponent extends Node {
 		}
 
 		this.name = info.name;
+		this.namespace = get_namespace(parent, component.namespace);
 
 		this.expression = this.name === 'svelte:component'
 			? new Expression(component, this, scope, info.expression)
@@ -165,3 +167,13 @@ export default class InlineComponent extends Node {
 function not_whitespace_text(node) {
 	return !(node.type === 'Text' && /^\s+$/.test(node.data));
 }
+
+function get_namespace(parent: Node, explicit_namespace: string) {
+	const parent_element = parent.find_nearest(/^Element/);
+
+	if (!parent_element) {
+		return explicit_namespace;
+	}
+
+	return parent_element.namespace;
+}
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
@@ -20,6 +20,7 @@ import { string_to_member_expression } from '../../../utils/string_to_member_exp
 import SlotTemplate from '../../../nodes/SlotTemplate';
 import { is_head } from '../shared/is_head';
 import compiler_warnings from '../../../compiler_warnings';
+import { namespaces } from '../../../../utils/namespaces';
 
 type SlotDefinition = { block: Block; scope: TemplateScope; get_context?: Node; get_changes?: Node };
 
@@ -150,7 +151,9 @@ 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;
+		const is_svg_namespace = this.node.namespace === namespaces.svg;
+		const css_custom_properties_wrapper_element = is_svg_namespace ? 'g' : 'div';
+		const css_custom_properties_wrapper = has_css_custom_properties ? block.get_unique_name(css_custom_properties_wrapper_element) : null;
 		if (has_css_custom_properties) {
 			block.add_variable(css_custom_properties_wrapper);
 		}
@@ -411,7 +414,7 @@ export default class InlineComponentWrapper extends Wrapper {
 			const snippet = this.node.expression.manipulate(block);
 
 			if (has_css_custom_properties) {
-				this.set_css_custom_properties(block, css_custom_properties_wrapper);
+				this.set_css_custom_properties(block, css_custom_properties_wrapper, css_custom_properties_wrapper_element, is_svg_namespace);
 			}
 
 			block.chunks.init.push(b`
@@ -440,7 +443,7 @@ export default class InlineComponentWrapper extends Wrapper {
 			block.chunks.mount.push(b`if (${name}) @mount_component(${name}, ${mount_target}, ${mount_anchor});`);
 
 			if (to_claim) {
-				if (css_custom_properties_wrapper) claim_nodes = this.create_css_custom_properties_wrapper_claim_chunk(block, claim_nodes, css_custom_properties_wrapper);
+				if (css_custom_properties_wrapper) claim_nodes = this.create_css_custom_properties_wrapper_claim_chunk(block, claim_nodes, css_custom_properties_wrapper, css_custom_properties_wrapper_element, is_svg_namespace);
 				block.chunks.claim.push(b`if (${name}) @claim_component(${name}.$$.fragment, ${claim_nodes});`);
 			}
 
@@ -514,7 +517,7 @@ export default class InlineComponentWrapper extends Wrapper {
 			`);
 
 			if (has_css_custom_properties) {
-				this.set_css_custom_properties(block, css_custom_properties_wrapper);
+				this.set_css_custom_properties(block, css_custom_properties_wrapper, css_custom_properties_wrapper_element, is_svg_namespace);
 			}
 			block.chunks.create.push(b`@create_component(${name}.$$.fragment);`);
 
@@ -522,7 +525,7 @@ export default class InlineComponentWrapper extends Wrapper {
 			block.chunks.mount.push(b`@mount_component(${name}, ${mount_target}, ${mount_anchor});`);
 
 			if (to_claim) {
-				if (css_custom_properties_wrapper) claim_nodes = this.create_css_custom_properties_wrapper_claim_chunk(block, claim_nodes, css_custom_properties_wrapper);
+				if (css_custom_properties_wrapper) claim_nodes = this.create_css_custom_properties_wrapper_claim_chunk(block, claim_nodes, css_custom_properties_wrapper, css_custom_properties_wrapper_element, is_svg_namespace);
 				block.chunks.claim.push(b`@claim_component(${name}.$$.fragment, ${claim_nodes});`);
 			}
 
@@ -568,11 +571,14 @@ export default class InlineComponentWrapper extends Wrapper {
 	private create_css_custom_properties_wrapper_claim_chunk(
 		block: Block,
 		parent_nodes: Identifier,
-		css_custom_properties_wrapper: Identifier | null
+		css_custom_properties_wrapper: Identifier | null,
+		css_custom_properties_wrapper_element: string,
+		is_svg_namespace: boolean
 	) {
 		const nodes = block.get_unique_name(`${css_custom_properties_wrapper.name}_nodes`);
+		const claim_element = is_svg_namespace ? x`@claim_svg_element` : x`@claim_element`;
 		block.chunks.claim.push(b`
-			${css_custom_properties_wrapper} = @claim_element(${parent_nodes}, "DIV", { style: true })
+			${css_custom_properties_wrapper} = ${claim_element}(${parent_nodes}, "${css_custom_properties_wrapper_element.toUpperCase()}", { style: true })
 			var ${nodes} = @children(${css_custom_properties_wrapper});
 		`);
 		return nodes;
@@ -580,10 +586,13 @@ export default class InlineComponentWrapper extends Wrapper {
 
 	private set_css_custom_properties(
 		block: Block,
-		css_custom_properties_wrapper: Identifier
+		css_custom_properties_wrapper: Identifier,
+		css_custom_properties_wrapper_element: string,
+		is_svg_namespace: boolean
 	) {
-		block.chunks.create.push(b`${css_custom_properties_wrapper} = @element("div");`);
-		block.chunks.hydrate.push(b`@set_style(${css_custom_properties_wrapper}, "display", "contents");`);
+		const element = is_svg_namespace ? x`@svg_element` : x`@element`;
+		block.chunks.create.push(b`${css_custom_properties_wrapper} = ${element}("${css_custom_properties_wrapper_element}");`);
+		if (!is_svg_namespace) 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();
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
@@ -3,6 +3,7 @@ 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';
+import { namespaces } from '../../../utils/namespaces';
 
 function get_prop_value(attribute) {
 	if (attribute.is_true) return x`true`;
@@ -88,11 +89,16 @@ export default function(node: InlineComponent, renderer: Renderer, options: Rend
 	}`;
 
 	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}:`);
+		if (node.namespace === namespaces.svg) {
+			renderer.add_string('<g style="');
+		} else {
+			renderer.add_string('<div style="display: contents; ');
+		}
+		node.css_custom_properties.forEach((attr, index) => {
+			renderer.add_string(`${attr.name}:`);
 			renderer.add_expression(get_attribute_value(attr));
 			renderer.add_string(';');
+			if (index < node.css_custom_properties.length - 1) renderer.add_string(' ');
 		});
 		renderer.add_string('">');
 	}
@@ -100,6 +106,10 @@ export default function(node: InlineComponent, renderer: Renderer, options: Rend
 	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>');
+		if (node.namespace === namespaces.svg) {
+			renderer.add_string('</g>');
+		} else {
+			renderer.add_string('</div>');
+		}
 	}
 }
diff --git a/src/compiler/utils/namespaces.ts b/src/compiler/utils/namespaces.ts
--- a/src/compiler/utils/namespaces.ts
+++ b/src/compiler/utils/namespaces.ts
@@ -25,4 +25,4 @@ export const valid_namespaces = [
 	xmlns
 ];
 
-export const namespaces: Record<string, string> = { foreign, html, mathml, svg, xlink, xml, xmlns };
+export const namespaces = { foreign, html, mathml, svg, xlink, xml, xmlns } as const;


Test patch:
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/Svg.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/Svg.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/Svg.svelte
@@ -0,0 +1,17 @@
+<script>
+  export let id;
+</script>
+
+<g {id}>
+  <circle cx="50" cy="50" r="10" />
+  <rect width="100" height="100" />
+</g>
+
+<style>
+  circle {
+    fill: var(--circle-color);
+  }
+  rect {
+    fill: var(--rect-color)
+  }
+</style>
\ No newline at end of file
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/_config.js b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/_config.js
@@ -0,0 +1,55 @@
+export default {
+	props: {
+		rectColor1: 'green',
+		circleColor1: 'red',
+		rectColor2: 'black',
+		circleColor2: 'blue'
+	},
+	html: `
+		<svg xmlns="http://www.w3.org/2000/svg">
+			<g style="--rect-color:green; --circle-color:red;">
+				<g id="svg-1">
+					<circle cx="50" cy="50" r="10" class="svelte-1qzlp1k"></circle>
+					<rect width="100" height="100" class="svelte-1qzlp1k"></rect>
+				</g>
+			</g>
+			<g style="--rect-color:black; --circle-color:blue;">
+				<g id="svg-2">
+					<circle cx="50" cy="50" r="10" class="svelte-1qzlp1k"></circle>
+					<rect width="100" height="100" class="svelte-1qzlp1k"></rect>
+				</g>
+			</g>
+		</svg>
+	`,
+	test({ component, assert, target }) {
+		component.rectColor1 = 'yellow';
+		component.circleColor2 = 'cyan';
+
+		assert.htmlEqual(target.innerHTML, `
+			<svg xmlns="http://www.w3.org/2000/svg">
+				<g style="--rect-color:yellow; --circle-color:red;">
+					<g id="svg-1">
+						<circle cx="50" cy="50" r="10" class="svelte-1qzlp1k"></circle>
+						<rect width="100" height="100" class="svelte-1qzlp1k"></rect>
+					</g>
+				</g>
+				<g style="--rect-color:black; --circle-color:cyan;">
+					<g id="svg-2">
+						<circle cx="50" cy="50" r="10" class="svelte-1qzlp1k"></circle>
+						<rect width="100" height="100" class="svelte-1qzlp1k"></rect>
+					</g>
+				</g>
+			</svg>
+		`);
+
+		const circleColor1 = target.querySelector('#svg-1 circle');
+		const rectColor1 = target.querySelector('#svg-1 rect');
+		const circleColor2 = target.querySelector('#svg-2 circle');
+		const rectColor2 = target.querySelector('#svg-2 rect');
+
+		assert.htmlEqual(window.getComputedStyle(circleColor1).fill, 'rgb(255, 0, 0)');
+		assert.htmlEqual(window.getComputedStyle(rectColor1).fill, 'rgb(255, 255, 0)');
+		assert.htmlEqual(window.getComputedStyle(circleColor2).fill, 'rgb(0, 255, 255)');
+		assert.htmlEqual(window.getComputedStyle(rectColor2).fill, 'rgb(0, 0, 0)');
+	}
+};
diff --git a/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/main.svelte b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime-puppeteer/samples/component-css-custom-properties-dynamic-svg/main.svelte
@@ -0,0 +1,25 @@
+<script>
+	import Svg from './Svg.svelte';
+	export let rectColor1;
+	export let rectColor2;
+	export let circleColor1;
+	export let circleColor2;
+
+	function identity(color) {
+		return color;
+	}
+</script>
+
+<svg xmlns="http://www.w3.org/2000/svg">
+	<Svg
+		id="svg-1"
+		--rect-color={rectColor1}
+		--circle-color={circleColor1}
+	/>
+
+	<Svg
+		id="svg-2"
+		--rect-color={rectColor2}
+		--circle-color={identity(circleColor2)}
+	/>
+</svg>
\ No newline at end of file