Repository: sveltejs/svelte (JavaScript)
Date: 2019-12-13 15:08:14+00:00
Task type: Bug Fix

Issue:
CI: event-handler-dynamic fails with exceptions
**Describe the bug**
CI logs show `event-handler-dynamic` failing to run with exceptions thrown after triggering button click events.

**Logs**
```
2019-12-10T16:53:34.3087021Z Error: Uncaught [TypeError: Cannot read property 'apply' of undefined]
2019-12-10T16:53:34.3088149Z     at reportException (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/helpers/runtime-script-errors.js:62:24)
2019-12-10T16:53:34.3089015Z     at innerInvokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:332:9)
2019-12-10T16:53:34.3089824Z     at invokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:267:3)
2019-12-10T16:53:34.3090633Z     at HTMLButtonElementImpl._dispatch (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:214:9)
2019-12-10T16:53:34.3091430Z     at HTMLButtonElementImpl.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:87:17)
2019-12-10T16:53:34.3091937Z     at HTMLButtonElement.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:144:23)
2019-12-10T16:53:34.3092817Z     at Object.test (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/_config.js:18:16)
2019-12-10T16:53:34.3093079Z     at Promise.resolve.then (/home/runner/work/svelte/svelte/test/runtime/index.js:193:37)
2019-12-10T16:53:34.3093490Z     at <anonymous> TypeError: Cannot read property 'apply' of undefined
2019-12-10T16:53:34.3094035Z     at HTMLButtonElement.<anonymous> (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/main.svelte:37:30)
2019-12-10T16:53:34.3094543Z     at innerInvokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:316:27)
2019-12-10T16:53:34.3095027Z     at invokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:267:3)
2019-12-10T16:53:34.3095507Z     at HTMLButtonElementImpl._dispatch (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:214:9)
2019-12-10T16:53:34.3096012Z     at HTMLButtonElementImpl.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:87:17)
2019-12-10T16:53:34.3096497Z     at HTMLButtonElement.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:144:23)
2019-12-10T16:53:34.3097185Z     at Object.test (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/_config.js:18:16)
2019-12-10T16:53:34.3097754Z     at Promise.resolve.then (/home/runner/work/svelte/svelte/test/runtime/index.js:193:37)
2019-12-10T16:53:34.3097902Z     at <anonymous>
2019-12-10T16:53:34.3154982Z     âœ“ event-handler-dynamic  (42ms)
2019-12-10T16:53:34.3450414Z Error: Uncaught [TypeError: Cannot read property 'apply' of undefined]
2019-12-10T16:53:34.3451353Z     at reportException (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/helpers/runtime-script-errors.js:62:24)
2019-12-10T16:53:34.3452328Z     at innerInvokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:332:9)
2019-12-10T16:53:34.3453515Z     at invokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:267:3)
2019-12-10T16:53:34.3455745Z     at HTMLButtonElementImpl._dispatch (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:214:9)
2019-12-10T16:53:34.3458053Z     at HTMLButtonElementImpl.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:87:17)
2019-12-10T16:53:34.3458987Z     at HTMLButtonElement.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:144:23)
2019-12-10T16:53:34.3459817Z     at Object.test (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/_config.js:18:16)
2019-12-10T16:53:34.3460313Z     at Promise.resolve.then (/home/runner/work/svelte/svelte/test/runtime/index.js:193:37)
2019-12-10T16:53:34.3461334Z     at <anonymous> TypeError: Cannot read property 'apply' of undefined
2019-12-10T16:53:34.3462291Z     at HTMLButtonElement.<anonymous> (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/main.svelte:67:30)
2019-12-10T16:53:34.3463254Z     at innerInvokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:316:27)
2019-12-10T16:53:34.3464028Z     at invokeEventListeners (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:267:3)
2019-12-10T16:53:34.3464808Z     at HTMLButtonElementImpl._dispatch (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:214:9)
2019-12-10T16:53:34.3465599Z     at HTMLButtonElementImpl.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/events/EventTarget-impl.js:87:17)
2019-12-10T16:53:34.3466102Z     at HTMLButtonElement.dispatchEvent (/home/runner/work/svelte/svelte/node_modules/jsdom/lib/jsdom/living/generated/EventTarget.js:144:23)
2019-12-10T16:53:34.3466795Z     at Object.test (/home/runner/work/svelte/svelte/test/runtime/samples/event-handler-dynamic/_config.js:18:16)
2019-12-10T16:53:34.3467233Z     at Promise.resolve.then (/home/runner/work/svelte/svelte/test/runtime/index.js:193:37)
```

**To Reproduce**
Take a look at the CI logs 

**Expected behavior**
The test case should run without exceptions **or** the CI check should fail.

**Severity**
Moderate.

If more tests fail in this manner, we'll be missing possible regressions because we think the CI check is OK when its not.



