Repository: sveltejs/svelte (JavaScript)
Date: 2017-07-11 02:39:52+00:00
Task type: Bug Fix

Issue:
Two-way binding observers not initialised in some circumstances
Another wild bug courtesy of @TehShrike. Repro [here](https://svelte.technology/repl?version=1.25.0&gist=c48146b14d6a6b28eb8ec38839b933db).

Essentially what happens is this:

* The `oncreate` function is successfully called for each `<Top>` component, and `component.get('top')` is correct
* Next comes bindings. The first binding is created (for the final component, because the order shouldn't really matter here), and that causes `component._set(...)` to happen (`component` being `App.html` in this case), which triggers an `update` of the main fragment, which causes `top` to become `undefined` for the remaining `<Top>` components, because their bindings haven't been initialised yet so `idToTop` is incomplete
* By the time the second and third bindings *are* initialised, the `differs` check returns false, because the value of `top` is `undefined`, which means the observer is not initialised.

So somehow we need to prevent the `_set` from causing the `update`. If we can figure that out, then we'll also be saving some unnecessary work from happening (the top-level component could `update` just once, instead of three times).

Answers on a postcard...


Issue discussion:

Solution patch:
diff --git a/src/generators/dom/index.ts b/src/generators/dom/index.ts
--- a/src/generators/dom/index.ts
+++ b/src/generators/dom/index.ts
@@ -125,9 +125,6 @@ export default function dom(
 		@dispatchObservers( this, this._observers.pre, newState, oldState );
 		${block.hasUpdateMethod && `this._fragment.update( newState, this._state );`}
 		@dispatchObservers( this, this._observers.post, newState, oldState );
-		${generator.hasComponents && `@callAll(this._oncreate);`}
-		${generator.hasComplexBindings && `@callAll(this._bindings);`}
-		${generator.hasIntroTransitions && `@callAll(this._postcreate);`}
 	`;
 
 	if (hasJs) {
@@ -207,9 +204,20 @@ export default function dom(
 			${generator.stylesheet.hasStyles &&
 				options.css !== false &&
 				`if ( !document.getElementById( '${generator.stylesheet.id}-style' ) ) @add_css();`}
-			${generator.hasComponents && `this._oncreate = [];`}
-			${generator.hasComplexBindings && `this._bindings = [];`}
-			${generator.hasIntroTransitions && `this._postcreate = [];`}
+
+			${templateProperties.oncreate && `var oncreate = @template.oncreate.bind( this );`}
+
+			${(templateProperties.oncreate || generator.hasComponents || generator.hasComplexBindings || generator.hasIntroTransitions) && deindent`
+				if ( !options._root ) {
+					this._oncreate = [${templateProperties.oncreate && `oncreate`}];
+					${(generator.hasComponents || generator.hasComplexBindings) && `this._beforecreate = [];`}
+					${(generator.hasComponents || generator.hasIntroTransitions) && `this._aftercreate = [];`}
+				} ${templateProperties.oncreate && deindent`
+					else {
+						this._root._oncreate.push(oncreate);
+					}
+				`}
+			`}
 
 			this._fragment = @create_main_fragment( this._state, this );
 
@@ -226,18 +234,16 @@ export default function dom(
 					`}
 				this._fragment.${block.hasIntroMethod ? 'intro' : 'mount'}( options.target, null );
 			}
-			
-			${generator.hasComponents && `@callAll(this._oncreate);`}
-			${generator.hasComplexBindings && `@callAll(this._bindings);`}
-
-			${templateProperties.oncreate && deindent`
-				if ( options._root ) {
-					options._root._oncreate.push( @template.oncreate.bind( this ) );
-				} else {
-					@template.oncreate.call( this );
-				}`}
-
-			${generator.hasIntroTransitions && `@callAll(this._postcreate);`}
+
+			${(generator.hasComponents || generator.hasComplexBindings || templateProperties.oncreate || generator.hasIntroTransitions) && deindent`
+				if ( !options._root ) {
+					${generator.hasComponents && `this._lock = true;`}
+					${(generator.hasComponents || generator.hasComplexBindings) && `@callAll(this._beforecreate);`}
+					${(generator.hasComponents || templateProperties.oncreate) && `@callAll(this._oncreate);`}
+					${(generator.hasComponents || generator.hasIntroTransitions) && `@callAll(this._aftercreate);`}
+					${generator.hasComponents && `this._lock = false;`}
+				}
+			`}
 		}
 
 		@assign( ${prototypeBase}, ${proto});
