Repository: sveltejs/svelte (JavaScript)
Date: 2018-06-22 17:45:04+00:00
Task type: Bug Fix

Issue:
Component arguments that are not valid JS identifiers
<!--
Thanks for raising an issue! (For *questions*, we recommend instead using https://stackoverflow.com and adding the 'svelte' tag.)

To help us help you, if you've found a bug please consider the following:

* If you can demonstrate the bug using https://svelte.technology/repl, please do.
* If that's not possible, we recommend creating a small repo that illustrates the problem.
* Make sure you include information about the browser, and which version of Svelte you're using

Reproductions should be small, self-contained, correct examples – http://sscce.org.

Occasionally, this won't be possible, and that's fine – we still appreciate you raising the issue. But please understand that Svelte is run by unpaid volunteers in their free time, and issues that follow these instructions will get fixed faster.

If you have a stack trace to include, we recommend putting inside a `<details>` block for the sake of the thread's readability:

<details>
  <summary>Stack trace</summary>

  Stack trace goes here...
</details>
-->
[REPL](https://svelte.technology/repl?version=1.40.2&gist=f9fc679ecfbaac72e859150fbd12149e)
When `b-c='2'` is changed to, say, `b_c='2'` it works.

The problem is the generated code has `data: {b-c: 2}`, where `b-c` is not quoted.



Issue discussion:
Thanks! Yeah, something should be done about this. I'm wondering though whether it might be better to instead disallow attributes like `b-c` on nested components. There are definitely some other places in Svelte where property names that aren't valid as `.whatever` keys aren't supported.
I think once #1106 gets settled, we should do the same thing here.
I think there probably are more sweeping changes we want to make here. Data properties in components whose names aren't valid identifiers can't be accessed in template tags. It's true that `get` and `set` can still be manually used with them, but it seems worthwhile to have dev warnings if `data()` returns non-identifier-safe keys, or if `set` is called or the component constructor is called with invalid keys.

If we make those be dev warnings (and not dev exceptions), does it still make sense to disallow non-identifier-safe component arguments at compile time, or should that too be a warning and we just quote the key in the compiled component? Not sure what's best. How much limiting of the component author is helpful and how much is overbearing?
Additionally, a way to access these data fields from template tags could be added, though I can't think of a nice syntax.. `{{this.get("b-c")}}` seems overly verbose
My inclination here would be to disallow invalid identifiers as component properties, because there are so many caveats around their use (e.g. you can't use them in computed props, etc). 

The one thing holding me back is the possibility of us introducing spread props, where leftover props could be added to an element:

```html
<!-- App.html -->
<Widget data-foo='bar'/>

<!-- Widget.html -->
<div ...someSubsetOfProps>...</div>
```

Solution patch:
diff --git a/src/compile/nodes/Component.ts b/src/compile/nodes/Component.ts
--- a/src/compile/nodes/Component.ts
+++ b/src/compile/nodes/Component.ts
@@ -5,7 +5,7 @@ import stringifyProps from '../../utils/stringifyProps';
 import CodeBuilder from '../../utils/CodeBuilder';
 import getTailSnippet from '../../utils/getTailSnippet';
 import getObject from '../../utils/getObject';
-import { quoteNameIfNecessary } from '../../utils/quoteIfNecessary';
+import { quoteNameIfNecessary, quotePropIfNecessary } from '../../utils/quoteIfNecessary';
 import { escape, escapeTemplate, stringify } from '../../utils/stringify';
 import Node from './shared/Node';
 import Block from '../dom/Block';
@@ -148,7 +148,7 @@ export default class Component extends Node {
 		const attributeObject = usesSpread
 			? '{}'
 			: stringifyProps(
-				this.attributes.map(attr => `${attr.name}: ${attr.getValue()}`)
+				this.attributes.map(attr => `${quoteNameIfNecessary(attr.name)}: ${attr.getValue()}`)
 			);
 
 		if (this.attributes.length || this.bindings.length) {
@@ -219,7 +219,7 @@ export default class Component extends Node {
 							updates.push(deindent`
 								if (${[...attribute.dependencies]
 									.map(dependency => `changed.${dependency}`)
-									.join(' || ')}) ${name_changes}.${attribute.name} = ${attribute.getValue()};
+									.join(' || ')}) ${name_changes}${quotePropIfNecessary(attribute.name)} = ${attribute.getValue()};
 							`);
 						}
 					});
@@ -250,10 +250,10 @@ export default class Component extends Node {
 
 					const lhs = binding.value.node.type === 'MemberExpression'
 						? binding.value.snippet
-						: `${head}${tail} = childState.${binding.name}`;
+						: `${head}${tail} = childState${quotePropIfNecessary(binding.name)}`;
 
 					setFromChild = deindent`
-						${lhs} = childState.${binding.name};
+						${lhs} = childState${quotePropIfNecessary(binding.name)};
 
 						${[...binding.value.dependencies]
 							.map((name: string) => {
@@ -264,7 +264,7 @@ export default class Component extends Node {
 								if (isStoreProp) hasStoreBindings = true;
 								else hasLocalBindings = true;
 
-								return `${newState}.${prop} = ctx.${name};`;
+								return `${newState}${quotePropIfNecessary(prop)} = ctx${quotePropIfNecessary(name)};`;
 							})}
 					`;
 				}
@@ -279,32 +279,32 @@ export default class Component extends Node {
 
 					if (binding.value.node.type === 'MemberExpression') {
 						setFromChild = deindent`
-							${binding.value.snippet} = childState.${binding.name};
-							${newState}.${prop} = ctx.${key};
+							${binding.value.snippet} = childState${quotePropIfNecessary(binding.name)};
+							${newState}${quotePropIfNecessary(prop)} = ctx${quotePropIfNecessary(key)};
 						`;
 					}
 
 					else {
-						setFromChild = `${newState}.${prop} = childState.${binding.name};`;
+						setFromChild = `${newState}${quotePropIfNecessary(prop)} = childState${quotePropIfNecessary(binding.name)};`;
 					}
 				}
 
 				statements.push(deindent`
 					if (${binding.value.snippet} !== void 0) {
-						${name_initial_data}.${binding.name} = ${binding.value.snippet};
-						${name_updating}.${binding.name} = true;
+						${name_initial_data}${quotePropIfNecessary(binding.name)} = ${binding.value.snippet};
+						${name_updating}${quotePropIfNecessary(binding.name)} = true;
 					}`
 				);
 
 				builder.addConditional(
-					`!${name_updating}.${binding.name} && changed.${binding.name}`,
+					`!${name_updating}${quotePropIfNecessary(binding.name)} && changed${quotePropIfNecessary(binding.name)}`,
 					setFromChild
 				);
 
 				updates.push(deindent`
-					if (!${name_updating}.${binding.name} && ${[...binding.value.dependencies].map((dependency: string) => `changed.${dependency}`).join(' || ')}) {
-						${name_changes}.${binding.name} = ${binding.value.snippet};
-						${name_updating}.${binding.name} = ${binding.value.snippet} !== void 0;
+					if (!${name_updating}${quotePropIfNecessary(binding.name)} && ${[...binding.value.dependencies].map((dependency: string) => `changed.${dependency}`).join(' || ')}) {
+						${name_changes}${quotePropIfNecessary(binding.name)} = ${binding.value.snippet};
+						${name_updating}${quotePropIfNecessary(binding.name)} = ${binding.value.snippet} !== void 0;
 					}
 				`);
 			});
@@ -329,7 +329,7 @@ export default class Component extends Node {
 
 			beforecreate = deindent`
 				#component.root._beforecreate.push(() => {
-					${name}._bind({ ${this.bindings.map(b => `${b.name}: 1`).join(', ')} }, ${name}.get());
+					${name}._bind({ ${this.bindings.map(b => `${quoteNameIfNecessary(b.name)}: 1`).join(', ')} }, ${name}.get());
 				});
 			`;
 		}
@@ -519,7 +519,7 @@ export default class Component extends Node {
 				? getTailSnippet(binding.value.node)
 				: '';
 
-			return `${binding.name}: ctx.${name}${tail}`;
+			return `${quoteNameIfNecessary(binding.name)}: ctx${quotePropIfNecessary(name)}${tail}`;
 		});
 
 		function getAttributeValue(attribute) {
@@ -547,14 +547,14 @@ export default class Component extends Node {
 						if (attribute.isSpread) {
 							return attribute.expression.snippet;
 						} else {
-							return `{ ${attribute.name}: ${getAttributeValue(attribute)} }`;
+							return `{ ${quoteNameIfNecessary(attribute.name)}: ${getAttributeValue(attribute)} }`;
 						}
 					})
 					.concat(bindingProps.map(p => `{ ${p} }`))
 					.join(', ')
 			})`
 			: `{ ${this.attributes
-				.map(attribute => `${attribute.name}: ${getAttributeValue(attribute)}`)
+				.map(attribute => `${quoteNameIfNecessary(attribute.name)}: ${getAttributeValue(attribute)}`)
 				.concat(bindingProps)
 				.join(', ')} }`;
 
@@ -585,7 +585,7 @@ export default class Component extends Node {
 				if (${conditions.reverse().join('&&')}) {
 					tmp = ${expression}.data();
 					if ('${name}' in tmp) {
-						ctx.${binding.name} = tmp.${name};
+						ctx${quotePropIfNecessary(binding.name)} = tmp.${name};
 						settled = false;
 					}
 				}


Test patch:
diff --git a/test/runtime/samples/component-binding-invalid-identifier/Counter.html b/test/runtime/samples/component-binding-invalid-identifier/Counter.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-invalid-identifier/Counter.html
@@ -0,0 +1,14 @@
+<button on:click='set({ "x-count": state["x-count"] + 1 })'>+1</button>
+
+<script>
+	export default {
+		data: () => ({
+			"x-count": 0
+		}),
+		computed: {
+			state(arg)  {
+				return arg;
+			}
+		}
+	};
+</script>
diff --git a/test/runtime/samples/component-binding-invalid-identifier/_config.js b/test/runtime/samples/component-binding-invalid-identifier/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-invalid-identifier/_config.js
@@ -0,0 +1,29 @@
+export default {
+	'skip-ssr': true, // TODO delete this line, once binding works
+
+	html: `
+		<button>+1</button>
+		<p>count: 0</p>
+	`,
+
+	test ( assert, component, target, window ) {
+		const click = new window.MouseEvent( 'click' );
+		const button = target.querySelector( 'button' );
+
+		button.dispatchEvent( click );
+
+		assert.equal( component.get().x, 1 );
+		assert.htmlEqual( target.innerHTML, `
+			<button>+1</button>
+			<p>count: 1</p>
+		` );
+
+		button.dispatchEvent( click );
+
+		assert.equal( component.get().x, 2 );
+		assert.htmlEqual( target.innerHTML, `
+			<button>+1</button>
+			<p>count: 2</p>
+		` );
+	}
+};
diff --git a/test/runtime/samples/component-binding-invalid-identifier/main.html b/test/runtime/samples/component-binding-invalid-identifier/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-invalid-identifier/main.html
@@ -0,0 +1,12 @@
+<Counter bind:x-count='x'/>
+<p>count: {x}</p>
+
+<script>
+	import Counter from './Counter.html';
+
+	export default {
+		components: {
+			Counter
+		}
+	};
+</script>
diff --git a/test/runtime/samples/component-invalid-identifier/Widget.html b/test/runtime/samples/component-invalid-identifier/Widget.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-invalid-identifier/Widget.html
@@ -0,0 +1,10 @@
+<p>{state["b-c"]}</p>
+<script>
+export default {
+    computed: {
+        state(states) {
+            return states;
+        }
+    }
+};
+</script>
diff --git a/test/runtime/samples/component-invalid-identifier/_config.js b/test/runtime/samples/component-invalid-identifier/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-invalid-identifier/_config.js
@@ -0,0 +1,3 @@
+export default {
+	html: '<div><p>i am a widget</p></div>'
+};
diff --git a/test/runtime/samples/component-invalid-identifier/main.html b/test/runtime/samples/component-invalid-identifier/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-invalid-identifier/main.html
@@ -0,0 +1,11 @@
+<div>
+	<Widget b-c="i am a widget"/>
+</div>
+
+<script>
+	import Widget from './Widget.html';
+
+	export default {
+		components: { Widget }
+	};
+</script>