Repository: sveltejs/svelte (JavaScript)
Date: 2021-05-09 09:17:20+00:00
Task type: Bug Fix

Issue:
Slot having stale state if transition is reversed midway
**Describe the bug**
Slot content seems to have stale state if the component were to be destroyed, but the transition was reversed midway.

**To Reproduce**
[REPL](https://svelte.dev/repl/405911d112e040818e26e30f37c61b8d?version=3.25.0).

I apologize if the example is excessive, I just tried to recreate the components from my app as close as possible. My case:

1. I have an array of stuff in a store
2. I want to open a single item in a modal. The modal is a component with a default slot, that basically handles the transition of the modal wrapper based on a boolean prop
3. in this modal I will perform some async operations and then delete the item from the store. The modal should close with a transition every time and then be recreated with a new item if it is present in the store

Steps:

1. click on the "Run" button. It has a 300 seconds delay
2. the modal goes away, but then goes back up. Both the prop `text` and local variable `loading` are now stale: the `text` prop is the same as it was before the transition started and loading is in `true` state. They'll never update no matter how much you try
3. also, you can push the "Toggle loading" button and it won't do anything the first time (presumably, it would toggle the default `false` state to `true` state, therefore the interface doesn't change)

**Expected behavior**
Prop should be updated, and the local state should be independent.

**Stacktraces**
N/A

**Information about your Svelte project:**
- Your browser and the version: Brave 1.13.86 (latest), Chrome 85.0 (latest), Safari 13.1.2 (latest)
- Your operating system: OS X 10.15.6
- Svelte version: first noticed when was working with 3.23.1, though could be present before. Still present on 3.25.0
- Whether your project uses Webpack or Rollup: Rollup ()

**Severity**
Not really. I use shady timers to avoid this bug and it is not critical for me.

**Additional context**
I added a timer-based fix on line 21 of the `ShowSingleItem.svelte` component. It seems to me that this timer value is somehow connected to transition duration, although `scale` has a default of 400, but the fix works fine for me with a value of 350ms.


