Repository: sveltejs/svelte (JavaScript)
Date: 2021-12-06 03:01:36+00:00
Task type: Bug Fix

Issue:
preserveWhitespace strips prefix space
**Describe the bug**
The compiler option `preserveWhitespace: true` strips prefix space:
```html
<h1> Hello {name}!</h1>
```

**To Reproduce**
See [this repo](https://github.com/bwbroersma/preserve-whitespace-in-ssr/). With [minimal changes](https://github.com/bwbroersma/preserve-whitespace-in-ssr/commit/1b2cafc59612880a6574339daf25a6dda551657b) from the svelte/template. This is [my output with missing prefix space](https://github.com/bwbroersma/preserve-whitespace-in-ssr/blob/master/public/build/bundle.js#L73).

**Expected behavior**
Keep the prefix whitespace in the SSR.

**Information about your Svelte project:**
svelte-3.21.0 & rollup.




Issue discussion:
I'm trying to make near-byte-perfect SSR output because we might be involved in the Creative Commons static site generator of the Licenses (the [deeds](https://creativecommons.org/licenses/by/4.0/deed) and [legalcode](https://creativecommons.org/licenses/by/4.0/legalcode)). Because this is quite a project (with many languages), I wanted to verify the Svelte (Sapper) output with the current situation (both compared to the code live and in the [repo](https://github.com/creativecommons/creativecommons.org/blob/master/docroot/legalcode/by_4.0.html)).

Because both comments and whitespace is not working like expected: whitespace is stripped in this case:
```html
<div>
    <div>
        <p> Some text </p>
    </div>
</div>
```
to
```html
<div><div><p>Some text </p></div>
```
however, there is a 'trick':
```html
<div><!---->
    <div><!---->
        <p><!----> Some text </p>
    </div><!---->
</div>
```
this will the desired output:
```html
<div>
    <div>
        <p> Some text </p>
    </div>
</div>
```
and it is [what I'm currently doing](https://github.com/openstate/cc-license-infra/blob/55d976f97d37a4c1a91ea2d77c4d0fad4525f6ed/src/components/Header.svelte#L5) in a PoC to get the compare working.

The main function involved in stripping whitespace is [`remove_whitespace_children`](https://github.com/sveltejs/svelte/blob/e3fef0f740fa788bba68a29f5561ad439475edbb/src/compiler/compile/render_ssr/handlers/utils/remove_whitespace_children.ts#L8) not checking `preserveWhitespace`.

This ssr [`trim` function](https://github.com/sveltejs/svelte/blob/e3fef0f740fa788bba68a29f5561ad439475edbb/src/compiler/compile/render_ssr/index.ts#L172) is involved in stripping leading & trailing whitespace in a component, without checking `preserveWhitespace`.

I also had issues with tags like `select` and `option` which are in `elements_without_text` meaning all spaces are [skipped](https://github.com/sveltejs/svelte/blob/e3fef0f740fa788bba68a29f5561ad439475edbb/src/compiler/compile/nodes/Text.ts#L43), also without checking `preserveWhitespace`.

I think the option `preserveWhitespace` should preserve all whitespace: newlines, prefix spaces, spaces in `select` tags, etc.
I would love to hear some feedback how people are thinking about this. 

Solution patch:
diff --git a/src/compiler/compile/nodes/Text.ts b/src/compiler/compile/nodes/Text.ts
--- a/src/compiler/compile/nodes/Text.ts
+++ b/src/compiler/compile/nodes/Text.ts
@@ -43,4 +43,21 @@ export default class Text extends Node {
 
 		return parent_element.namespace || elements_without_text.has(parent_element.name);
 	}
+
+	keep_space(): boolean {
+		if (this.component.component_options.preserveWhitespace) return true;
+		return this.within_pre();
+	}
+
+	within_pre(): boolean {
+		let node = this.parent;
+		while (node) {
+			if (node.type === 'Element' && node.name === 'pre') {
+				return true;
+			}
+			node = node.parent;
+		}
+
+		return false;
+	}
 }
diff --git a/src/compiler/compile/render_dom/wrappers/Fragment.ts b/src/compiler/compile/render_dom/wrappers/Fragment.ts
--- a/src/compiler/compile/render_dom/wrappers/Fragment.ts
+++ b/src/compiler/compile/render_dom/wrappers/Fragment.ts
@@ -95,7 +95,7 @@ export default class FragmentWrapper {
 						next_sibling ? (next_sibling.node.type === 'Text' && /^\s/.test(next_sibling.node.data) && trimmable_at(child, next_sibling)) : !child.has_ancestor('EachBlock')
 					);
 
-					if (should_trim) {
+					if (should_trim && !child.keep_space()) {
 						data = trim_end(data);
 						if (!data) continue;
 					}
@@ -127,7 +127,7 @@ export default class FragmentWrapper {
 		if (strip_whitespace) {
 			const first = this.nodes[0] as Text;
 
-			if (first && first.node.type === 'Text') {
+			if (first && first.node.type === 'Text' && !first.node.keep_space()) {
 				first.data = trim_start(first.data);
 				if (!first.data) {
 					first.var = null;
diff --git a/src/compiler/compile/render_dom/wrappers/Text.ts b/src/compiler/compile/render_dom/wrappers/Text.ts
--- a/src/compiler/compile/render_dom/wrappers/Text.ts
+++ b/src/compiler/compile/render_dom/wrappers/Text.ts
@@ -29,15 +29,7 @@ export default class TextWrapper extends Wrapper {
 		if (this.renderer.component.component_options.preserveWhitespace) return false;
 		if (/[\S\u00A0]/.test(this.data)) return false;
 
-		let node = this.parent && this.parent.node;
-		while (node) {
-			if (node.type === 'Element' && node.name === 'pre') {
-				return false;
-			}
-			node = node.parent;
-		}
-
-		return true;
+		return !this.node.within_pre();
 	}
 
 	render(block: Block, parent_node: Identifier, parent_nodes: Identifier) {
diff --git a/src/compiler/compile/render_ssr/handlers/utils/remove_whitespace_children.ts b/src/compiler/compile/render_ssr/handlers/utils/remove_whitespace_children.ts
--- a/src/compiler/compile/render_ssr/handlers/utils/remove_whitespace_children.ts
+++ b/src/compiler/compile/render_ssr/handlers/utils/remove_whitespace_children.ts
@@ -26,7 +26,7 @@ export default function remove_whitespace_children(children: INode[], next?: INo
 					  trimmable_at(child, next)
 					: !child.has_ancestor('EachBlock');
 
-				if (should_trim) {
+				if (should_trim && !child.keep_space()) {
 					data = trim_end(data);
 					if (!data) continue;
 				}
@@ -47,7 +47,7 @@ export default function remove_whitespace_children(children: INode[], next?: INo
 	}
 
 	const first = nodes[0];
-	if (first && first.type === 'Text') {
+	if (first && first.type === 'Text' && !first.keep_space()) {
 		first.data = trim_start(first.data);
 		if (!first.data) {
 			first.var = null;


Test patch:
diff --git a/test/runtime/samples/pre-tag/_config.js b/test/runtime/samples/pre-tag/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/pre-tag/_config.js
@@ -0,0 +1,57 @@
+export default {
+	test({ assert, target }) {
+		// Test for <pre> tag
+		const elementPre = target.querySelector('#pre');
+		// Test for non <pre> tag
+		const elementDiv = target.querySelector('#div');
+		// Test for <pre> tag in non <pre> tag
+		const elementDivWithPre = target.querySelector('#div-with-pre');
+
+		// There is a slight difference in innerHTML because there is a difference in HTML optimization (in jsdom)
+		// depending on how the innerHTML is set.
+		// (There is no difference in the display.)
+		// Reassign innerHTML to add the same optimizations to innerHTML.
+
+		// eslint-disable-next-line no-self-assign
+		elementPre.innerHTML = elementPre.innerHTML;
+		// eslint-disable-next-line no-self-assign
+		elementDiv.innerHTML = elementDiv.innerHTML;
+		// eslint-disable-next-line no-self-assign
+		elementDivWithPre.innerHTML = elementDivWithPre.innerHTML;
+
+		assert.equal(
+			elementPre.innerHTML,
+			`
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+`
+		);
+		assert.equal(
+			elementDiv.innerHTML,
+			`A
+  B
+  <span>C
+    D</span>
+  E
+  F`
+		);
+		assert.equal(
+			elementDivWithPre.innerHTML,
+			`<pre>    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>`
+		);
+	}
+};
diff --git a/test/runtime/samples/pre-tag/main.svelte b/test/runtime/samples/pre-tag/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/pre-tag/main.svelte
@@ -0,0 +1,34 @@
+<pre id="pre">
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div id="div">
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div id="div-with-pre">
+  <pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+</div>
diff --git a/test/runtime/samples/preserve-whitespaces/_config.js b/test/runtime/samples/preserve-whitespaces/_config.js
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/preserve-whitespaces/_config.js
@@ -0,0 +1,66 @@
+export default {
+	compileOptions: {
+		preserveWhitespace: true
+	},
+	test({ assert, target }) {
+		// Test for <pre> tag
+		const elementPre = target.querySelector('#pre');
+		// Test for non <pre> tag
+		const elementDiv = target.querySelector('#div');
+		// Test for <pre> tag in non <pre> tag
+		const elementDivWithPre = target.querySelector('#div-with-pre');
+
+		// There is a slight difference in innerHTML because there is a difference in HTML optimization (in jsdom)
+		// depending on how the innerHTML is set.
+		// (There is no difference in the display.)
+		// Reassign innerHTML to add the same optimizations to innerHTML.
+
+		// eslint-disable-next-line no-self-assign
+		elementPre.innerHTML = elementPre.innerHTML;
+		// eslint-disable-next-line no-self-assign
+		elementDiv.innerHTML = elementDiv.innerHTML;
+		// eslint-disable-next-line no-self-assign
+		elementDivWithPre.innerHTML = elementDivWithPre.innerHTML;
+
+		assert.equal(
+			elementPre.innerHTML,
+			`
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+`
+		);
+		assert.equal(
+			elementDiv.innerHTML,
+			`
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+`
+		);
+		assert.equal(
+			elementDivWithPre.innerHTML,
+			`
+  <pre>    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+`
+		);
+	}
+};
diff --git a/test/runtime/samples/preserve-whitespaces/main.svelte b/test/runtime/samples/preserve-whitespaces/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/runtime/samples/preserve-whitespaces/main.svelte
@@ -0,0 +1,34 @@
+<pre id="pre">
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div id="div">
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div id="div-with-pre">
+  <pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+</div>
diff --git a/test/server-side-rendering/index.ts b/test/server-side-rendering/index.ts
--- a/test/server-side-rendering/index.ts
+++ b/test/server-side-rendering/index.ts
@@ -83,9 +83,13 @@ describe('ssr', () => {
 				if (css.code) fs.writeFileSync(`${dir}/_actual.css`, css.code);
 
 				try {
-					(compileOptions.preserveComments
-						? assert.htmlEqualWithComments
-						: assert.htmlEqual)(html, expectedHtml);
+					if (config.withoutNormalizeHtml) {
+						assert.strictEqual(html.trim(), expectedHtml.trim().replace(/\r\n/g, '\n'));
+					} else {
+						(compileOptions.preserveComments
+							? assert.htmlEqualWithComments
+							: assert.htmlEqual)(html, expectedHtml);
+					}
 				} catch (error) {
 					if (shouldUpdateExpected()) {
 						fs.writeFileSync(`${dir}/_expected.html`, html);
diff --git a/test/server-side-rendering/samples/pre-tag/_config.js b/test/server-side-rendering/samples/pre-tag/_config.js
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/pre-tag/_config.js
@@ -0,0 +1,3 @@
+export default {
+	withoutNormalizeHtml: true
+};
diff --git a/test/server-side-rendering/samples/pre-tag/_expected.html b/test/server-side-rendering/samples/pre-tag/_expected.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/pre-tag/_expected.html
@@ -0,0 +1,30 @@
+<pre>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div>A
+  B
+  <span>C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div><pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre></div>
diff --git a/test/server-side-rendering/samples/pre-tag/main.svelte b/test/server-side-rendering/samples/pre-tag/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/pre-tag/main.svelte
@@ -0,0 +1,34 @@
+<pre>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div>
+  <pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+</div>
diff --git a/test/server-side-rendering/samples/preserve-whitespaces/_config.js b/test/server-side-rendering/samples/preserve-whitespaces/_config.js
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/preserve-whitespaces/_config.js
@@ -0,0 +1,6 @@
+export default {
+	withoutNormalizeHtml: true,
+	compileOptions: {
+		preserveWhitespace: true
+	}
+};
diff --git a/test/server-side-rendering/samples/preserve-whitespaces/_expected.html b/test/server-side-rendering/samples/preserve-whitespaces/_expected.html
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/preserve-whitespaces/_expected.html
@@ -0,0 +1,34 @@
+<pre>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div>
+  <pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+</div>
diff --git a/test/server-side-rendering/samples/preserve-whitespaces/main.svelte b/test/server-side-rendering/samples/preserve-whitespaces/main.svelte
new file mode 100644
--- /dev/null
+++ b/test/server-side-rendering/samples/preserve-whitespaces/main.svelte
@@ -0,0 +1,34 @@
+<pre>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</pre>
+
+<div>
+  A
+  B
+  <span>
+    C
+    D
+  </span>
+  E
+  F
+</div>
+
+<div>
+  <pre>
+    A
+    B
+    <span>
+      C
+      D
+    </span>
+    E
+    F
+  </pre>
+</div>