Issue discussion:
@tanhauhau, mind taking a look at this?
I see you were the last committer to this file.

The offending file is here: [event-handler-dynamic](https://github.com/sveltejs/svelte/blob/master/test/runtime/samples/event-handler-dynamic/_config.js)

It's passing for me locally on the latest master, and it's passing in github's CI - https://github.com/sveltejs/svelte/commit/0a6310f7a39b76bb884251cf3cc385c9298645db/checks?check_suite_id=351586083
@Conduitry, I pulled that exception from the latest master... the problem is that it is passing.
The test doesn't actually run because of the exception.... though you are right, Svelte does the right thing if you copy/paste the test case into a REPL.

False negatives are crazy bad for regression tests.
Performing the test steps manually in Chrome dev mode gives the same result:
![image](https://user-images.githubusercontent.com/5039735/70562167-e23e6280-1b59-11ea-9eb1-4d39a9efd0b2.png)

Here's a REPL:
https://svelte.dev/repl/54f3229b24e0493c9f7faf0f2dae2dfc?version=3.16.3

I found the issue... 

The test wants to see that clicking the button before assigning the handler does nothing.
Since the variable is unassigned, it throws the exception as it should.
If we really want to test dynamic assignment, we should give the handler a no-op function to start.
i don't understand your statement:
```js
let [hand1, hand2, btn] = div.querySelectorAll('button')
```
i have searched MDN, but there are no api like this.
and what do you want to do?
Closing this in favor of the root cause specified in #4090.
I've reopened this issue as a placeholder for the root cause of why exceptions in Svelte runtime don't fail tests. The actually answer lies in JSDOM's approximation of how real exceptions are handled by the browser.

In the specific test mentioned here, the event handler installed by Svelte has a bug where it doesn't check the validity of the callback before trying to execute it. Since the runtime is being triggered by `dispatchEvent(click)`, the resulting exception gets trapped by JSDOM, barfed out to the logs, and then dropped on the floor.

The simple solution is to install an `onerror` handler on the window and `preventDefault()` to stop the log stack trace. From the handler, we can then do something meaningful. I've been doing this on a test-by-test basis, but for long-term, we should look at installing the handler at a higher level so that any future test will be automatically protected false negatives due to runtime exceptions.

With the handler installed, you get a much clearer (and failed) test output:
```
let err = "";
window.addEventListener('error', (e) => {
	e.preventDefault();
	err = e.message;
});

await button.dispatchEvent(event);
assert.equal(err, "", err);
```
```
  1) runtime
       event-handler-dynamic :

      AssertionError [ERR_ASSERTION]: Cannot read property 'apply' of undefined
      + expected - actual

      -Cannot read property 'apply' of undefined

      at Object.test (test\runtime\samples\event-handler-dynamic\_config.js:24:10)

  cmd-click: test\runtime\samples\event-handler-dynamic/main.svelte
```

Solution patch:
diff --git a/src/compiler/compile/render_dom/wrappers/Element/EventHandler.ts b/src/compiler/compile/render_dom/wrappers/Element/EventHandler.ts
--- a/src/compiler/compile/render_dom/wrappers/Element/EventHandler.ts
+++ b/src/compiler/compile/render_dom/wrappers/Element/EventHandler.ts
@@ -30,7 +30,7 @@ export default class EventHandlerWrapper {
 
 		if (this.node.reassigned) {
 			block.maintain_context = true;
-			return x`function () { ${snippet}.apply(this, arguments); }`;
+			return x`function () { if (@is_function(${snippet})) ${snippet}.apply(this, arguments); }`;
 		}
 		return snippet;
 	}


Test patch:
diff --git a/test/js/samples/event-handler-dynamic/expected.js b/test/js/samples/event-handler-dynamic/expected.js
--- a/test/js/samples/event-handler-dynamic/expected.js
+++ b/test/js/samples/event-handler-dynamic/expected.js
@@ -6,6 +6,7 @@ import {
 	element,
 	init,
 	insert,
+	is_function,
 	listen,
 	noop,
 	run_all,
@@ -46,7 +47,7 @@ function create_fragment(ctx) {
 				listen(button0, "click", /*updateHandler1*/ ctx[2]),
 				listen(button1, "click", /*updateHandler2*/ ctx[3]),
 				listen(button2, "click", function () {
-					/*clickHandler*/ ctx[0].apply(this, arguments);
+					if (is_function(/*clickHandler*/ ctx[0])) /*clickHandler*/ ctx[0].apply(this, arguments);
 				})
 			];
 		},
diff --git a/test/runtime/samples/event-handler-dynamic-hash/_config.js b/test/runtime/samples/event-handler-dynamic-hash/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-hash/_config.js
@@ -0,0 +1,56 @@
+export default {
+	html: `
+	<p>
+		<button>set handler 1</button>
+		<button>set handler 2</button>
+	</p>
+	<p>0</p>
+	<button>click</button>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const [updateButton1, updateButton2, button] = target.querySelectorAll(
+			'button'
+		);
+
+		const event = new window.MouseEvent('click');
+		let err = "";
+		window.addEventListener('error', (e) => {
+			e.preventDefault();
+			err = e.message;
+		});
+
+		await button.dispatchEvent(event);
+		assert.equal(err, "", err);
+		assert.htmlEqual(target.innerHTML, `
+			<p>
+				<button>set handler 1</button>
+				<button>set handler 2</button>
+			</p>
+			<p>0</p>
+			<button>click</button>
+		`);
+
+		await updateButton1.dispatchEvent(event);
+		await button.dispatchEvent(event);
+		assert.htmlEqual(target.innerHTML, `
+			<p>
+				<button>set handler 1</button>
+				<button>set handler 2</button>
+			</p>
+			<p>1</p>
+			<button>click</button>
+		`);
+
+		await updateButton2.dispatchEvent(event);
+		await button.dispatchEvent(event);
+		assert.htmlEqual(target.innerHTML, `
+			<p>
+				<button>set handler 1</button>
+				<button>set handler 2</button>
+			</p>
+			<p>2</p>
+			<button>click</button>
+		`);
+	},
+};
diff --git a/test/runtime/samples/event-handler-dynamic-hash/main.svelte b/test/runtime/samples/event-handler-dynamic-hash/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-hash/main.svelte
@@ -0,0 +1,23 @@
+<script>
+	let clickHandler = {};
+	let number = 0;
+	
+	function updateHandler1(){
+		clickHandler.f = () => number = 1;
+	}
+
+	function updateHandler2(){
+		clickHandler.f = () => number = 2;
+	}
+	
+
+</script>
+
+<p>
+<button on:click={updateHandler1}>set handler 1</button>
+<button on:click={updateHandler2}>set handler 2</button>
+</p>
+
+<p>{ number }</p>
+
+<button on:click={clickHandler.f}>click</button>
\ No newline at end of file
diff --git a/test/runtime/samples/event-handler-dynamic-invalid/_config.js b/test/runtime/samples/event-handler-dynamic-invalid/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-invalid/_config.js
@@ -0,0 +1,28 @@
+export default {
+	html: `<button>undef</button>
+	<button>null</button>
+	<button>invalid</button>`,
+
+	async test({ assert, component, target, window }) {
+		const [buttonUndef, buttonNull, buttonInvalid] = target.querySelectorAll(
+			'button'
+		);
+
+		const event = new window.MouseEvent('click');
+		let err = "";
+		window.addEventListener('error', (e) => {
+			e.preventDefault();
+			err = e.message;
+		});
+
+		// All three should not throw if proper checking is done in runtime code
+		await buttonUndef.dispatchEvent(event);
+		assert.equal(err, "", err);
+
+		await buttonNull.dispatchEvent(event);
+		assert.equal(err, "", err);
+
+		await buttonInvalid.dispatchEvent(event);
+		assert.equal(err, "", err);
+	},
+};
diff --git a/test/runtime/samples/event-handler-dynamic-invalid/main.svelte b/test/runtime/samples/event-handler-dynamic-invalid/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-invalid/main.svelte
@@ -0,0 +1,13 @@
+<script>
+	let handlerUndef;
+	let handlerNull;
+	let handlerInvalid;
+
+	handlerUndef = undefined;
+	handlerNull = null;
+	handlerInvalid = 42;
+</script>
+
+<button on:click={handlerUndef}>undef</button>
+<button on:click={handlerNull}>null</button>
+<button on:click={handlerInvalid}>invalid</button>
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-once/_config.js b/test/runtime/samples/event-handler-dynamic-modifier-once/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-once/_config.js
@@ -0,0 +1,16 @@
+export default {
+	html: `
+		<button>0</button>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const button = target.querySelector('button');
+		const event = new window.MouseEvent('click');
+
+		await button.dispatchEvent(event);
+		assert.equal(component.count, 1);
+
+		await button.dispatchEvent(event);
+		assert.equal(component.count, 1);
+	}
+};
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-once/main.svelte b/test/runtime/samples/event-handler-dynamic-modifier-once/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-once/main.svelte
@@ -0,0 +1,7 @@
+<script>
+	let f;
+	export let count = 0;
+	f = () => count += 1;
+</script>
+
+<button on:click|once="{f}">{count}</button>
\ No newline at end of file
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/_config.js b/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/_config.js
@@ -0,0 +1,16 @@
+export default {
+	html: `
+		<button>click me</button>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const button = target.querySelector('button');
+		const event = new window.MouseEvent('click', {
+			cancelable: true
+		});
+
+		await button.dispatchEvent(event);
+
+		assert.ok(component.default_was_prevented);
+	}
+};
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/main.svelte b/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-prevent-default/main.svelte
@@ -0,0 +1,11 @@
+<script>
+	export let default_was_prevented;
+	let f;
+
+	function handle_click(event) {
+		default_was_prevented = event.defaultPrevented;
+	}
+	f = handle_click;
+</script>
+
+<button on:click|preventDefault={f}>click me</button>
\ No newline at end of file
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-self/_config.js b/test/runtime/samples/event-handler-dynamic-modifier-self/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-self/_config.js
@@ -0,0 +1,16 @@
+export default {
+	html: `
+		<div>
+			<button>click me</button>
+		</div>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const button = target.querySelector('button');
+		const event = new window.MouseEvent('click');
+
+		await button.dispatchEvent(event);
+
+		assert.ok(!component.inner_clicked);
+	},
+};
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-self/main.svelte b/test/runtime/samples/event-handler-dynamic-modifier-self/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-self/main.svelte
@@ -0,0 +1,13 @@
+<script>
+	export let inner_clicked;
+	let f;
+
+	function handle_click(event) {
+		inner_clicked = true;
+	}
+	f = handle_click;
+</script>
+
+<div on:click|self={f}>
+	<button>click me</button>
+</div>
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/_config.js b/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/_config.js
@@ -0,0 +1,19 @@
+export default {
+	html: `
+		<div>
+			<button>click me</button>
+		</div>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const button = target.querySelector('button');
+		const event = new window.MouseEvent('click', {
+			bubbles: true
+		});
+
+		await button.dispatchEvent(event);
+
+		assert.ok(component.inner_clicked);
+		assert.ok(!component.outer_clicked);
+	}
+};
diff --git a/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/main.svelte b/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-modifier-stop-propagation/main.svelte
@@ -0,0 +1,20 @@
+<script>
+	export let inner_clicked;
+	export let outer_clicked;
+	let f1;
+	let f2;
+
+	function handle_inner_click(event) {
+		inner_clicked = true;
+	}
+
+	function handle_outer_click(event) {
+		outer_clicked = true;
+	}
+	f1 = handle_inner_click;
+	f2 = handle_outer_click;
+</script>
+
+<div on:click={f2}>
+	<button on:click|stopPropagation={f1}>click me</button>
+</div>
\ No newline at end of file
diff --git a/test/runtime/samples/event-handler-dynamic-multiple/_config.js b/test/runtime/samples/event-handler-dynamic-multiple/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-multiple/_config.js
@@ -0,0 +1,14 @@
+export default {
+	html: `
+		<button>click me</button>
+	`,
+
+	async test({ assert, component, target, window }) {
+		const button = target.querySelector('button');
+		const event = new window.MouseEvent('click');
+
+		await button.dispatchEvent(event);
+		assert.equal(component.clickHandlerOne, 1);
+		assert.equal(component.clickHandlerTwo, 1);
+	}
+};
diff --git a/test/runtime/samples/event-handler-dynamic-multiple/main.svelte b/test/runtime/samples/event-handler-dynamic-multiple/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/event-handler-dynamic-multiple/main.svelte
@@ -0,0 +1,11 @@
+<script>
+	export let clickHandlerOne = 0;
+	export let clickHandlerTwo = 0;
+	let f1;
+	let f2;
+
+	f1 = () => clickHandlerOne++;
+	f2 = () => clickHandlerTwo++;
+</script>
+
+<button on:click='{f1}' on:click='{f2}'>click me</button>
diff --git a/test/runtime/samples/event-handler-dynamic/_config.js b/test/runtime/samples/event-handler-dynamic/_config.js
--- a/test/runtime/samples/event-handler-dynamic/_config.js
+++ b/test/runtime/samples/event-handler-dynamic/_config.js
@@ -14,8 +14,14 @@ export default {
 		);
 
 		const event = new window.MouseEvent('click');
+		let err = "";
+		window.addEventListener('error', (e) => {
+			e.preventDefault();
+			err = e.message;
+		});
 
 		await button.dispatchEvent(event);
+		assert.equal(err, "", err);
 		assert.htmlEqual(target.innerHTML, `
 			<p>
 				<button>set handler 1</button>
@@ -24,7 +30,7 @@ export default {
 			<p>0</p>
 			<button>click</button>
 		`);
-		
+
 		await updateButton1.dispatchEvent(event);
 		await button.dispatchEvent(event);
 		assert.htmlEqual(target.innerHTML, `
@@ -35,7 +41,7 @@ export default {
 			<p>1</p>
 			<button>click</button>
 		`);
-		
+
 		await updateButton2.dispatchEvent(event);
 		await button.dispatchEvent(event);
 		assert.htmlEqual(target.innerHTML, `