Issue discussion:
I think I am having the same problem:
- Go [here](https://svelte-materialify.vercel.app/getting-started/installation/)
- Then click the closed navigation groups on the left side multiple times quickly.

Solution patch:
diff --git a/src/compiler/compile/render_dom/wrappers/Slot.ts b/src/compiler/compile/render_dom/wrappers/Slot.ts
--- a/src/compiler/compile/render_dom/wrappers/Slot.ts
+++ b/src/compiler/compile/render_dom/wrappers/Slot.ts
@@ -107,7 +107,7 @@ export default class SlotWrapper extends Wrapper {
 			if (spread_dynamic_dependencies.size) {
 				get_slot_spread_changes_fn = renderer.component.get_unique_name(`get_${sanitize(slot_name)}_slot_spread_changes`);
 				renderer.blocks.push(b`
-					const ${get_slot_spread_changes_fn} = #dirty => ${renderer.dirty(Array.from(spread_dynamic_dependencies))} > 0 ? -1 : 0;
+					const ${get_slot_spread_changes_fn} = #dirty => ${renderer.dirty(Array.from(spread_dynamic_dependencies))};
 				`);
 			}
 		} else {
@@ -168,27 +168,41 @@ export default class SlotWrapper extends Wrapper {
 		if (block.has_outros) {
 			condition = x`!#current || ${condition}`;
 		}
-		let dirty = x`#dirty`;
-		if (block.has_outros) {
-			dirty = x`!#current ? ${renderer.get_initial_dirty()} : ${dirty}`;
+
+		// conditions to treat everything as dirty
+		const all_dirty_conditions = [
+			get_slot_spread_changes_fn ? x`${get_slot_spread_changes_fn}(#dirty)` : null,
+			block.has_outros ? x`!#current` : null
+		].filter(Boolean);
+		const all_dirty_condition = all_dirty_conditions.length ? all_dirty_conditions.reduce((condition1, condition2) => x`${condition1} || ${condition2}`): null;
+
+		let slot_update;
+		if (all_dirty_condition) {
+			const dirty = x`${all_dirty_condition} ? @get_all_dirty_from_scope(${renderer.reference('$$scope')}) : @get_slot_changes(${slot_definition}, ${renderer.reference('$$scope')}, #dirty, ${get_slot_changes_fn})`;
+
+			slot_update = b`
+				if (${slot}.p && ${condition}) {
+					@update_slot_base(${slot}, ${slot_definition}, #ctx, ${renderer.reference('$$scope')}, ${dirty}, ${get_slot_context_fn});
+				}
+			`;
+		} else {
+			slot_update = b`
+				if (${slot}.p && ${condition}) {
+					@update_slot(${slot}, ${slot_definition}, #ctx, ${renderer.reference('$$scope')}, #dirty, ${get_slot_changes_fn}, ${get_slot_context_fn});
+				}
+			`;
 		}
 
-		const slot_update = get_slot_spread_changes_fn ? b`
-			if (${slot}.p && ${condition}) {
-				@update_slot_spread(${slot}, ${slot_definition}, #ctx, ${renderer.reference('$$scope')}, ${dirty}, ${get_slot_changes_fn}, ${get_slot_spread_changes_fn}, ${get_slot_context_fn});
-			}
-		` : b`
-			if (${slot}.p && ${condition}) {
-				@update_slot(${slot}, ${slot_definition}, #ctx, ${renderer.reference('$$scope')}, ${dirty}, ${get_slot_changes_fn}, ${get_slot_context_fn});
-			}
-		`;
 		let fallback_condition = renderer.dirty(fallback_dynamic_dependencies);
+		let fallback_dirty = x`#dirty`;
 		if (block.has_outros) {
 			fallback_condition = x`!#current || ${fallback_condition}`;
+			fallback_dirty = x`!#current ? ${renderer.get_initial_dirty()} : ${fallback_dirty}`;
 		}
+		
 		const fallback_update = has_fallback && fallback_dynamic_dependencies.length > 0 && b`
 			if (${slot_or_fallback} && ${slot_or_fallback}.p && ${fallback_condition}) {
-				${slot_or_fallback}.p(#ctx, ${dirty});
+				${slot_or_fallback}.p(#ctx, ${fallback_dirty});
 			}
 		`;
 
diff --git a/src/runtime/internal/utils.ts b/src/runtime/internal/utils.ts
--- a/src/runtime/internal/utils.ts
+++ b/src/runtime/internal/utils.ts
@@ -109,20 +109,28 @@ export function get_slot_changes(definition, $$scope, dirty, fn) {
 	return $$scope.dirty;
 }
 
-export function update_slot(slot, slot_definition, ctx, $$scope, dirty, get_slot_changes_fn, get_slot_context_fn) {
-	const slot_changes = get_slot_changes(slot_definition, $$scope, dirty, get_slot_changes_fn);
+export function update_slot_base(slot, slot_definition, ctx, $$scope, slot_changes, get_slot_context_fn) {
 	if (slot_changes) {
 		const slot_context = get_slot_context(slot_definition, ctx, $$scope, get_slot_context_fn);
 		slot.p(slot_context, slot_changes);
 	}
 }
 
-export function update_slot_spread(slot, slot_definition, ctx, $$scope, dirty, get_slot_changes_fn, get_slot_spread_changes_fn, get_slot_context_fn) {
-	const slot_changes = get_slot_spread_changes_fn(dirty) | get_slot_changes(slot_definition, $$scope, dirty, get_slot_changes_fn);
-	if (slot_changes) {
-		const slot_context = get_slot_context(slot_definition, ctx, $$scope, get_slot_context_fn);
-		slot.p(slot_context, slot_changes);
+export function update_slot(slot, slot_definition, ctx, $$scope, dirty, get_slot_changes_fn, get_slot_context_fn) {
+	const slot_changes = get_slot_changes(slot_definition, $$scope, dirty, get_slot_changes_fn);
+	update_slot_base(slot, slot_definition, ctx, $$scope, slot_changes, get_slot_context_fn);
+}
+
+export function get_all_dirty_from_scope($$scope) {
+	if ($$scope.ctx.length > 32) {
+		const dirty = [];
+		const length = $$scope.ctx.length / 32;
+		for (let i = 0; i < length; i++) {
+			dirty[i] = -1;
+		}
+		return dirty;
 	}
+	return -1;
 }
 
 export function exclude_internal_props(props) {


Test patch:
diff --git a/test/runtime/samples/transition-js-slot-2/_config.js b/test/runtime/samples/transition-js-slot-2/_config.js
--- a/test/runtime/samples/transition-js-slot-2/_config.js
+++ b/test/runtime/samples/transition-js-slot-2/_config.js
@@ -1,3 +1,4 @@
+// cancelled the transition halfway
 export default {
 	html: `
 		<div>Foo</div>
diff --git a/test/runtime/samples/transition-js-slot-4-cancelled/Nested.svelte b/test/runtime/samples/transition-js-slot-4-cancelled/Nested.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-4-cancelled/Nested.svelte
@@ -0,0 +1,19 @@
+<script>
+	export let visible;
+	export let slotProps;
+
+	function fade(node) {
+		return {
+			duration: 100,
+			tick: t => {
+				node.foo = t;
+			}
+		};
+	}
+</script>
+
+{#if visible}
+	<div transition:fade>
+		<slot {slotProps}></slot>
+	</div>
+{/if}
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-slot-4-cancelled/_config.js b/test/runtime/samples/transition-js-slot-4-cancelled/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-4-cancelled/_config.js
@@ -0,0 +1,35 @@
+// updated props in the middle of transitions
+// and cancelled the transition halfway
+export default {
+	html: `
+		<div>outside Foo Foo Foo</div>
+		<div>inside Foo Foo Foo</div>
+	`,
+	props: {
+		props: 'Foo'
+	},
+
+	async test({ assert, component, target, window, raf }) {
+		await component.hide();
+		const [, div] = target.querySelectorAll('div');
+
+		raf.tick(50);
+		assert.equal(div.foo, 0.5);
+
+		component.props = 'Bar';
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Foo Foo Foo</div>
+		`);
+
+		await component.show();
+
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Bar Bar Bar</div>
+		`);
+
+		raf.tick(100);
+		assert.equal(div.foo, 1);
+	}
+};
diff --git a/test/runtime/samples/transition-js-slot-4-cancelled/main.svelte b/test/runtime/samples/transition-js-slot-4-cancelled/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-4-cancelled/main.svelte
@@ -0,0 +1,22 @@
+<script>
+	import Nested from './Nested.svelte';
+
+	let visible = true;
+	let state = 'Foo';
+	let slotProps = 'Foo';
+	export let props;
+
+	export function show() {
+		visible = true;
+	}
+	export function hide() {
+		visible = false;
+		state = 'Bar';
+		slotProps = 'Bar';
+	}
+</script>
+
+<div>outside {state} {props} {slotProps}</div>
+<Nested {visible} {slotProps} let:slotProps>
+	inside {state} {props} {slotProps}
+</Nested>
diff --git a/test/runtime/samples/transition-js-slot-5-cancelled-overflow/Nested.svelte b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/Nested.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/Nested.svelte
@@ -0,0 +1,19 @@
+<script>
+	export let visible;
+	export let slotProps;
+
+	function fade(node) {
+		return {
+			duration: 100,
+			tick: t => {
+				node.foo = t;
+			}
+		};
+	}
+</script>
+
+{#if visible}
+	<div transition:fade>
+		<slot {slotProps}></slot>
+	</div>
+{/if}
diff --git a/test/runtime/samples/transition-js-slot-5-cancelled-overflow/_config.js b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/_config.js
@@ -0,0 +1,40 @@
+// updated props in the middle of transitions
+// and cancelled the transition halfway
+// + spreaded props + overflow context
+
+export default {
+	html: `
+		<div>outside Foo Foo Foo</div>
+		<div>inside Foo Foo Foo</div>
+		0
+	`,
+	props: {
+		props: 'Foo'
+	},
+
+	async test({ assert, component, target, window, raf }) {
+		await component.hide();
+		const [, div] = target.querySelectorAll('div');
+
+		raf.tick(50);
+		assert.equal(div.foo, 0.5);
+
+		component.props = 'Bar';
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Foo Foo Foo</div>
+			0
+		`);
+
+		await component.show();
+
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Bar Bar Bar</div>
+			0
+		`);
+
+		raf.tick(100);
+		assert.equal(div.foo, 1);
+	}
+};
diff --git a/test/runtime/samples/transition-js-slot-5-cancelled-overflow/main.svelte b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-5-cancelled-overflow/main.svelte
@@ -0,0 +1,27 @@
+<script>
+	import Nested from './Nested.svelte';
+
+	export let a1=0,a2=0,a3=0,a4=0,a5=0,a6=0,a7=0,a8=0,a9=0,a10=0,a11=0, a12=0,a13=0,a14=0,a15=0,a16=0,a17=0,a18=0,a19=0,a20=0,a21=0, a22=0,a23=0,a24=0,a25=0,a26=0,a27=0,a28=0,a29=0,a30=0,a31=0,a32=0,a33=0;
+
+	let visible = true;
+	let state = 'Foo';
+	let slotProps = 'Foo';
+	export let props;
+
+	export function show() {
+		visible = true;
+	}
+	export function hide() {
+		visible = false;
+		state = 'Bar';
+		slotProps = 'Bar';
+	}
+</script>
+
+<div>outside {state} {props} {slotProps}</div>
+
+<Nested {visible} {slotProps} let:slotProps>
+	inside {state} {props} {slotProps}
+</Nested>
+
+{a1+a2+a3+a4+a5+a6+a7+a8+a9+a10+a11+a12+a13+a14+a15+a16+a17+a18+a19+a20+a21+a22+a23+a24+a25+a26+a27+a28+a29+a30+a31+a32+a33}
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested.svelte b/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested.svelte
@@ -0,0 +1,19 @@
+<script>
+	export let visible;
+	export let slotProps;
+
+	function fade(node) {
+		return {
+			duration: 100,
+			tick: t => {
+				node.foo = t;
+			}
+		};
+	}
+</script>
+
+{#if visible}
+	<div transition:fade>
+		<slot {...slotProps}></slot>
+	</div>
+{/if}
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested2.svelte b/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested2.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-6-spread-cancelled/Nested2.svelte
@@ -0,0 +1,19 @@
+<script>
+	export let visible;
+	let slotProps = { slotProps: 'XXX' };
+
+	function fade(node) {
+		return {
+			duration: 100,
+			tick: t => {
+				node.foo = t;
+			}
+		};
+	}
+</script>
+
+{#if visible}
+	<div transition:fade>
+		<slot {...slotProps}></slot>
+	</div>
+{/if}
\ No newline at end of file
diff --git a/test/runtime/samples/transition-js-slot-6-spread-cancelled/_config.js b/test/runtime/samples/transition-js-slot-6-spread-cancelled/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-6-spread-cancelled/_config.js
@@ -0,0 +1,40 @@
+// updated props in the middle of transitions
+// and cancelled the transition halfway
+// with spreaded props
+
+export default {
+	html: `
+		<div>outside Foo Foo Foo</div>
+		<div>inside Foo Foo Foo</div>
+		<div>inside Foo Foo XXX</div>
+	`,
+	props: {
+		props: 'Foo'
+	},
+
+	async test({ assert, component, target, window, raf }) {
+		await component.hide();
+		const [, div] = target.querySelectorAll('div');
+
+		raf.tick(50);
+		assert.equal(div.foo, 0.5);
+
+		component.props = 'Bar';
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Foo Foo Foo</div>
+			<div>inside Foo Foo XXX</div>
+		`);
+
+		await component.show();
+
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Bar Bar Bar</div>
+			<div>inside Bar Bar XXX</div>
+		`);
+
+		raf.tick(100);
+		assert.equal(div.foo, 1);
+	}
+};
diff --git a/test/runtime/samples/transition-js-slot-6-spread-cancelled/main.svelte b/test/runtime/samples/transition-js-slot-6-spread-cancelled/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-6-spread-cancelled/main.svelte
@@ -0,0 +1,26 @@
+<script>
+	import Nested from './Nested.svelte';
+	import Nested2 from './Nested2.svelte';
+
+	let visible = true;
+	let state = 'Foo';
+	let slotProps = { slotProps: 'Foo' };
+	export let props;
+
+	export function show() {
+		visible = true;
+	}
+	export function hide() {
+		visible = false;
+		state = 'Bar';
+		slotProps = { slotProps: 'Bar' };
+	}
+</script>
+
+<div>outside {state} {props} {slotProps.slotProps}</div>
+<Nested {visible} {slotProps} let:slotProps>
+	inside {state} {props} {slotProps}
+</Nested>
+<Nested2 {visible} let:slotProps>
+	inside {state} {props} {slotProps}
+</Nested2>
diff --git a/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/Nested.svelte b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/Nested.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/Nested.svelte
@@ -0,0 +1,19 @@
+<script>
+	export let visible;
+	export let slotProps;
+
+	function fade(node) {
+		return {
+			duration: 100,
+			tick: t => {
+				node.foo = t;
+			}
+		};
+	}
+</script>
+
+{#if visible}
+	<div transition:fade>
+		<slot {...slotProps}></slot>
+	</div>
+{/if}
diff --git a/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/_config.js b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/_config.js
@@ -0,0 +1,40 @@
+// updated props in the middle of transitions
+// and cancelled the transition halfway
+// + spreaded props + overflow context
+
+export default {
+	html: `
+		<div>outside Foo Foo Foo</div>
+		<div>inside Foo Foo Foo</div>
+		0
+	`,
+	props: {
+		props: 'Foo'
+	},
+
+	async test({ assert, component, target, window, raf }) {
+		await component.hide();
+		const [, div] = target.querySelectorAll('div');
+
+		raf.tick(50);
+		assert.equal(div.foo, 0.5);
+
+		component.props = 'Bar';
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Foo Foo Foo</div>
+			0
+		`);
+
+		await component.show();
+
+		assert.htmlEqual(target.innerHTML, `
+			<div>outside Bar Bar Bar</div>
+			<div>inside Bar Bar Bar</div>
+			0
+		`);
+
+		raf.tick(100);
+		assert.equal(div.foo, 1);
+	}
+};
diff --git a/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/main.svelte b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/transition-js-slot-7-spread-cancelled-overflow/main.svelte
@@ -0,0 +1,27 @@
+<script>
+	import Nested from './Nested.svelte';
+
+	export let a1=0,a2=0,a3=0,a4=0,a5=0,a6=0,a7=0,a8=0,a9=0,a10=0,a11=0, a12=0,a13=0,a14=0,a15=0,a16=0,a17=0,a18=0,a19=0,a20=0,a21=0, a22=0,a23=0,a24=0,a25=0,a26=0,a27=0,a28=0,a29=0,a30=0,a31=0,a32=0,a33=0;
+
+	let visible = true;
+	let state = 'Foo';
+	let slotProps = { slotProps: 'Foo' };
+	export let props;
+
+	export function show() {
+		visible = true;
+	}
+	export function hide() {
+		visible = false;
+		state = 'Bar';
+		slotProps = { slotProps: 'Bar' };
+	}
+</script>
+
+<div>outside {state} {props} {slotProps.slotProps}</div>
+
+<Nested {visible} {slotProps} let:slotProps>
+	inside {state} {props} {slotProps}
+</Nested>
+
+{a1+a2+a3+a4+a5+a6+a7+a8+a9+a10+a11+a12+a13+a14+a15+a16+a17+a18+a19+a20+a21+a22+a23+a24+a25+a26+a27+a28+a29+a30+a31+a32+a33}
\ No newline at end of file