Repository: sveltejs/svelte (JavaScript)
Date: 2021-01-02 03:21:53+00:00
Task type: Bug Fix

Issue:
Module Declarations are not Reactive - is this expected?
**Describe the bug**
When following the Tutorial, the [Module Context > Sharing Code](https://svelte.dev/tutorial/sharing-code) lesson shows that module variables are shared between components of the same type.

There's nothing to say that module variables are *not* reactive - is this expected behaviour?

**To Reproduce**

I have a trite REPL example that shows no reactivity when module variables are modified:

https://svelte.dev/repl/369bc4ec3fbb4d81910d08aba96779a1?version=3.31.0

**Expected behavior**

Visually there's no clue that the following are fundamentally different.

### This works as expected

```html
<script>
  let foo;
  function updateFoo(bar) {
    foo = Math.random();
  }
  $: console.log(foo); // fine
</script>
```

### This doesn't

```html
<script context="module">
  let foo;
</script>
<script>
  function updateFoo(bar) {
    foo = Math.random();
  }
  $: console.log(foo); // not fine
</script>
```

### A slight tangent

The second foo should throw a warning.

Paste this into a vanilla html document and you'd see `Uncaught SyntaxError: Identifier 'foo' has already been declared`

```html
<script context="module">
  let foo;
</script>
<script>
  let foo;
</script>
```

**Severity**
More than anything, it surprised me. In the context of the tutorial, it's an 11th-hour edge-case / gotcha to be mindful of.

**Idea**
The compiler could / should complain about reactive statements related to module variables. I'd love to see this in the same vein of:

* A component can only have one <script context="module"> element
* A component can only have one instance-level <script> element




Issue discussion:
Following on about compiler warnings, the idea could be generalised as:

`this reactive statement will never be triggered / is unused`

this means you could handle other edge cases too, ie:

```js
const foo;
$: console.log(foo);
```

Via: https://discord.com/channels/457912077277855764/457912077277855766/794528287002329088
It is expected, and is described in the reference in the blue box in https://svelte.dev/docs#script_context_module but perhaps should also be mentioned in the tutorial
Thanks.

Should I split out another issue for reactive statement warnings? Seems like I've really made two issues here.

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,7 @@
 
 ## Unreleased
 
+* Warn when using `module` variables reactively, and close weird reactivity loophole ([#5847](https://github.com/sveltejs/svelte/pull/5847))
 * Throw a parser error for `class:` directives with an empty class name ([#5858](https://github.com/sveltejs/svelte/issues/5858))
 * Fix extraneous store subscription in SSR mode ([#5883](https://github.com/sveltejs/svelte/issues/5883))
 * Don't emit update code for `class:` directives whose expression is not dynamic ([#5919](https://github.com/sveltejs/svelte/issues/5919))
diff --git a/src/compiler/compile/Component.ts b/src/compiler/compile/Component.ts
--- a/src/compiler/compile/Component.ts
+++ b/src/compiler/compile/Component.ts
@@ -1175,15 +1175,20 @@ export default class Component {
 	extract_reactive_declarations() {
 		const component = this;
 
-		const unsorted_reactive_declarations = [];
+		const unsorted_reactive_declarations: Array<{
+			assignees: Set<string>;
+			dependencies: Set<string>;
+			node: Node;
+			declaration: Node;
+		}> = [];
 
 		this.ast.instance.content.body.forEach(node => {
 			if (node.type === 'LabeledStatement' && node.label.name === '$') {
 				this.reactive_declaration_nodes.add(node);
 
-				const assignees = new Set();
+				const assignees = new Set<string>();
 				const assignee_nodes = new Set();
-				const dependencies = new Set();
+				const dependencies = new Set<string>();
 
 				let scope = this.instance_scope;
 				const map = this.instance_scope_map;
@@ -1214,10 +1219,22 @@ export default class Component {
 								const { name } = identifier;
 								const owner = scope.find_owner(name);
 								const variable = component.var_lookup.get(name);
-								if (variable) variable.is_reactive_dependency = true;
+								let should_add_as_dependency = true;
+
+								if (variable) {
+									variable.is_reactive_dependency = true;
+									if (variable.module) {
+										should_add_as_dependency = false;
+										component.warn(node as any, {
+											code: 'module-script-reactive-declaration',
+											message: `"${name}" is declared in a module script and will not be reactive`
+										});
+									}
+								}
 								const is_writable_or_mutated =
 									variable && (variable.writable || variable.mutated);
 								if (
+									should_add_as_dependency &&
 									(!owner || owner === component.instance_scope) &&
 									(name[0] === '$' || is_writable_or_mutated)
 								) {


Test patch:
diff --git a/test/js/samples/reactive-class-optimized/expected.js b/test/js/samples/reactive-class-optimized/expected.js
--- a/test/js/samples/reactive-class-optimized/expected.js
+++ b/test/js/samples/reactive-class-optimized/expected.js
@@ -148,12 +148,7 @@ function instance($$self, $$props, $$invalidate) {
 		reactiveConst.x += 1;
 	}
 
-	$$self.$$.update = () => {
-		if ($$self.$$.dirty & /*reactiveModuleVar*/ 0) {
-			$: $$subscribe_reactiveDeclaration($$invalidate(1, reactiveDeclaration = reactiveModuleVar * 2));
-		}
-	};
-
+	$: $$subscribe_reactiveDeclaration($$invalidate(1, reactiveDeclaration = reactiveModuleVar * 2));
 	return [reactiveConst, reactiveDeclaration, $reactiveStoreVal, $reactiveDeclaration];
 }
 
diff --git a/test/runtime/samples/reactive-statement-module-vars/_config.js b/test/runtime/samples/reactive-statement-module-vars/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/reactive-statement-module-vars/_config.js
@@ -0,0 +1,18 @@
+export default {
+	html: `
+		a: moduleA
+		b: moduleB
+		moduleA: moduleA
+		moduleB: moduleB
+	`,
+	async test({ assert, target, component }) {
+		await component.updateModuleA();
+
+		assert.htmlEqual(target.innerHTML, `
+			a: moduleA
+			b: moduleB
+			moduleA: moduleA
+			moduleB: moduleB
+		`);
+	}
+};
diff --git a/test/runtime/samples/reactive-statement-module-vars/main.svelte b/test/runtime/samples/reactive-statement-module-vars/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/reactive-statement-module-vars/main.svelte
@@ -0,0 +1,15 @@
+<script context="module">
+	let moduleA = 'moduleA';
+	let moduleB = 'moduleB';
+</script>
+<script>
+	export function updateModuleA() {
+		moduleA = 'something else';
+	}
+	$: a = moduleA;
+	$: b = moduleB;
+</script>
+a: {a}
+b: {b}
+moduleA: {moduleA}
+moduleB: {moduleB}
diff --git a/test/validator/samples/reactive-module-variable/input.svelte b/test/validator/samples/reactive-module-variable/input.svelte
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/reactive-module-variable/input.svelte
@@ -0,0 +1,6 @@
+<script context="module">
+	let foo;
+</script>
+<script>
+	$: bar = foo;
+</script>
diff --git a/test/validator/samples/reactive-module-variable/warnings.json b/test/validator/samples/reactive-module-variable/warnings.json
new file mode 100644
--- /dev/null
+++ b/test/validator/samples/reactive-module-variable/warnings.json
@@ -0,0 +1,17 @@
+[
+	{
+		"code": "module-script-reactive-declaration",
+		"message": "\"foo\" is declared in a module script and will not be reactive",
+		"pos": 65,
+		"start": {
+			"character": 65,
+			"column": 10,
+			"line": 5
+		},
+		"end": {
+			"character": 68,
+			"column": 13,
+			"line": 5
+		}
+	}
+]