Repository: sveltejs/svelte (JavaScript)
Date: 2019-08-02 11:53:56+00:00
Task type: Bug Fix

Issue:
Slotted {@html} content duplicated on each re-render
**Describe the bug**

1. If a component has a `<slot/>`
2. ..and in that slot we render something using `{@html}`
3. ...and then we render these components in `{#each}` and somehow cause re-renders, like changing sort order
→ the contents of `{@html}` gets duplicated each time the `{#each}` loop is re-rendered

**To Reproduce**

https://svelte.dev/repl/322a9644386f43d9929f914659174d48?version=3.6.8

**Expected behavior**
I'd expect elements not to duplicate in DOM on their own.

**Severity**

💯 



Issue discussion:
Simpler repro: https://svelte.dev/repl/71495c9cb4ba4f8d9e70059c61f4dbc7?version=3.6.8
I just noticed this has some similarity to a (unfixed) bug I reported in Svelte v2: https://github.com/sveltejs/svelte/issues/1712

Solution patch:
diff --git a/src/compiler/compile/render_dom/wrappers/RawMustacheTag.ts b/src/compiler/compile/render_dom/wrappers/RawMustacheTag.ts
--- a/src/compiler/compile/render_dom/wrappers/RawMustacheTag.ts
+++ b/src/compiler/compile/render_dom/wrappers/RawMustacheTag.ts
@@ -2,7 +2,6 @@ import Renderer from '../Renderer';
 import Block from '../Block';
 import Tag from './shared/Tag';
 import Wrapper from './shared/Wrapper';
-import deindent from '../../utils/deindent';
 import MustacheTag from '../../nodes/MustacheTag';
 import RawMustacheTag from '../../nodes/RawMustacheTag';
 
@@ -19,95 +18,47 @@ export default class RawMustacheTagWrapper extends Tag {
 		this.cannot_use_innerhtml();
 	}
 
