Repository: sveltejs/svelte (JavaScript)
Date: 2018-05-06 16:21:11+00:00
Task type: Bug Fix

Issue:
outro is not aborted
If a block has an outro, and the block is recreated during that outro, the transition isn't aborted — the content is still removed. [REPL](https://svelte.technology/repl?version=2.4.4&gist=ec5210f14bc4d27ce1994e8bc14895fa)


Issue discussion:

Solution patch:
diff --git a/src/compile/dom/Block.ts b/src/compile/dom/Block.ts
--- a/src/compile/dom/Block.ts
+++ b/src/compile/dom/Block.ts
@@ -232,7 +232,7 @@ export default class Block {
 			}
 		}
 
-		if (this.hasIntroMethod) {
+		if (this.hasIntroMethod || this.hasOutroMethod) {
 			if (hasIntros) {
 				properties.addBlock(deindent`
 					${dev ? 'i: function intro' : 'i'}(#target, anchor) {
@@ -252,9 +252,7 @@ export default class Block {
 					},
 				`);
 			}
-		}
 
-		if (this.hasOutroMethod) {
 			if (hasOutros) {
 				properties.addBlock(deindent`
 					${dev ? 'o: function outro' : 'o'}(#outrocallback) {
diff --git a/src/compile/nodes/AwaitBlock.ts b/src/compile/nodes/AwaitBlock.ts
--- a/src/compile/nodes/AwaitBlock.ts
+++ b/src/compile/nodes/AwaitBlock.ts
@@ -132,8 +132,10 @@ export default class AwaitBlock extends Node {
 		const initialMountNode = parentNode || '#target';
 		const anchorNode = parentNode ? 'null' : 'anchor';
 
+		const hasTransitions = this.pending.block.hasIntroMethod || this.pending.block.hasOutroMethod;
+
 		block.builders.mount.addBlock(deindent`
-			${info}.block.${this.pending.block.hasIntroMethod ? 'i' : 'm'}(${initialMountNode}, ${info}.anchor = ${anchorNode});
+			${info}.block.${hasTransitions ? 'i' : 'm'}(${initialMountNode}, ${info}.anchor = ${anchorNode});
 			${info}.mount = () => ${updateMountNode};
 		`);
 
diff --git a/src/compile/nodes/EachBlock.ts b/src/compile/nodes/EachBlock.ts
--- a/src/compile/nodes/EachBlock.ts
+++ b/src/compile/nodes/EachBlock.ts
@@ -146,7 +146,7 @@ export default class EachBlock extends Node {
 		compiler.code.overwrite(c, c + 4, 'length');
 		const length = `[✂${c}-${c+4}✂]`;
 
-		const mountOrIntro = this.block.hasIntroMethod ? 'i' : 'm';
+		const mountOrIntro = (this.block.hasIntroMethod || this.block.hasOutroMethod) ? 'i' : 'm';
 		const vars = {
 			each,
 			create_each_block,
@@ -379,7 +379,7 @@ export default class EachBlock extends Node {
 
 		if (condition !== '') {
 			const forLoopBody = this.block.hasUpdateMethod
-				? this.block.hasIntroMethod
+				? (this.block.hasIntroMethod || this.block.hasOutroMethod)
 					? deindent`
 						if (${iterations}[#i]) {
 							${iterations}[#i].p(changed, child_ctx);
diff --git a/src/compile/nodes/Element.ts b/src/compile/nodes/Element.ts
--- a/src/compile/nodes/Element.ts
+++ b/src/compile/nodes/Element.ts
@@ -701,6 +701,8 @@ export default class Element extends Node {
 			const fn = `%transitions-${intro.name}`;
 
 			block.builders.intro.addBlock(deindent`
+				if (${name}) ${name}.invalidate();
+
 				#component.root._aftercreate.push(() => {
 					if (!${name}) ${name} = @wrapTransition(#component, ${this.var}, ${fn}, ${snippet}, true);
 					${name}.run(1);
@@ -748,6 +750,10 @@ export default class Element extends Node {
 
 				const fn = `%transitions-${outro.name}`;
 
+				block.builders.intro.addBlock(deindent`
+					if (${outroName}) ${outroName}.abort();
+				`);
+
 				// TODO hide elements that have outro'd (unless they belong to a still-outroing
 				// group) prior to their removal from the DOM
 				block.builders.outro.addBlock(deindent`
diff --git a/src/compile/nodes/IfBlock.ts b/src/compile/nodes/IfBlock.ts
--- a/src/compile/nodes/IfBlock.ts
+++ b/src/compile/nodes/IfBlock.ts
@@ -367,7 +367,7 @@ export default class IfBlock extends Node {
 		const updateMountNode = this.getUpdateMountNode(anchor);
 
 		const enter = dynamic
-			? branch.hasIntroMethod
+			? (branch.hasIntroMethod || branch.hasOutroMethod)
 				? deindent`
 					if (${name}) {
 						${name}.p(changed, ctx);
@@ -387,7 +387,7 @@ export default class IfBlock extends Node {
 						${name}.m(${updateMountNode}, ${anchor});
 					}
 				`
-			: branch.hasIntroMethod
+			: (branch.hasIntroMethod || branch.hasOutroMethod)
 				? deindent`
 					if (!${name}) {
 						${name} = ${branch.block}(#component, ctx);
diff --git a/src/shared/transitions.js b/src/shared/transitions.js
--- a/src/shared/transitions.js
+++ b/src/shared/transitions.js
@@ -6,9 +6,10 @@ export function linear(t) {
 }
 
 export function generateRule({ a, b, delta, duration }, ease, fn) {
+	const step = 16.666 / duration;
 	let keyframes = '{\n';
 
-	for (let p = 0; p <= 1; p += 16.666 / duration) {
+	for (let p = 0; p <= 1; p += step) {
 		const t = a + delta * ease(p);
 		keyframes += p * 100 + `%{${fn(t)}}\n`;
 	}
@@ -112,7 +113,7 @@ export function wrapTransition(component, node, fn, params, intro) {
 
 			component.fire(`${program.b ? 'intro' : 'outro'}.end`, { node });
 
-			if (!program.b) {
+			if (!program.b && !program.invalidated) {
 				program.group.callbacks.push(() => {
 					program.callback();
 					if (obj.css) transitionManager.deleteRule(node, program.name);
@@ -123,17 +124,26 @@ export function wrapTransition(component, node, fn, params, intro) {
 						fn();
 					});
 				}
+			} else {
+				if (obj.css) transitionManager.deleteRule(node, program.name);
 			}
 
-			this.program = null;
 			this.running = !!this.pending;
 		},
 
 		abort() {
-			if (obj.tick) obj.tick(1);
-			if (obj.css) transitionManager.deleteRule(node, this.program.name);
-			this.program = this.pending = null;
-			this.running = false;
+			if (this.program) {
+				if (obj.tick) obj.tick(1);
+				if (obj.css) transitionManager.deleteRule(node, this.program.name);
+				this.program = this.pending = null;
+				this.running = false;
+			}
+		},
+
+		invalidate() {
+			if (this.program) {
+				this.program.invalidated = true;
+			}
 		}
 	};
 }
@@ -204,7 +214,7 @@ export var transitionManager = {
 	deleteRule(node, name) {
 		node.style.animation = node.style.animation
 			.split(', ')
-			.filter(anim => anim.indexOf(name) === -1)
+			.filter(anim => anim && anim.indexOf(name) === -1)
 			.join(', ');
 	},
 


Test patch:
diff --git a/test/runtime/samples/transition-js-aborted-outro-in-each/_config.js b/test/runtime/samples/transition-js-aborted-outro-in-each/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-aborted-outro-in-each/_config.js
@@ -0,0 +1,39 @@
+export default {
+	data: {
+		things: [
+			'one',
+			'two',
+			'three'
+		]
+	},
+
+	test(assert, component, target, window, raf) {
+		const { things } = component.get();
+
+		component.set({ things: [] });
+		const spans = target.querySelectorAll('span');
+
+		raf.tick(25);
+		assert.equal(spans[0].foo, 0.75);
+		assert.equal(spans[1].foo, undefined);
+		assert.equal(spans[2].foo, undefined);
+
+		raf.tick(125);
+		assert.equal(spans[0].foo, 0);
+		assert.equal(spans[1].foo, 0.25);
+		assert.equal(spans[2].foo, 0.75);
+
+		component.set({ things });
+		raf.tick(225);
+
+		assert.htmlEqual(target.innerHTML, `
+			<span>one</span>
+			<span>two</span>
+			<span>three</span>
+		`);
+
+		assert.equal(spans[0].foo, 1);
+		assert.equal(spans[1].foo, 1);
+		assert.equal(spans[2].foo, 1);
+	},
+};
diff --git a/test/runtime/samples/transition-js-aborted-outro-in-each/main.html b/test/runtime/samples/transition-js-aborted-outro-in-each/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-aborted-outro-in-each/main.html
@@ -0,0 +1,19 @@
+{#each things as thing, i}
+	<span out:foo="{delay: i * 50}">{thing}</span>
+{/each}
+
+<script>
+	export default {
+		transitions: {
+			foo(node, params) {
+				return {
+					delay: params.delay,
+					duration: 100,
+					tick: t => {
+						node.foo = t;
+					}
+				};
+			}
+		}
+	};
+</script>
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-aborted-outro/_config.js b/test/runtime/samples/transition-js-aborted-outro/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-aborted-outro/_config.js
@@ -0,0 +1,24 @@
+export default {
+	data: {
+		visible: true,
+	},
+
+	test(assert, component, target, window, raf) {
+		component.set({ visible: false });
+		const span = target.querySelector('span');
+
+		raf.tick(50);
+		assert.equal(span.foo, 0.5);
+
+		component.set({ visible: true });
+		assert.equal(span.foo, 1);
+
+		raf.tick(75);
+		assert.equal(span.foo, 1);
+
+		raf.tick(100);
+		assert.htmlEqual(target.innerHTML, `
+			<span>hello</span>
+		`);
+	},
+};
diff --git a/test/runtime/samples/transition-js-aborted-outro/main.html b/test/runtime/samples/transition-js-aborted-outro/main.html
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-aborted-outro/main.html
@@ -0,0 +1,18 @@
+{#if visible}
+	<span out:foo>hello</span>
+{/if}
+
+<script>
+	export default {
+		transitions: {
+			foo(node, params) {
+				return {
+					duration: 100,
+					tick: t => {
+						node.foo = t;
+					}
+				};
+			}
+		}
+	};
+</script>
\ No newline at end of file