Repository: sveltejs/svelte (JavaScript)
Date: 2019-03-05 17:36:28+00:00
Task type: Feature

Issue:
Proposal: Don't re-trigger reactive declarations when their assignees are re-assigned
Lumpy title aside, this is basically proposing that if you have `$: foo = bar;` then assignments to `foo` shouldn't retrigger the reactive declaration to be run. The rationale for this was "it was trying to make `foo` read-only — like, if it's a prop, a consumer of that component shouldn't be able to set `foo` to a value other than `bar`" but I think that this causes more problems than it solves.

Props don't sound to me to be the right place to have data live that's going to be flowing out of the component.

If you do want to be able to assign to `foo` freely elsewhere in the component and not have it be re-set to `bar`, the current workaround is to do something like

```javascript
function updateFoo(bar) {
  foo = bar;
}
$: updateFoo(bar);
```

which seems ugly and unnecessary. It's basically tricking the compiler out of making a bad decision.

Other better ways to have read-only computed props include: component methods that return the value, read-only store properties on component instances, and events.


Issue discussion:
I'm taking a look at this again. Part of this is going to be a revert of #1993, but there's probably going to be more to it than that.

@Rich-Harris recalls that under the original implementation, `$: foo[bar] += 1` did not register a dependency on `bar`, which I agree sounds like a bug.

He also said that in

```javascript
$: {
  foo = bar * 2;
  if (foo >= 10) foo = 0;
}
```

that "i think we want to register `foo` as both a dependency (for `if (foo >= 10)`) _and_ as an assignee (for `foo = bar * 2` and `foo = 0`)" and this part I don't think I agree with. Having things that are both assignees and dependencies is what caused the current headache to begin with, as it's now impossible to change `foo` from anywhere else in the code.
Yeah, maybe you're right. Let's try it with just the first bit and see what happens
What I'm currently looking at locally is this:

```diff
diff --git a/src/compile/Component.ts b/src/compile/Component.ts
index 1b843e9c..7ff56dfa 100644
--- a/src/compile/Component.ts
+++ b/src/compile/Component.ts
@@ -1042,15 +1042,17 @@ export default class Component {
 						}
 
 						if (node.type === 'AssignmentExpression') {
-							assignees.add(getObject(node.left).name);
+							const { name } = getObject(node.left)
+							assignees.add(name);
+							dependencies.delete(name);
 						} else if (node.type === 'UpdateExpression') {
-							assignees.add(getObject(node.argument).name);
+							const { name } = getObject(node.argument);
+							assignees.add(name);
+							dependencies.delete(name);
 						} else if (isReference(node, parent)) {
-							const object = getObject(node);
-							const { name } = object;
-
+							const { name } = getObject(node);
 							const owner = scope.findOwner(name);
-							if ((!owner || owner === component.instance_scope) && (name[0] === '$' || component.var_lookup.has(name))) {
+							if ((!owner || owner === component.instance_scope) && (name[0] === '$' || component.var_lookup.has(name)) && !assignees.has(name)) {
 								dependencies.add(name);
 							}
```

The logic for what the assignees are stays the same, but we make sure we specifically prevent any assignees from being dependencies. We also make sure we don't `.skip()` until the node we're on actually is a reference, so we shouldn't miss any dependencies.

There are other equivalent ways of doing this, like manually removing everything in `assignees` from `dependencies` after walking the AST, which lines-of-code-wise would be a smaller change. I'm not sure which is clearer to read.
It just struck me that if we're handling this like in my previous comment, we could now distinguish between variables that are assigned to before they're used vs. ones that are used before they're assigned to. If we wanted, we could have variables that are used in the block before they are assigned to still be both assignees and dependencies. This could be an awful idea. We need to balance between having the compiler doing the right thing without people thinking about it, and also the compiler's behavior being comprehensible if people do decide to think about it.