-	render(block: Block, parent_node: string, parent_nodes: string) {
-		const name = this.var;
-
+	render(block: Block, parent_node: string, _parent_nodes: string) {
 		const in_head = parent_node === '@_document.head';
-		const needs_anchors = !parent_node || in_head;
-
-		// if in head always needs anchors
-		if (in_head) {
-			this.prev = null;
-			this.next = null;
-		}
 
-		// TODO use is_dom_node instead of type === 'Element'?
-		const needs_anchor_before = this.prev ? this.prev.node.type !== 'Element' : needs_anchors;
-		const needs_anchor_after = this.next ? this.next.node.type !== 'Element' : needs_anchors;
+		const can_use_innerhtml = !in_head && parent_node && !this.prev && !this.next;
 
-		const anchor_before = needs_anchor_before
-			? block.get_unique_name(`${name}_before`)
-			: (this.prev && this.prev.var) || 'null';
+		if (can_use_innerhtml) {
+			const insert = content => `${parent_node}.innerHTML = ${content};`;
 
-		const anchor_after = needs_anchor_after
-			? block.get_unique_name(`${name}_after`)
-			: (this.next && this.next.var) || 'null';
-
-		let detach: string;
-		let insert: (content: string) => string;
-		let use_innerhtml = false;
+			const { init } = this.rename_this_method(
+				block,
+				content => insert(content)
+			);
 
-		if (anchor_before === 'null' && anchor_after === 'null') {
-			use_innerhtml = true;
-			detach = `${parent_node}.innerHTML = '';`;
-			insert = content => `${parent_node}.innerHTML = ${content};`;
-		} else if (anchor_before === 'null') {
-			detach = `@detach_before(${anchor_after});`;
-			insert = content => `${anchor_after}.insertAdjacentHTML("beforebegin", ${content});`;
-		} else if (anchor_after === 'null') {
-			detach = `@detach_after(${anchor_before});`;
-			insert = content => `${anchor_before}.insertAdjacentHTML("afterend", ${content});`;
-		} else {
-			detach = `@detach_between(${anchor_before}, ${anchor_after});`;
-			insert = content => `${anchor_before}.insertAdjacentHTML("afterend", ${content});`;
+			block.builders.mount.add_line(insert(init));
 		}
 
-		const { init } = this.rename_this_method(
-			block,
-			content => deindent`
-				${!use_innerhtml && detach}
-				${insert(content)}
-			`
-		);
+		else {
+			const needs_anchor = in_head || (this.next && !this.next.is_dom_node());
 
-		// we would have used comments here, but the `insertAdjacentHTML` api only
-		// exists for `Element`s.
-		if (needs_anchor_before) {
-			block.add_element(
-				anchor_before,
-				`@element('noscript')`,
-				parent_nodes && `@element('noscript')`,
-				parent_node,
-				true
-			);
-		}
+			const html_tag = block.get_unique_name('html_tag');
+			const html_anchor = needs_anchor && block.get_unique_name('html_anchor');
+
+			block.add_variable(html_tag);
 
-		function add_anchor_after() {
-			block.add_element(
-				anchor_after,
-				`@element('noscript')`,
-				parent_nodes && `@element('noscript')`,
-				parent_node
+			const { init } = this.rename_this_method(
+				block,
+				content => `${html_tag}.p(${content});`
 			);
-		}
 
-		if (needs_anchor_after && anchor_before === 'null') {
-			// anchor_after needs to be in the DOM before we
-			// insert the HTML...
-			add_anchor_after();
-		}
+			const anchor = in_head ? 'null' : needs_anchor ? html_anchor : this.next ? this.next.var : 'null';
 
-		block.builders.mount.add_line(insert(init));
+			block.builders.hydrate.add_line(`${html_tag} = new @HtmlTag(${init}, ${anchor});`);
+			block.builders.mount.add_line(`${html_tag}.m(${parent_node || '#target'}, anchor);`);
 
-		if (needs_anchors) {
-			block.builders.destroy.add_conditional('detaching', needs_anchor_before
-				? `${detach}\n@detach(${anchor_before});`
-				: detach);
-		}
+			if (needs_anchor) {
+				block.add_element(html_anchor, '@empty()', '@empty()', parent_node);
+			}
 
-		if (needs_anchor_after && anchor_before !== 'null') {
-			// ...otherwise it should go afterwards
-			add_anchor_after();
+			if (!parent_node || in_head) {
+				block.builders.destroy.add_conditional('detaching', `${html_tag}.d();`);
+			}
 		}
 	}
 }
diff --git a/src/compiler/compile/render_dom/wrappers/shared/Tag.ts b/src/compiler/compile/render_dom/wrappers/shared/Tag.ts
--- a/src/compiler/compile/render_dom/wrappers/shared/Tag.ts
+++ b/src/compiler/compile/render_dom/wrappers/shared/Tag.ts
@@ -34,7 +34,7 @@ export default class Tag extends Wrapper {
 
 			const update_cached_value = `${value} !== (${value} = ${snippet})`;
 
-			const condition =this.node.should_cache
+			const condition = this.node.should_cache
 				? `(${changed_check}) && ${update_cached_value}`
 				: changed_check;
 
diff --git a/src/runtime/internal/dom.ts b/src/runtime/internal/dom.ts
--- a/src/runtime/internal/dom.ts
+++ b/src/runtime/internal/dom.ts
@@ -10,24 +10,6 @@ export function detach(node: Node) {
 	node.parentNode.removeChild(node);
 }
 
-export function detach_between(before: Node, after: Node) {
-	while (before.nextSibling && before.nextSibling !== after) {
-		before.parentNode.removeChild(before.nextSibling);
-	}
-}
-
-export function detach_before(after: Node) {
-	while (after.previousSibling) {
-		after.parentNode.removeChild(after.previousSibling);
-	}
-}
-
-export function detach_after(before: Node) {
-	while (before.nextSibling) {
-		before.parentNode.removeChild(before.nextSibling);
-	}
-}
-
 export function destroy_each(iterations, detaching) {
 	for (let i = 0; i < iterations.length; i += 1) {
 		if (iterations[i]) iterations[i].d(detaching);
@@ -257,3 +239,39 @@ export function custom_event<T=any>(type: string, detail?: T) {
 	e.initCustomEvent(type, false, false, detail);
 	return e;
 }
+
+export class HtmlTag {
+	e: HTMLElement;
+	n: ChildNode[];
+	t: HTMLElement;
+	a: HTMLElement;
+
+	constructor(html: string, anchor: HTMLElement = null) {
+		this.e = element('div');
+		this.a = anchor;
+		this.u(html);
+	}
+
+	m(target: HTMLElement, anchor: HTMLElement) {
+		for (let i = 0; i < this.n.length; i += 1) {
+			insert(target, this.n[i], anchor);
+		}
+
+		this.t = target;
+	}
+
+	u(html: string) {
+		this.e.innerHTML = html;
+		this.n = Array.from(this.e.childNodes);
+	}
+
+	p(html: string) {
+		this.d();
+		this.u(html);
+		this.m(this.t, this.a);
+	}
+
+	d() {
+		this.n.forEach(detach);
+	}
+}
\ No newline at end of file


Test patch:
diff --git a/test/js/samples/each-block-changed-check/expected.js b/test/js/samples/each-block-changed-check/expected.js
--- a/test/js/samples/each-block-changed-check/expected.js
+++ b/test/js/samples/each-block-changed-check/expected.js
@@ -1,11 +1,11 @@
 /* generated by Svelte vX.Y.Z */
 import {
+	HtmlTag,
 	SvelteComponent,
 	append,
 	attr,
 	destroy_each,
 	detach,
-	detach_after,
 	element,
 	init,
 	insert,
@@ -25,7 +25,7 @@ function get_each_context(ctx, list, i) {
 
 // (8:0) {#each comments as comment, i}
 function create_each_block(ctx) {
-	var div, strong, t0, t1, span, t2_value = ctx.comment.author, t2, t3, t4_value = ctx.elapsed(ctx.comment.time, ctx.time), t4, t5, t6, raw_value = ctx.comment.html, raw_before;
+	var div, strong, t0, t1, span, t2_value = ctx.comment.author, t2, t3, t4_value = ctx.elapsed(ctx.comment.time, ctx.time), t4, t5, t6, html_tag, raw_value = ctx.comment.html;
 
 	return {
 		c() {
@@ -39,8 +39,8 @@ function create_each_block(ctx) {
 			t4 = text(t4_value);
 			t5 = text(" ago:");
 			t6 = space();
-			raw_before = element('noscript');
 			attr(span, "class", "meta");
+			html_tag = new HtmlTag(raw_value, null);
 			attr(div, "class", "comment");
 		},
 
@@ -55,8 +55,7 @@ function create_each_block(ctx) {
 			append(span, t4);
 			append(span, t5);
 			append(div, t6);
-			append(div, raw_before);
-			raw_before.insertAdjacentHTML("afterend", raw_value);
+			html_tag.m(div, anchor);
 		},
 
 		p(changed, ctx) {
@@ -69,8 +68,7 @@ function create_each_block(ctx) {
 			}
 
 			if ((changed.comments) && raw_value !== (raw_value = ctx.comment.html)) {
-				detach_after(raw_before);
-				raw_before.insertAdjacentHTML("afterend", raw_value);
+				html_tag.p(raw_value);
 			}
 		},
 
diff --git a/test/runtime/samples/each-block-keyed-html/_config.js b/test/runtime/samples/each-block-keyed-html/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/each-block-keyed-html/_config.js
@@ -0,0 +1,10 @@
+export default {
+	html: `
+		JohnJill
+	`,
+
+	test({ assert, component, target }) {
+		component.names = component.names.reverse();
+		assert.htmlEqual(target.innerHTML, `JillJohn`);
+	}
+};
diff --git a/test/runtime/samples/each-block-keyed-html/main.svelte b/test/runtime/samples/each-block-keyed-html/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/each-block-keyed-html/main.svelte
@@ -0,0 +1,8 @@
+<script>
+	export let names = ['John', 'Jill'];
+</script>
+
+{#each names as name (name)}
+	{@html name}
+{/each}
+
diff --git a/test/runtime/samples/raw-mustache-before-element/_config.js b/test/runtime/samples/raw-mustache-before-element/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/raw-mustache-before-element/_config.js
@@ -0,0 +1,3 @@
+export default {
+	html: `<p>x<span>baz</span></p>`
+};
diff --git a/test/runtime/samples/raw-mustache-before-element/main.svelte b/test/runtime/samples/raw-mustache-before-element/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/raw-mustache-before-element/main.svelte
@@ -0,0 +1 @@
+<p>{@html 'x'}<span>baz</span></p>
\ No newline at end of file
diff --git a/test/runtime/samples/raw-mustaches/_config.js b/test/runtime/samples/raw-mustaches/_config.js
--- a/test/runtime/samples/raw-mustaches/_config.js
+++ b/test/runtime/samples/raw-mustaches/_config.js
@@ -1,5 +1,3 @@
-const ns = '<noscript></noscript>';
-
 export default {
 	skip_if_ssr: true,
 
@@ -7,13 +5,13 @@ export default {
 		raw: '<span><em>raw html!!!\\o/</span></em>'
 	},
 
-	html: `before${ns}<span><em>raw html!!!\\o/</span></em>${ns}after`,
+	html: `before<span><em>raw html!!!\\o/</span></em>after`,
 
 	test({ assert, component, target }) {
 		component.raw = '';
-		assert.equal(target.innerHTML, `before${ns}${ns}after`);
+		assert.equal(target.innerHTML, `beforeafter`);
 		component.raw = 'how about <strong>unclosed elements?';
-		assert.equal(target.innerHTML, `before${ns}how about <strong>unclosed elements?</strong>${ns}after`);
+		assert.equal(target.innerHTML, `beforehow about <strong>unclosed elements?</strong>after`);
 		component.$destroy();
 		assert.equal(target.innerHTML, '');
 	}