Repository: sveltejs/svelte (JavaScript)
Date: 2018-12-31 15:44:54+00:00
Task type: Bug Fix

Issue:
Animate directive functions must be exported to be used with an easing function
When using `animate` directives the correspdoning function must be marked as an `export` in order to be used with an easing function. Without an easing function they work fine, any reference to an easing function within the function body causes the animation to fail (even if it is just a `console.log()`).

This bug is present with imported easing functions and easing functions defined outside of the body of the animate function. If the easing is inlined or the definition appears inside of the animate function body, it works fine.

[REPL Link](https://v3.svelte.technology/repl?version=3.0.0-alpha12&gist=4dd95924742856ae7459637b8f4b5fc6) — Remove the export to break the example.

✅ Easing functions defined with the animation function body work fine:
```js
function animate(node, {from, to}, params) {
  const x = from.left - to.left;
  const easing = t => --t * t * t * t * t + 1;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

✅ As do inline easing functions:
```js
function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing: t => --t * t * t * t * t + 1,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

✅ Marking the animation function as an export causes them to behave normally:
```js
const easing = t => --t * t * t * t * t + 1;

export function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

❌ Removing the export causes the function to fail (same behaviour with imported easing functions):
```js
const easing = t => --t * t * t * t * t + 1;

function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```
Animate directive functions must be exported to be used with an easing function
When using `animate` directives the correspdoning function must be marked as an `export` in order to be used with an easing function. Without an easing function they work fine, any reference to an easing function within the function body causes the animation to fail (even if it is just a `console.log()`).

This bug is present with imported easing functions and easing functions defined outside of the body of the animate function. If the easing is inlined or the definition appears inside of the animate function body, it works fine.

[REPL Link](https://v3.svelte.technology/repl?version=3.0.0-alpha12&gist=4dd95924742856ae7459637b8f4b5fc6) — Remove the export to break the example.

✅ Easing functions defined with the animation function body work fine:
```js
function animate(node, {from, to}, params) {
  const x = from.left - to.left;
  const easing = t => --t * t * t * t * t + 1;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

✅ As do inline easing functions:
```js
function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing: t => --t * t * t * t * t + 1,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

✅ Marking the animation function as an export causes them to behave normally:
```js
const easing = t => --t * t * t * t * t + 1;

export function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```

❌ Removing the export causes the function to fail (same behaviour with imported easing functions):
```js
const easing = t => --t * t * t * t * t + 1;

function animate(node, {from, to}, params) {
  const x = from.left - to.left;

  return {
    duration: 300,
    easing,
    css: (t, u) => `transform: translateX(${u * x}px);`
  }
};
```


Issue discussion:

Solution patch:
diff --git a/src/compile/Component.ts b/src/compile/Component.ts
--- a/src/compile/Component.ts
+++ b/src/compile/Component.ts
@@ -683,7 +683,7 @@ export default class Component {
 		// reference instance variables other than other
 		// hoistable functions. TODO others?
 
-		const { hoistable_names, hoistable_nodes } = this;
+		const { hoistable_names, hoistable_nodes, imported_declarations } = this;
 
 		const top_level_function_declarations = new Map();
 
@@ -738,6 +738,7 @@ export default class Component {
 						if (owner === instance_scope) {
 							if (name === fn_declaration.id.name) return;
 							if (hoistable_names.has(name)) return;
+							if (imported_declarations.has(name)) return;
 
 							if (top_level_function_declarations.has(name)) {
 								const other_declaration = top_level_function_declarations.get(name);
diff --git a/src/compile/nodes/Animation.ts b/src/compile/nodes/Animation.ts
--- a/src/compile/nodes/Animation.ts
+++ b/src/compile/nodes/Animation.ts
@@ -1,12 +1,13 @@
 import Node from './shared/Node';
 import Expression from './shared/Expression';
+import Component from '../Component';
 
 export default class Animation extends Node {
 	type: 'Animation';
 	name: string;
 	expression: Expression;
 
-	constructor(component, parent, scope, info) {
+	constructor(component: Component, parent, scope, info) {
 		super(component, parent, scope, info);
 
 		component.warn_if_undefined(info, scope);
diff --git a/src/compile/nodes/shared/Expression.ts b/src/compile/nodes/shared/Expression.ts
--- a/src/compile/nodes/shared/Expression.ts
+++ b/src/compile/nodes/shared/Expression.ts
@@ -264,17 +264,19 @@ export default class Expression {
 							code.overwrite(node.start, node.end, dirty.map(n => `$$invalidate('${n}', ${n})`).join('; '));
 						} else {
 							names.forEach(name => {
-								if (!scope.declarations.has(name)) {
-									pending_assignments.add(name);
-								}
+								if (scope.declarations.has(name)) return;
+								if (component.imported_declarations.has(name)) return;
+
+								pending_assignments.add(name);
 							});
 						}
 					} else if (node.type === 'UpdateExpression') {
 						const { name } = getObject(node.argument);
 
-						if (!scope.declarations.has(name)) {
-							pending_assignments.add(name);
-						}
+						if (scope.declarations.has(name)) return;
+						if (component.imported_declarations.has(name)) return;
+
+						pending_assignments.add(name);
 					}
 				} else {
 					if (node.type === 'AssignmentExpression') {
diff --git a/src/compile/render-dom/index.ts b/src/compile/render-dom/index.ts
--- a/src/compile/render-dom/index.ts
+++ b/src/compile/render-dom/index.ts
@@ -174,10 +174,11 @@ export default function dom(
 						code.overwrite(node.start, node.end, dirty.map(n => `$$invalidate('${n}', ${n})`).join('; '));
 					} else {
 						names.forEach(name => {
-							if (scope.findOwner(name) === component.instance_scope) {
-								pending_assignments.add(name);
-								component.has_reactive_assignments = true;
-							}
+							if (component.imported_declarations.has(name)) return;
+							if (scope.findOwner(name) !== component.instance_scope) return;
+
+							pending_assignments.add(name);
+							component.has_reactive_assignments = true;
 						});
 					}
 				}
@@ -185,10 +186,11 @@ export default function dom(
 				else if (node.type === 'UpdateExpression') {
 					const { name } = getObject(node.argument);
 
-					if (scope.findOwner(name) === component.instance_scope) {
-						pending_assignments.add(name);
-						component.has_reactive_assignments = true;
-					}
+					if (component.imported_declarations.has(name)) return;
+					if (scope.findOwner(name) !== component.instance_scope) return;
+
+					pending_assignments.add(name);
+					component.has_reactive_assignments = true;
 				}
 
 				if (pending_assignments.size > 0) {
diff --git a/src/compile/render-dom/wrappers/Element/index.ts b/src/compile/render-dom/wrappers/Element/index.ts
--- a/src/compile/render-dom/wrappers/Element/index.ts
+++ b/src/compile/render-dom/wrappers/Element/index.ts
@@ -682,10 +682,7 @@ export default class ElementWrapper extends Wrapper {
 
 		const params = this.node.animation.expression ? this.node.animation.expression.render() : '{}';
 
-		let { name } = this.node.animation;
-		if (!component.hoistable_names.has(name) && !component.imported_declarations.has(name)) {
-			name = `ctx.${name}`;
-		}
+		const name = component.qualify(this.node.animation.name);
 
 		block.builders.animate.addBlock(deindent`
 			if (${animation}) ${animation}.stop();


Test patch:
diff --git a/test/runtime/samples/animation-js-easing/_config.js b/test/runtime/samples/animation-js-easing/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/animation-js-easing/_config.js
@@ -0,0 +1,56 @@
+export default {
+	props: {
+		things: [
+			{ id: 1, name: 'a' },
+			{ id: 2, name: 'b' },
+			{ id: 3, name: 'c' },
+			{ id: 4, name: 'd' },
+			{ id: 5, name: 'e' }
+		]
+	},
+
+	html: `
+		<div>a</div>
+		<div>b</div>
+		<div>c</div>
+		<div>d</div>
+		<div>e</div>
+	`,
+
+	test({ assert, component, target, window, raf }) {
+		let divs = document.querySelectorAll('div');
+		divs.forEach(div => {
+			div.getBoundingClientRect = function() {
+				const index = [...this.parentNode.children].indexOf(this);
+				const top = index * 30;
+
+				return {
+					left: 0,
+					right: 100,
+					top,
+					bottom: top + 20
+				}
+			};
+		})
+
+		component.things = [
+			{ id: 5, name: 'e' },
+			{ id: 2, name: 'b' },
+			{ id: 3, name: 'c' },
+			{ id: 4, name: 'd' },
+			{ id: 1, name: 'a' }
+		];
+
+		divs = document.querySelectorAll('div');
+		assert.equal(divs[0].dy, 120);
+		assert.equal(divs[4].dy, -120);
+
+		raf.tick(50);
+		assert.equal(divs[0].dy, 60);
+		assert.equal(divs[4].dy, -60);
+
+		raf.tick(100);
+		assert.equal(divs[0].dy, 0);
+		assert.equal(divs[4].dy, 0);
+	}
+};
\ No newline at end of file
diff --git a/test/runtime/samples/animation-js-easing/easing.js b/test/runtime/samples/animation-js-easing/easing.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/animation-js-easing/easing.js
@@ -0,0 +1,3 @@
+export function linear(t) {
+	return t;
+}
\ No newline at end of file
diff --git a/test/runtime/samples/animation-js-easing/main.html b/test/runtime/samples/animation-js-easing/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/animation-js-easing/main.html
@@ -0,0 +1,22 @@
+<script>
+	import { linear } from './easing.js';
+	export let things;
+
+	function flip(node, animation, params) {
+		const dx = animation.from.left - animation.to.left;
+		const dy = animation.from.top - animation.to.top;
+
+		return {
+			duration: 100,
+			easing: linear,
+			tick: (t, u) => {
+				node.dx = u * dx;
+				node.dy = u * dy;
+			}
+		};
+	}
+</script>
+
+{#each things as thing (thing.id)}
+	<div animate:flip>{thing.name}</div>
+{/each}
\ No newline at end of file