Repository: sveltejs/svelte (JavaScript)
Date: 2017-05-27 16:48:27+00:00
Task type: Bug Fix

Issue:
<textarea value='{{foo}}'> doesn't work
[Repro here](https://svelte.technology/repl?version=1.20.2&gist=703b45513de90fe93bb47eafc61b3f8b) — the generated code is wrong:

```diff
if ( textarea_value_value !== ( textarea_value_value = state.foo ) ) {
-  setAttribute( textarea, 'value', textarea_value_value );
+  textarea.value = textarea_value_value;
}
```


Issue discussion:
`<textarea>{{foo}}</textarea>` works, doesn't it? I'm not sure why we'd want to support `<textarea value='{{foo}}>` when `<textarea value='some text'>` doesn't work in regular HTML. I might be being overly pedantic.
Because the way that you update the contents of a textarea is with `textarea.value = content` - I'd argue it's more natural (and certainly easier to implement!) than `<textarea>{{value}}</textarea>`
Just ran into this issue moments ago, and I was about to open an issue about this. I'm glad you ran into this as well!
I don't see a reason not to allow using `value`, adds parity with the other form elements and since this is a compiler it doesn't add more code to the output.

Solution patch:
diff --git a/src/generators/dom/visitors/Element/Element.ts b/src/generators/dom/visitors/Element/Element.ts
--- a/src/generators/dom/visitors/Element/Element.ts
+++ b/src/generators/dom/visitors/Element/Element.ts
@@ -107,6 +107,20 @@ export default function visitElement ( generator: DomGenerator, block: Block, st
 	}
 
 	if ( node.name !== 'select' ) {
+		if ( node.name === 'textarea' ) {
+			// this is an egregious hack, but it's the easiest way to get <textarea>
+			// children treated the same way as a value attribute
+			if ( node.children.length > 0 ) {
+				node.attributes.push({
+					type: 'Attribute',
+					name: 'value',
+					value: node.children
+				});
+
+				node.children = [];
+			}
+		}
+
 		// <select> value attributes are an annoying special case — it must be handled
 		// *after* its children have been updated
 		visitAttributesAndAddProps();
diff --git a/src/generators/dom/visitors/Element/lookup.ts b/src/generators/dom/visitors/Element/lookup.ts
--- a/src/generators/dom/visitors/Element/lookup.ts
+++ b/src/generators/dom/visitors/Element/lookup.ts
@@ -109,7 +109,7 @@ const lookup = {
 	title: {},
 	type: { appliesTo: [ 'button', 'input', 'command', 'embed', 'object', 'script', 'source', 'style', 'menu' ] },
 	usemap: { propertyName: 'useMap', appliesTo: [ 'img', 'input', 'object' ] },
-	value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select' ] },
+	value: { appliesTo: [ 'button', 'option', 'input', 'li', 'meter', 'progress', 'param', 'select', 'textarea' ] },
 	width: { appliesTo: [ 'canvas', 'embed', 'iframe', 'img', 'input', 'object', 'video' ] },
 	wrap: { appliesTo: [ 'textarea' ] }
 };
diff --git a/src/generators/server-side-rendering/visitors/Element.ts b/src/generators/server-side-rendering/visitors/Element.ts
--- a/src/generators/server-side-rendering/visitors/Element.ts
+++ b/src/generators/server-side-rendering/visitors/Element.ts
@@ -10,6 +10,17 @@ const meta = {
 	':Window': visitWindow
 };
 
+function stringifyAttributeValue ( block: Block, chunks: Node[] ) {
+	return chunks.map( ( chunk: Node ) => {
+		if ( chunk.type === 'Text' ) {
+			return chunk.data;
+		}
+
+		const { snippet } = block.contextualise( chunk.expression );
+		return '${' + snippet + '}';
+	}).join( '' )
+}
+
 export default function visitElement ( generator: SsrGenerator, block: Block, node: Node ) {
 	if ( node.name in meta ) {
 		return meta[ node.name ]( generator, block, node );
@@ -21,24 +32,22 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no
 	}
 
 	let openingTag = `<${node.name}`;
+	let textareaContents; // awkward special case
 
 	node.attributes.forEach( ( attribute: Node ) => {
 		if ( attribute.type !== 'Attribute' ) return;
 
-		let str = ` ${attribute.name}`;
+		if ( attribute.name === 'value' && node.name === 'textarea' ) {
+			textareaContents = stringifyAttributeValue( block, attribute.value );
+		} else {
+			let str = ` ${attribute.name}`;
 
-		if ( attribute.value !== true ) {
-			str += `="` + attribute.value.map( ( chunk: Node ) => {
-				if ( chunk.type === 'Text' ) {
-					return chunk.data;
-				}
+			if ( attribute.value !== true ) {
+				str += `="${stringifyAttributeValue( block, attribute.value )}"`;
+			}
 
-				const { snippet } = block.contextualise( chunk.expression );
-				return '${' + snippet + '}';
-			}).join( '' ) + `"`;
+			openingTag += str;
 		}