diff --git a/src/generators/dom/visitors/Component/Binding.ts b/src/generators/dom/visitors/Component/Binding.ts
--- a/src/generators/dom/visitors/Component/Binding.ts
+++ b/src/generators/dom/visitors/Component/Binding.ts
@@ -66,15 +66,24 @@ export default function visitBinding(
 	const updating = block.getUniqueName(`${local.name}_updating`);
 	block.addVariable(updating, 'false');
 
+	const observer = block.getUniqueName('observer');
+	const value = block.getUniqueName('value');
+
 	local.create.addBlock(deindent`
-		#component._bindings.push( function () {
-			if ( ${local.name}._torndown ) return;
-			${local.name}.observe( '${attribute.name}', function ( value ) {
-				if ( ${updating} ) return;
-				${updating} = true;
-				${setter}
-				${updating} = false;
-			}, { init: @differs( ${local.name}.get( '${attribute.name}' ), ${snippet} ) });
+		function ${observer} ( value ) {
+			if ( ${updating} ) return;
+			${updating} = true;
+			${setter}
+			${updating} = false;
+		}
+
+		${local.name}.observe( '${attribute.name}', ${observer}, { init: false });
+
+		#component._root._beforecreate.push( function () {
+			var value = ${local.name}.get( '${attribute.name}' );
+			if ( @differs( value, ${snippet} ) ) {
+				${observer}.call( ${local.name}, value );
+			}
 		});
 	`);
 
diff --git a/src/generators/dom/visitors/Element/Binding.ts b/src/generators/dom/visitors/Element/Binding.ts
--- a/src/generators/dom/visitors/Element/Binding.ts
+++ b/src/generators/dom/visitors/Element/Binding.ts
@@ -94,7 +94,7 @@ export default function visitBinding(
 
 		generator.hasComplexBindings = true;
 		block.builders.hydrate.addBlock(
-			`if ( !('${name}' in state) ) #component._bindings.push( ${handler} );`
+			`if ( !('${name}' in state) ) #component._root._beforecreate.push( ${handler} );`
 		);
 	} else if (attribute.name === 'group') {
 		// <input type='checkbox|radio' bind:group='selected'> special case
@@ -120,7 +120,7 @@ export default function visitBinding(
 		updateElement = `${state.parentNode}.checked = ${condition};`;
 	} else if (node.name === 'audio' || node.name === 'video') {
 		generator.hasComplexBindings = true;
-		block.builders.hydrate.addBlock(`#component._bindings.push( ${handler} );`);
+		block.builders.hydrate.addBlock(`#component._root._beforecreate.push( ${handler} );`);
 
 		if (attribute.name === 'currentTime') {
 			const frame = block.getUniqueName(`${state.parentNode}_animationframe`);
diff --git a/src/generators/dom/visitors/Element/addTransitions.ts b/src/generators/dom/visitors/Element/addTransitions.ts
--- a/src/generators/dom/visitors/Element/addTransitions.ts
+++ b/src/generators/dom/visitors/Element/addTransitions.ts
@@ -23,7 +23,7 @@ export default function addTransitions(
 		const fn = `@template.transitions.${intro.name}`;
 
 		block.builders.intro.addBlock(deindent`
-			#component._postcreate.push( function () {
+			#component._root._aftercreate.push( function () {
 				if ( !${name} ) ${name} = @wrapTransition( #component, ${state.name}, ${fn}, ${snippet}, true, null );
 				${name}.run( true, function () {
 					#component.fire( 'intro.end', { node: ${state.name} });
@@ -58,7 +58,7 @@ export default function addTransitions(
 			}
 
 			block.builders.intro.addBlock(deindent`
-				#component._postcreate.push( function () {
+				#component._root._aftercreate.push( function () {
 					${introName} = @wrapTransition( #component, ${state.name}, ${fn}, ${snippet}, true, null );
 					${introName}.run( true, function () {
 						#component.fire( 'intro.end', { node: ${state.name} });
diff --git a/src/generators/dom/visitors/shared/binding/getSetter.ts b/src/generators/dom/visitors/shared/binding/getSetter.ts
--- a/src/generators/dom/visitors/shared/binding/getSetter.ts
+++ b/src/generators/dom/visitors/shared/binding/getSetter.ts
@@ -27,10 +27,10 @@ export default function getSetter({
 			list[index]${tail} = ${value};
 
 			${computed
-				? `#component._set({ ${dependencies
+				? `#component.set({ ${dependencies
 						.map((prop: string) => `${prop}: state.${prop}`)
 						.join(', ')} });`
-				: `#component._set({ ${dependencies
+				: `#component.set({ ${dependencies
 						.map((prop: string) => `${prop}: #component.get( '${prop}' )`)
 						.join(', ')} });`}
 		`;
@@ -40,13 +40,13 @@ export default function getSetter({
 		return deindent`
 			var state = #component.get();
 			${snippet} = ${value};
-			#component._set({ ${dependencies
+			#component.set({ ${dependencies
 				.map((prop: string) => `${prop}: state.${prop}`)
 				.join(', ')} });
 		`;
 	}
 
-	return `#component._set({ ${name}: ${value} });`;
+	return `#component.set({ ${name}: ${value} });`;
 }
 
 function isComputed(node: Node) {
diff --git a/src/shared/index.js b/src/shared/index.js
--- a/src/shared/index.js
+++ b/src/shared/index.js
@@ -106,7 +106,12 @@ export function onDev(eventName, handler) {
 
 export function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 export function callAll(fns) {


Test patch:
diff --git a/test/js/samples/collapses-text-around-comments/expected-bundle.js b/test/js/samples/collapses-text-around-comments/expected-bundle.js
--- a/test/js/samples/collapses-text-around-comments/expected-bundle.js
+++ b/test/js/samples/collapses-text-around-comments/expected-bundle.js
@@ -115,7 +115,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/computed-collapsed-if/expected-bundle.js b/test/js/samples/computed-collapsed-if/expected-bundle.js
--- a/test/js/samples/computed-collapsed-if/expected-bundle.js
+++ b/test/js/samples/computed-collapsed-if/expected-bundle.js
@@ -91,7 +91,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/each-block-changed-check/expected-bundle.js b/test/js/samples/each-block-changed-check/expected-bundle.js
--- a/test/js/samples/each-block-changed-check/expected-bundle.js
+++ b/test/js/samples/each-block-changed-check/expected-bundle.js
@@ -124,7 +124,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/event-handlers-custom/expected-bundle.js b/test/js/samples/event-handlers-custom/expected-bundle.js
--- a/test/js/samples/event-handlers-custom/expected-bundle.js
+++ b/test/js/samples/event-handlers-custom/expected-bundle.js
@@ -109,7 +109,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/if-block-no-update/expected-bundle.js b/test/js/samples/if-block-no-update/expected-bundle.js
--- a/test/js/samples/if-block-no-update/expected-bundle.js
+++ b/test/js/samples/if-block-no-update/expected-bundle.js
@@ -115,7 +115,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/if-block-simple/expected-bundle.js b/test/js/samples/if-block-simple/expected-bundle.js
--- a/test/js/samples/if-block-simple/expected-bundle.js
+++ b/test/js/samples/if-block-simple/expected-bundle.js
@@ -115,7 +115,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/js/samples/non-imported-component/expected-bundle.js b/test/js/samples/non-imported-component/expected-bundle.js
--- a/test/js/samples/non-imported-component/expected-bundle.js
+++ b/test/js/samples/non-imported-component/expected-bundle.js
@@ -103,7 +103,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
@@ -178,7 +183,12 @@ function SvelteComponent ( options ) {
 	this._yield = options._yield;
 
 	this._torndown = false;
-	this._oncreate = [];
+
+	if ( !options._root ) {
+		this._oncreate = [];
+		this._beforecreate = [];
+		this._aftercreate = [];
+	}
 
 	this._fragment = create_main_fragment( this._state, this );
 
@@ -187,7 +197,13 @@ function SvelteComponent ( options ) {
 		this._fragment.mount( options.target, null );
 	}
 
-	callAll(this._oncreate);
+	if ( !options._root ) {
+		this._lock = true;
+		callAll(this._beforecreate);
+		callAll(this._oncreate);
+		callAll(this._aftercreate);
+		this._lock = false;
+	}
 }
 
 assign( SvelteComponent.prototype, proto );
@@ -197,7 +213,6 @@ SvelteComponent.prototype._set = function _set ( newState ) {
 	this._state = assign( {}, oldState, newState );
 	dispatchObservers( this, this._observers.pre, newState, oldState );
 	dispatchObservers( this, this._observers.post, newState, oldState );
-	callAll(this._oncreate);
 };
 
 SvelteComponent.prototype.teardown = SvelteComponent.prototype.destroy = function destroy ( detach ) {
diff --git a/test/js/samples/non-imported-component/expected.js b/test/js/samples/non-imported-component/expected.js
--- a/test/js/samples/non-imported-component/expected.js
+++ b/test/js/samples/non-imported-component/expected.js
@@ -62,7 +62,12 @@ function SvelteComponent ( options ) {
 	this._yield = options._yield;
 
 	this._torndown = false;
-	this._oncreate = [];
+
+	if ( !options._root ) {
+		this._oncreate = [];
+		this._beforecreate = [];
+		this._aftercreate = [];
+	}
 
 	this._fragment = create_main_fragment( this._state, this );
 
@@ -71,7 +76,13 @@ function SvelteComponent ( options ) {
 		this._fragment.mount( options.target, null );
 	}
 
-	callAll(this._oncreate);
+	if ( !options._root ) {
+		this._lock = true;
+		callAll(this._beforecreate);
+		callAll(this._oncreate);
+		callAll(this._aftercreate);
+		this._lock = false;
+	}
 }
 
 assign( SvelteComponent.prototype, proto );
@@ -81,7 +92,6 @@ SvelteComponent.prototype._set = function _set ( newState ) {
 	this._state = assign( {}, oldState, newState );
 	dispatchObservers( this, this._observers.pre, newState, oldState );
 	dispatchObservers( this, this._observers.post, newState, oldState );
-	callAll(this._oncreate);
 };
 
 SvelteComponent.prototype.teardown = SvelteComponent.prototype.destroy = function destroy ( detach ) {
diff --git a/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js b/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js
--- a/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js
+++ b/test/js/samples/onrender-onteardown-rewritten/expected-bundle.js
@@ -91,7 +91,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
@@ -143,6 +148,14 @@ function SvelteComponent ( options ) {
 
 	this._torndown = false;
 
+	var oncreate = template.oncreate.bind( this );
+
+	if ( !options._root ) {
+		this._oncreate = [oncreate];
+	} else {
+	 	this._root._oncreate.push(oncreate);
+	 }
+
 	this._fragment = create_main_fragment( this._state, this );
 
 	if ( options.target ) {
@@ -150,10 +163,8 @@ function SvelteComponent ( options ) {
 		this._fragment.mount( options.target, null );
 	}
 
-	if ( options._root ) {
-		options._root._oncreate.push( template.oncreate.bind( this ) );
-	} else {
-		template.oncreate.call( this );
+	if ( !options._root ) {
+		callAll(this._oncreate);
 	}
 }
 
diff --git a/test/js/samples/onrender-onteardown-rewritten/expected.js b/test/js/samples/onrender-onteardown-rewritten/expected.js
--- a/test/js/samples/onrender-onteardown-rewritten/expected.js
+++ b/test/js/samples/onrender-onteardown-rewritten/expected.js
@@ -1,4 +1,4 @@
-import { assign, dispatchObservers, noop, proto } from "svelte/shared.js";
+import { assign, callAll, dispatchObservers, noop, proto } from "svelte/shared.js";
 
 var template = (function () {
 	return {
@@ -37,6 +37,14 @@ function SvelteComponent ( options ) {
 
 	this._torndown = false;
 
+	var oncreate = template.oncreate.bind( this );
+
+	if ( !options._root ) {
+		this._oncreate = [oncreate];
+	} else {
+	 	this._root._oncreate.push(oncreate);
+	 }
+
 	this._fragment = create_main_fragment( this._state, this );
 
 	if ( options.target ) {
@@ -44,10 +52,8 @@ function SvelteComponent ( options ) {
 		this._fragment.mount( options.target, null );
 	}
 
-	if ( options._root ) {
-		options._root._oncreate.push( template.oncreate.bind( this ) );
-	} else {
-		template.oncreate.call( this );
+	if ( !options._root ) {
+		callAll(this._oncreate);
 	}
 }
 
diff --git a/test/js/samples/use-elements-as-anchors/expected-bundle.js b/test/js/samples/use-elements-as-anchors/expected-bundle.js
--- a/test/js/samples/use-elements-as-anchors/expected-bundle.js
+++ b/test/js/samples/use-elements-as-anchors/expected-bundle.js
@@ -115,7 +115,12 @@ function on(eventName, handler) {
 
 function set(newState) {
 	this._set(assign({}, newState));
+	if (this._root._lock) return;
+	this._root._lock = true;
+	callAll(this._root._beforecreate);
 	callAll(this._root._oncreate);
+	callAll(this._root._aftercreate);
+	this._root._lock = false;
 }
 
 function callAll(fns) {
diff --git a/test/runtime/index.js b/test/runtime/index.js
--- a/test/runtime/index.js
+++ b/test/runtime/index.js
@@ -137,10 +137,9 @@ describe("runtime", () => {
 						throw err;
 					}
 
+					let usedObjectAssign = false;
 					Object.assign = () => {
-						throw new Error(
-							"cannot use Object.assign in generated code, as it is not supported everywhere"
-						);
+						usedObjectAssign = true;
 					};
 
 					global.window = window;
@@ -182,14 +181,18 @@ describe("runtime", () => {
 						assert.htmlEqual(target.innerHTML, config.html);
 					}
 
-					Object.assign = Object_assign;
-
 					if (config.test) {
 						config.test(assert, component, target, window, raf);
 					} else {
 						component.destroy();
 						assert.equal(target.innerHTML, "");
 					}
+
+					if (usedObjectAssign) {
+						throw new Error(
+							"cannot use Object.assign in generated code, as it is not supported everywhere"
+						);
+					}
 				})
 				.catch(err => {
 					Object.assign = Object_assign;
diff --git a/test/runtime/samples/component-binding-blowback-b/Nested.html b/test/runtime/samples/component-binding-blowback-b/Nested.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-blowback-b/Nested.html
@@ -0,0 +1,18 @@
+<li>
+	{{yield}}
+</li>
+
+<script>
+	const initialValues = {
+		'id-0': 'zero',
+		'id-1': 'one',
+		'id-2': 'two',
+		'id-3': 'three'
+	};
+
+	export default {
+		oncreate() {
+			this.set({ value: initialValues[this.get('id')] });
+		}
+	};
+</script>
diff --git a/test/runtime/samples/component-binding-blowback-b/_config.js b/test/runtime/samples/component-binding-blowback-b/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-blowback-b/_config.js
@@ -0,0 +1,33 @@
+export default {
+	'skip-ssr': true,
+
+	data: {
+		count: 3
+	},
+
+	html: `
+		<input type='number'>
+		<ol>
+			<li>id-0: value is zero</li>
+			<li>id-1: value is one</li>
+			<li>id-2: value is two</li>
+		</ol>
+	`,
+
+	test (assert, component, target, window) {
+		const input = target.querySelector('input');
+
+		input.value = 4;
+		input.dispatchEvent(new window.Event('input'));
+
+		assert.htmlEqual(target.innerHTML, `
+			<input type='number'>
+			<ol>
+				<li>id-0: value is zero</li>
+				<li>id-1: value is one</li>
+				<li>id-2: value is two</li>
+				<li>id-3: value is three</li>
+			</ol>
+		`);
+	}
+};
diff --git a/test/runtime/samples/component-binding-blowback-b/main.html b/test/runtime/samples/component-binding-blowback-b/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/component-binding-blowback-b/main.html
@@ -0,0 +1,33 @@
+<input type='number' bind:value='count'>
+
+<ol>
+	{{#each ids as id}}
+		<Nested :id bind:value="idToValue[id]">
+			{{id}}: value is {{idToValue[id]}}
+		</Nested>
+	{{/each}}
+</ol>
+
+<script>
+	import Nested from './Nested.html';
+	
+	export default {
+		data() {
+			return {
+				idToValue: Object.create(null)
+			};
+		},
+
+		computed: {
+			ids(count) {
+				return new Array(count)
+					.fill(null)
+					.map((_, i) => 'id-' + i);
+			}
+		},
+
+		components: {
+			Nested
+		}
+	};
+</script>
\ No newline at end of file