Solution patch:
diff --git a/src/compile/Component.ts b/src/compile/Component.ts
--- a/src/compile/Component.ts
+++ b/src/compile/Component.ts
@@ -1042,15 +1042,17 @@ export default class Component {
 						}
 
 						if (node.type === 'AssignmentExpression') {
-							assignees.add(getObject(node.left).name);
+							const { name } = getObject(node.left)
+							assignees.add(name);
+							dependencies.delete(name);
 						} else if (node.type === 'UpdateExpression') {
-							assignees.add(getObject(node.argument).name);
+							const { name } = getObject(node.argument);
+							assignees.add(name);
+							dependencies.delete(name);
 						} else if (isReference(node, parent)) {
-							const object = getObject(node);
-							const { name } = object;
-
+							const { name } = getObject(node);
 							const owner = scope.findOwner(name);
-							if ((!owner || owner === component.instance_scope) && (name[0] === '$' || component.var_lookup.has(name))) {
+							if ((!owner || owner === component.instance_scope) && (name[0] === '$' || component.var_lookup.has(name)) && !assignees.has(name)) {
 								dependencies.add(name);
 							}
 


Test patch:
diff --git a/test/js/samples/dev-warning-missing-data-computed/expected.js b/test/js/samples/dev-warning-missing-data-computed/expected.js
--- a/test/js/samples/dev-warning-missing-data-computed/expected.js
+++ b/test/js/samples/dev-warning-missing-data-computed/expected.js
@@ -56,8 +56,8 @@ function instance($$self, $$props, $$invalidate) {
 		if ('foo' in $$props) $$invalidate('foo', foo = $$props.foo);
 	};
 
-	$$self.$$.update = ($$dirty = { bar: 1, foo: 1 }) => {
-		if ($$dirty.bar || $$dirty.foo) {
+	$$self.$$.update = ($$dirty = { foo: 1 }) => {
+		if ($$dirty.foo) {
 			bar = foo * 2; $$invalidate('bar', bar);
 		}
 	};
diff --git a/test/js/samples/reactive-values-non-topologically-ordered/expected.js b/test/js/samples/reactive-values-non-topologically-ordered/expected.js
--- a/test/js/samples/reactive-values-non-topologically-ordered/expected.js
+++ b/test/js/samples/reactive-values-non-topologically-ordered/expected.js
@@ -22,11 +22,11 @@ function instance($$self, $$props, $$invalidate) {
 		if ('x' in $$props) $$invalidate('x', x = $$props.x);
 	};
 
-	$$self.$$.update = ($$dirty = { b: 1, x: 1, a: 1 }) => {
-		if ($$dirty.b || $$dirty.x) {
+	$$self.$$.update = ($$dirty = { x: 1, b: 1 }) => {
+		if ($$dirty.x) {
 			b = x; $$invalidate('b', b);
 		}
-		if ($$dirty.a || $$dirty.b) {
+		if ($$dirty.b) {
 			a = b; $$invalidate('a', a);
 		}
 	};
diff --git a/test/js/samples/reactive-values-non-writable-dependencies/expected.js b/test/js/samples/reactive-values-non-writable-dependencies/expected.js
--- a/test/js/samples/reactive-values-non-writable-dependencies/expected.js
+++ b/test/js/samples/reactive-values-non-writable-dependencies/expected.js
@@ -17,12 +17,12 @@ let a = 1;
 let b = 2;
 
 function instance($$self, $$props, $$invalidate) {
-
+	
 
 	let max;
 
-	$$self.$$.update = ($$dirty = { max: 1, Math: 1, a: 1, b: 1 }) => {
-		if ($$dirty.max || $$dirty.a || $$dirty.b) {
+	$$self.$$.update = ($$dirty = { Math: 1, a: 1, b: 1 }) => {
+		if ($$dirty.a || $$dirty.b) {
 			max = Math.max(a, b); $$invalidate('max', max);
 		}
 	};
diff --git a/test/runtime/samples/reactive-values-readonly/_config.js b/test/runtime/samples/reactive-values-overwrite/_config.js
similarity index 76%
rename from test/runtime/samples/reactive-values-readonly/_config.js
rename to test/runtime/samples/reactive-values-overwrite/_config.js
--- a/test/runtime/samples/reactive-values-readonly/_config.js
+++ b/test/runtime/samples/reactive-values-overwrite/_config.js
@@ -11,11 +11,11 @@ export default {
 			<p>doubled: 4</p>
 		`);
 
-		component.doubled = 6;
+		component.doubled = 3;
 
-		assert.equal(component.doubled, 4);
+		assert.equal(component.doubled, 3);
 		assert.htmlEqual(target.innerHTML, `
-			<p>doubled: 4</p>
+			<p>doubled: 3</p>
 		`);
 	}
 };
diff --git a/test/runtime/samples/reactive-values-readonly/main.svelte b/test/runtime/samples/reactive-values-overwrite/main.svelte
similarity index 100%
rename from test/runtime/samples/reactive-values-readonly/main.svelte
rename to test/runtime/samples/reactive-values-overwrite/main.svelte
diff --git a/test/runtime/samples/reactive-values-subscript-assignment/_config.js b/test/runtime/samples/reactive-values-subscript-assignment/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/reactive-values-subscript-assignment/_config.js
@@ -0,0 +1,11 @@
+export default {
+	test({ assert, component }) {
+		assert.deepEqual(component.foo, {});
+		component.bar = 'hello';
+		assert.deepEqual(component.foo, { hello: true });
+		component.bar = 'world';
+		assert.deepEqual(component.foo, { hello: true, world: true });
+		component.bar = false;
+		assert.deepEqual(component.foo, { hello: true, world: true });
+	}
+};
diff --git a/test/runtime/samples/reactive-values-subscript-assignment/main.svelte b/test/runtime/samples/reactive-values-subscript-assignment/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/reactive-values-subscript-assignment/main.svelte
@@ -0,0 +1,7 @@
+<script>
+	export let foo = {};
+	export let bar;
+	$: if (bar) {
+		foo[bar] = true;
+	}
+</script>