-
-		openingTag += str;
 	});
 
 	if ( generator.cssId && !generator.elementDepth ) {
@@ -49,13 +58,17 @@ export default function visitElement ( generator: SsrGenerator, block: Block, no
 
 	generator.append( openingTag );
 
-	generator.elementDepth += 1;
+	if ( node.name === 'textarea' && textareaContents !== undefined ) {
+		generator.append( textareaContents );
+	} else {
+		generator.elementDepth += 1;
 
-	node.children.forEach( ( child: Node ) => {
-		visit( generator, block, child );
-	});
+		node.children.forEach( ( child: Node ) => {
+			visit( generator, block, child );
+		});
 
-	generator.elementDepth -= 1;
+		generator.elementDepth -= 1;
+	}
 
 	if ( !isVoidElementName( node.name ) ) {
 		generator.append( `</${node.name}>` );
diff --git a/src/parse/state/tag.ts b/src/parse/state/tag.ts
--- a/src/parse/state/tag.ts
+++ b/src/parse/state/tag.ts
@@ -9,7 +9,6 @@ import { Parser } from '../index';
 import { Node } from '../../interfaces';
 
 const validTagName = /^\!?[a-zA-Z]{1,}:?[a-zA-Z0-9\-]*/;
-const invalidUnquotedAttributeCharacters = /[\s"'=<>\/`]/;
 
 const SELF = ':Self';
 
@@ -181,6 +180,11 @@ export default function tag ( parser: Parser ) {
 
 	if ( selfClosing ) {
 		element.end = parser.index;
+	} else if ( name === 'textarea' ) {
+		// special case
+		element.children = readSequence( parser, () => parser.template.slice( parser.index, parser.index + 11 ) === '</textarea>' );
+		parser.read( /<\/textarea>/ );
+		element.end = parser.index;
 	} else {
 		// don't push self-closing elements onto the stack
 		parser.stack.push( element );
@@ -280,11 +284,41 @@ function readAttribute ( parser: Parser, uniqueNames ) {
 }
 
 function readAttributeValue ( parser: Parser ) {
-	let quoteMark;
+	const quoteMark = (
+		parser.eat( `'` ) ? `'` :
+		parser.eat( `"` ) ? `"` :
+		null
+	);
+
+	const regex = (
+		quoteMark === `'` ? /'/ :
+		quoteMark === `"` ? /"/ :
+		/[\s"'=<>\/`]/
+	);
+
+	const value = readSequence( parser, () => regex.test( parser.template[ parser.index ] ) );
+
+	if ( quoteMark ) parser.index += 1;
+	return value;
+}
 
-	if ( parser.eat( `'` ) ) quoteMark = `'`;
-	if ( parser.eat( `"` ) ) quoteMark = `"`;
+function getShorthandValue ( start: number, name: string ) {
+	const end = start + name.length;
 
+	return [{
+		type: 'AttributeShorthand',
+		start,
+		end,
+		expression: {
+			type: 'Identifier',
+			start,
+			end,
+			name
+		}
+	}];
+}
+
+function readSequence ( parser: Parser, done: () => boolean ) {
 	let currentChunk: Node = {
 		start: parser.index,
 		end: null,
@@ -292,16 +326,24 @@ function readAttributeValue ( parser: Parser ) {
 		data: ''
 	};
 
-	const done = quoteMark ?
-		char => char === quoteMark :
-		char => invalidUnquotedAttributeCharacters.test( char );
-
 	const chunks = [];
 
 	while ( parser.index < parser.template.length ) {
 		const index = parser.index;
 
-		if ( parser.eat( '{{' ) ) {
+		if ( done() ) {
+			currentChunk.end = parser.index;
+
+			if ( currentChunk.data ) chunks.push( currentChunk );
+
+			chunks.forEach( chunk => {
+				if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data );
+			});
+
+			return chunks;
+		}
+
+		else if ( parser.eat( '{{' ) ) {
 			if ( currentChunk.data ) {
 				currentChunk.end = index;
 				chunks.push( currentChunk );
@@ -328,39 +370,10 @@ function readAttributeValue ( parser: Parser ) {
 			};
 		}
 
-		else if ( done( parser.template[ parser.index ] ) ) {
-			currentChunk.end = parser.index;
-			if ( quoteMark ) parser.index += 1;
-
-			if ( currentChunk.data ) chunks.push( currentChunk );
-
-			chunks.forEach( chunk => {
-				if ( chunk.type === 'Text' ) chunk.data = decodeCharacterReferences( chunk.data );
-			});
-
-			return chunks;
-		}
-
 		else {
 			currentChunk.data += parser.template[ parser.index++ ];
 		}
 	}
 
 	parser.error( `Unexpected end of input` );
-}
-
-function getShorthandValue ( start: number, name: string ) {
-	const end = start + name.length;
-
-	return [{
-		type: 'AttributeShorthand',
-		start,
-		end,
-		expression: {
-			type: 'Identifier',
-			start,
-			end,
-			name
-		}
-	}];
-}
+}
\ No newline at end of file
diff --git a/src/validate/html/validateElement.ts b/src/validate/html/validateElement.ts
--- a/src/validate/html/validateElement.ts
+++ b/src/validate/html/validateElement.ts
@@ -77,6 +77,14 @@ export default function validateElement ( validator: Validator, node: Node ) {
 				validator.error( `Missing transition '${attribute.name}'`, attribute.start );
 			}
 		}
+
+		else if ( attribute.type === 'Attribute' ) {
+			if ( attribute.name === 'value' && node.name === 'textarea' ) {
+				if ( node.children.length ) {
+					validator.error( `A <textarea> can have either a value attribute or (equivalently) child content, but not both`, attribute.start );
+				}
+			}
+		}
 	});
 }
 


Test patch:
diff --git a/test/parser/samples/textarea-children/input.html b/test/parser/samples/textarea-children/input.html
new file mode 100644
--- /dev/null
+++ b/test/parser/samples/textarea-children/input.html
@@ -0,0 +1,3 @@
+<textarea>
+	<p>not actually an element. {{foo}}</p>
+</textarea>
\ No newline at end of file
diff --git a/test/parser/samples/textarea-children/output.json b/test/parser/samples/textarea-children/output.json
new file mode 100644
--- /dev/null
+++ b/test/parser/samples/textarea-children/output.json
@@ -0,0 +1,44 @@
+{
+	"hash": 3618147195,
+	"html": {
+		"start": 0,
+		"end": 63,
+		"type": "Fragment",
+		"children": [
+			{
+				"start": 0,
+				"end": 63,
+				"type": "Element",
+				"name": "textarea",
+				"attributes": [],
+				"children": [
+					{
+						"start": 10,
+						"end": 40,
+						"type": "Text",
+						"data": "\n\t<p>not actually an element. "
+					},
+					{
+						"start": 40,
+						"end": 47,
+						"type": "MustacheTag",
+						"expression": {
+							"type": "Identifier",
+							"start": 42,
+							"end": 45,
+							"name": "foo"
+						}
+					},
+					{
+						"start": 47,
+						"end": 52,
+						"type": "Text",
+						"data": "</p>\n"
+					}
+				]
+			}
+		]
+	},
+	"css": null,
+	"js": null
+}
\ No newline at end of file
diff --git a/test/runtime/samples/textarea-children/_config.js b/test/runtime/samples/textarea-children/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/textarea-children/_config.js
@@ -0,0 +1,17 @@
+export default {
+	'skip-ssr': true, // SSR behaviour is awkwardly different
+
+	data: {
+		foo: 42
+	},
+
+	html: `<textarea></textarea>`,
+
+	test ( assert, component, target ) {
+		const textarea = target.querySelector( 'textarea' );
+		assert.strictEqual( textarea.value, `\n\t<p>not actually an element. 42</p>\n` );
+
+		component.set({ foo: 43 });
+		assert.strictEqual( textarea.value, `\n\t<p>not actually an element. 43</p>\n` );
+	}
+};
\ No newline at end of file
diff --git a/test/runtime/samples/textarea-children/main.html b/test/runtime/samples/textarea-children/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/textarea-children/main.html
@@ -0,0 +1,3 @@
+<textarea>
+	<p>not actually an element. {{foo}}</p>
+</textarea>
\ No newline at end of file
diff --git a/test/runtime/samples/textarea-value/_config.js b/test/runtime/samples/textarea-value/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/textarea-value/_config.js
@@ -0,0 +1,17 @@
+export default {
+	'skip-ssr': true, // SSR behaviour is awkwardly different
+
+	data: {
+		foo: 42
+	},
+
+	html: `<textarea></textarea>`,
+
+	test ( assert, component, target ) {
+		const textarea = target.querySelector( 'textarea' );
+		assert.strictEqual( textarea.value, '42' );
+
+		component.set({ foo: 43 });
+		assert.strictEqual( textarea.value, '43' );
+	}
+};
\ No newline at end of file
diff --git a/test/runtime/samples/textarea-value/main.html b/test/runtime/samples/textarea-value/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/textarea-value/main.html
@@ -0,0 +1 @@
+<textarea value='{{foo}}'/>
\ No newline at end of file
diff --git a/test/server-side-rendering/samples/textarea-children/_expected.html b/test/server-side-rendering/samples/textarea-children/_expected.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/textarea-children/_expected.html
@@ -0,0 +1,3 @@
+<textarea>
+	<p>not actually an element. 42</p>
+</textarea>
\ No newline at end of file
diff --git a/test/server-side-rendering/samples/textarea-children/main.html b/test/server-side-rendering/samples/textarea-children/main.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/textarea-children/main.html
@@ -0,0 +1,11 @@
+<textarea>
+	<p>not actually an element. {{foo}}</p>
+</textarea>
+
+<script>
+	export default {
+		data () {
+			return { foo: 42 };
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/server-side-rendering/samples/textarea-value/_expected.html b/test/server-side-rendering/samples/textarea-value/_expected.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/textarea-value/_expected.html
@@ -0,0 +1 @@
+<textarea>42</textarea>
\ No newline at end of file
diff --git a/test/server-side-rendering/samples/textarea-value/main.html b/test/server-side-rendering/samples/textarea-value/main.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/textarea-value/main.html
@@ -0,0 +1,9 @@
+<textarea value='{{foo}}'/>
+
+<script>
+	export default {
+		data () {
+			return { foo: 42 };
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/validator/samples/textarea-value-children/errors.json b/test/validator/samples/textarea-value-children/errors.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/textarea-value-children/errors.json
@@ -0,0 +1,8 @@
+[{
+	"message": "A <textarea> can have either a value attribute or (equivalently) child content, but not both",
+	"loc": {
+		"line": 1,
+		"column": 10
+	},
+	"pos": 10
+}]
\ No newline at end of file
diff --git a/test/validator/samples/textarea-value-children/input.html b/test/validator/samples/textarea-value-children/input.html
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/textarea-value-children/input.html
@@ -0,0 +1,3 @@
+<textarea value='{{foo}}'>
+	some illegal text
+</textarea>
\ No newline at end of file