Skip to content

Commit 3a238fe

Browse files
authored
[fix] strip leading newline after <pre> and <textarea> (#7280)
Fixes #7264
1 parent 9778eef commit 3a238fe

File tree

22 files changed

+313
-56
lines changed

22 files changed

+313
-56
lines changed

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* Fix `{@const}` tag not working inside Component when there's no `let:` [#7189](https://github.com/sveltejs/svelte/issues/7189)
77
* Ignore comments in `{#each}` blocks when containing elements with `animate:` ([#3999](https://github.com/sveltejs/svelte/issues/3999))
88
* Add a third parameter to the returned function of `createEventDispatcher` that allows passing an object of `{ cancelable: true }` to create a cancelable custom event. The returned function when called will also return a boolean depending on whether the event is cancelled ([#7064](https://github.com/sveltejs/svelte/pull/7064))
9-
* Fix value of `let:` bindings not updating in certain cases ([#7440](https://github.com/sveltejs/svelte/issues/7440))
9+
* Fix value of `let:` bindings not updating in certain cases ([#7440](https://github.com/sveltejs/svelte/issues/7440))
10+
* Strip leading newline after `<pre>` and `<textarea>` ([#7264](https://github.com/sveltejs/svelte/issues/7264))
1011

1112
## 3.47.0
1213

src/compiler/compile/nodes/Element.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import StyleDirective from './StyleDirective';
1111
import Text from './Text';
1212
import { namespaces } from '../../utils/namespaces';
1313
import map_children from './shared/map_children';
14-
import { dimensions } from '../../utils/patterns';
14+
import { dimensions, start_newline } from '../../utils/patterns';
1515
import fuzzymatch from '../../utils/fuzzymatch';
1616
import list from '../../utils/list';
1717
import Let from './Let';
@@ -216,6 +216,20 @@ export default class Element extends Node {
216216
this.namespace = get_namespace(parent as Element, this, component.namespace);
217217

218218
if (this.namespace !== namespaces.foreign) {
219+
if (this.name === 'pre' || this.name === 'textarea') {
220+
const first = info.children[0];
221+
if (first && first.type === 'Text') {
222+
// The leading newline character needs to be stripped because of a qirk,
223+
// it is ignored by browsers if the tag and its contents are set through
224+
// innerHTML (NOT if set through the innerHTML of the tag or dynamically).
225+
// Therefore strip it here but add it back in the appropriate
226+
// places if there's another newline afterwards.
227+
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
228+
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
229+
first.data = first.data.replace(start_newline, '');
230+
}
231+
}
232+
219233
if (this.name === 'textarea') {
220234
if (info.children.length > 0) {
221235
const value_attribute = info.attributes.find(node => node.name === 'value');

src/compiler/compile/render_dom/wrappers/Element/index.ts

Lines changed: 50 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { namespaces } from '../../../../utils/namespaces';
1212
import AttributeWrapper from './Attribute';
1313
import StyleAttributeWrapper from './StyleAttribute';
1414
import SpreadAttributeWrapper from './SpreadAttribute';
15-
import { dimensions } from '../../../../utils/patterns';
15+
import { dimensions, start_newline } from '../../../../utils/patterns';
1616
import Binding from './Binding';
1717
import add_to_set from '../../../utils/add_to_set';
1818
import { add_event_handler } from '../shared/add_event_handlers';
@@ -1114,6 +1114,9 @@ export default class ElementWrapper extends Wrapper {
11141114
function to_html(wrappers: Array<ElementWrapper | TextWrapper | MustacheTagWrapper | RawMustacheTagWrapper>, block: Block, literal: any, state: any, can_use_raw_text?: boolean) {
11151115
wrappers.forEach(wrapper => {
11161116
if (wrapper instanceof TextWrapper) {
1117+
// Don't add the <pre>/<textare> newline logic here because pre/textarea.innerHTML
1118+
// would keep the leading newline, too, only someParent.innerHTML = '..<pre/textarea>..' won't
1119+
11171120
if ((wrapper as TextWrapper).use_space()) state.quasi.value.raw += ' ';
11181121

11191122
const parent = wrapper.node.parent as Element;
@@ -1141,29 +1144,46 @@ function to_html(wrappers: Array<ElementWrapper | TextWrapper | MustacheTagWrapp
11411144
// element
11421145
state.quasi.value.raw += `<${wrapper.node.name}`;
11431146

1147+
const is_empty_textarea = wrapper.node.name === 'textarea' && wrapper.fragment.nodes.length === 0;
1148+
11441149
(wrapper as ElementWrapper).attributes.forEach((attr: AttributeWrapper) => {
1150+
if (is_empty_textarea && attr.node.name === 'value') {
1151+
// The value attribute of <textarea> renders as content.
1152+
return;
1153+
}
11451154
state.quasi.value.raw += ` ${fix_attribute_casing(attr.node.name)}="`;
11461155

1147-
attr.node.chunks.forEach(chunk => {
1148-
if (chunk.type === 'Text') {
1149-
state.quasi.value.raw += escape_html(chunk.data);
1150-
} else {
1151-
literal.quasis.push(state.quasi);
1152-
literal.expressions.push(chunk.manipulate(block));
1153-
1154-
state.quasi = {
1155-
type: 'TemplateElement',
1156-
value: { raw: '' }
1157-
};
1158-
}
1159-
});
1156+
to_html_for_attr_value(attr, block, literal, state);
11601157

11611158
state.quasi.value.raw += '"';
11621159
});
11631160

11641161
if (!wrapper.void) {
11651162
state.quasi.value.raw += '>';
11661163

1164+
if (wrapper.node.name === 'pre') {
1165+
// Two or more leading newlines are required to restore the leading newline immediately after `<pre>`.
1166+
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
1167+
const first = wrapper.fragment.nodes[0];
1168+
if (first && first.node.type === 'Text' && start_newline.test(first.node.data)) {
1169+
state.quasi.value.raw += '\n';
1170+
}
1171+
}
1172+
1173+
if (is_empty_textarea) {
1174+
// The <textarea> renders the value attribute as content because the content is stored in the value attribute.
1175+
const value_attribute = wrapper.attributes.find(attr => attr.node.name === 'value');
1176+
if (value_attribute) {
1177+
// Two or more leading newlines are required to restore the leading newline immediately after `<textarea>`.
1178+
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
1179+
const first = value_attribute.node.chunks[0];
1180+
if (first && first.type === 'Text' && start_newline.test(first.data)) {
1181+
state.quasi.value.raw += '\n';
1182+
}
1183+
to_html_for_attr_value(value_attribute, block, literal, state);
1184+
}
1185+
}
1186+
11671187
to_html(wrapper.fragment.nodes as Array<ElementWrapper | TextWrapper>, block, literal, state);
11681188

11691189
state.quasi.value.raw += `</${wrapper.node.name}>`;
@@ -1173,3 +1193,19 @@ function to_html(wrappers: Array<ElementWrapper | TextWrapper | MustacheTagWrapp
11731193
}
11741194
});
11751195
}
1196+
1197+
function to_html_for_attr_value(attr: AttributeWrapper | StyleAttributeWrapper | SpreadAttributeWrapper, block: Block, literal: any, state: any) {
1198+
attr.node.chunks.forEach(chunk => {
1199+
if (chunk.type === 'Text') {
1200+
state.quasi.value.raw += escape_html(chunk.data);
1201+
} else {
1202+
literal.quasis.push(state.quasi);
1203+
literal.expressions.push(chunk.manipulate(block));
1204+
1205+
state.quasi = {
1206+
type: 'TemplateElement',
1207+
value: { raw: '' }
1208+
};
1209+
}
1210+
});
1211+
}

src/compiler/compile/render_ssr/handlers/Element.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Expression from '../../nodes/shared/Expression';
88
import remove_whitespace_children from './utils/remove_whitespace_children';
99
import fix_attribute_casing from '../../render_dom/wrappers/Element/fix_attribute_casing';
1010
import { namespaces } from '../../../utils/namespaces';
11+
import { start_newline } from '../../../utils/patterns';
1112
import { Expression as ESExpression } from 'estree';
1213

1314
export default function (node: Element, renderer: Renderer, options: RenderOptions) {
@@ -42,7 +43,7 @@ export default function (node: Element, renderer: Renderer, options: RenderOptio
4243
const { name, expression: { node: expression } } = style_directive;
4344
return p`"${name}": ${expression}`;
4445
});
45-
46+
4647
const style_expression =
4748
style_expression_list.length > 0 &&
4849
x`{ ${style_expression_list} }`;
@@ -166,11 +167,31 @@ export default function (node: Element, renderer: Renderer, options: RenderOptio
166167

167168
renderer.add_expression(x`($$value => $$value === void 0 ? ${result} : $$value)(${node_contents})`);
168169
} else {
170+
if (node.name === 'textarea') {
171+
// Two or more leading newlines are required to restore the leading newline immediately after `<textarea>`.
172+
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
173+
const value_attribute = node.attributes.find(({ name }) => name === 'value');
174+
if (value_attribute) {
175+
const first = value_attribute.chunks[0];
176+
if (first && first.type === 'Text' && start_newline.test(first.data)) {
177+
renderer.add_string('\n');
178+
}
179+
}
180+
}
169181
renderer.add_expression(node_contents);
170182
}
171183

172184
add_close_tag();
173185
} else {
186+
if (node.name === 'pre') {
187+
// Two or more leading newlines are required to restore the leading newline immediately after `<pre>`.
188+
// see https://html.spec.whatwg.org/multipage/grouping-content.html#the-pre-element
189+
// see https://html.spec.whatwg.org/multipage/syntax.html#element-restrictions
190+
const first = children[0];
191+
if (first && first.type === 'Text' && start_newline.test(first.data)) {
192+
renderer.add_string('\n');
193+
}
194+
}
174195
renderer.render(children, options);
175196
add_close_tag();
176197
}

src/compiler/utils/patterns.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const whitespace = /[ \t\r\n]/;
22
export const start_whitespace = /^[ \t\r\n]*/;
33
export const end_whitespace = /[ \t\r\n]*$/;
4+
export const start_newline = /^\r?\n/;
45

56
export const dimensions = /^(?:offset|client)(?:Width|Height)$/;
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[main.svelte]
2+
trim_trailing_whitespace = unset

test/runtime/samples/pre-tag/_config.js

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,14 @@ export default {
66
const elementDiv = target.querySelector('#div');
77
// Test for <pre> tag in non <pre> tag
88
const elementDivWithPre = target.querySelector('#div-with-pre');
9-
10-
// There is a slight difference in innerHTML because there is a difference in HTML optimization (in jsdom)
11-
// depending on how the innerHTML is set.
12-
// (There is no difference in the display.)
13-
// Reassign innerHTML to add the same optimizations to innerHTML.
14-
15-
// eslint-disable-next-line no-self-assign
16-
elementPre.innerHTML = elementPre.innerHTML;
17-
// eslint-disable-next-line no-self-assign
18-
elementDiv.innerHTML = elementDiv.innerHTML;
19-
// eslint-disable-next-line no-self-assign
20-
elementDivWithPre.innerHTML = elementDivWithPre.innerHTML;
9+
// Test for <pre> tag with leading newline
10+
const elementPreWithLeadingNewline = target.querySelector('#pre-with-leading-newline');
11+
const elementPreWithoutLeadingNewline = target.querySelector('#pre-without-leading-newline');
12+
const elementPreWithMultipleLeadingNewline = target.querySelector('#pre-with-multiple-leading-newlines');
2113

2214
assert.equal(
2315
elementPre.innerHTML,
24-
`
25-
A
16+
` A
2617
B
2718
<span>
2819
C
@@ -53,5 +44,12 @@ export default {
5344
F
5445
</pre>`
5546
);
47+
assert.equal(elementPreWithLeadingNewline.children[0].innerHTML, 'leading newline');
48+
assert.equal(elementPreWithLeadingNewline.children[1].innerHTML, ' leading newline and spaces');
49+
assert.equal(elementPreWithLeadingNewline.children[2].innerHTML, '\nleading newlines');
50+
assert.equal(elementPreWithoutLeadingNewline.children[0].innerHTML, 'without spaces');
51+
assert.equal(elementPreWithoutLeadingNewline.children[1].innerHTML, ' with spaces ');
52+
assert.equal(elementPreWithoutLeadingNewline.children[2].innerHTML, ' \nnewline after leading space');
53+
assert.equal(elementPreWithMultipleLeadingNewline.innerHTML, '\n\nmultiple leading newlines');
5654
}
5755
};

test/runtime/samples/pre-tag/main.svelte

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,25 @@
3232
F
3333
</pre>
3434
</div>
35+
36+
<div id="pre-with-leading-newline">
37+
<pre>
38+
leading newline</pre>
39+
<pre>
40+
leading newline and spaces</pre>
41+
<pre>
42+
43+
leading newlines</pre>
44+
</div>
45+
46+
<div id="pre-without-leading-newline">
47+
<pre>without spaces</pre>
48+
<pre> with spaces </pre>
49+
<pre>
50+
newline after leading space</pre>
51+
</div>
52+
53+
<pre id="pre-with-multiple-leading-newlines">
54+
55+
56+
multiple leading newlines</pre>

test/runtime/samples/preserve-whitespaces/_config.js

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,22 +10,9 @@ export default {
1010
// Test for <pre> tag in non <pre> tag
1111
const elementDivWithPre = target.querySelector('#div-with-pre');
1212

13-
// There is a slight difference in innerHTML because there is a difference in HTML optimization (in jsdom)
14-
// depending on how the innerHTML is set.
15-
// (There is no difference in the display.)
16-
// Reassign innerHTML to add the same optimizations to innerHTML.
17-
18-
// eslint-disable-next-line no-self-assign
19-
elementPre.innerHTML = elementPre.innerHTML;
20-
// eslint-disable-next-line no-self-assign
21-
elementDiv.innerHTML = elementDiv.innerHTML;
22-
// eslint-disable-next-line no-self-assign
23-
elementDivWithPre.innerHTML = elementDivWithPre.innerHTML;
24-
2513
assert.equal(
2614
elementPre.innerHTML,
27-
`
28-
A
15+
` A
2916
B
3017
<span>
3118
C

test/runtime/samples/textarea-children/_config.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,9 @@ export default {
99

1010
test({ assert, component, target }) {
1111
const textarea = target.querySelector( 'textarea' );
12-
assert.strictEqual( textarea.value, '\n\t<p>not actually an element. 42</p>\n' );
12+
assert.strictEqual( textarea.value, '\t<p>not actually an element. 42</p>\n' );
1313

1414
component.foo = 43;
15-
assert.strictEqual( textarea.value, '\n\t<p>not actually an element. 43</p>\n' );
15+
assert.strictEqual( textarea.value, '\t<p>not actually an element. 43</p>\n' );
1616
}
1717
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[main.svelte]
2+
trim_trailing_whitespace = unset
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
export default {
2+
test({ assert, target }) {
3+
// Test for <textarea> tag
4+
const elementTextarea = target.querySelector('#textarea');
5+
// Test for <textarea> tag in non <textarea> tag
6+
const elementDivWithTextarea = target.querySelector('#div-with-textarea');
7+
// Test for <textarea> tag with leading newline
8+
const elementTextareaWithLeadingNewline = target.querySelector('#textarea-with-leading-newline');
9+
const elementTextareaWithoutLeadingNewline = target.querySelector('#textarea-without-leading-newline');
10+
const elementTextareaWithMultipleLeadingNewline = target.querySelector('#textarea-with-multiple-leading-newlines');
11+
const elementDivWithTextareaWithMultipleLeadingNewline = target.querySelector('#div-with-textarea-with-multiple-leading-newlines');
12+
13+
assert.equal(
14+
elementTextarea.value,
15+
` A
16+
B
17+
`
18+
);
19+
assert.equal(
20+
elementDivWithTextarea.children[0].value,
21+
` A
22+
B
23+
`
24+
);
25+
assert.equal(elementTextareaWithLeadingNewline.children[0].value, 'leading newline');
26+
assert.equal(elementTextareaWithLeadingNewline.children[1].value, ' leading newline and spaces');
27+
assert.equal(elementTextareaWithLeadingNewline.children[2].value, '\nleading newlines');
28+
assert.equal(elementTextareaWithoutLeadingNewline.children[0].value, 'without spaces');
29+
assert.equal(elementTextareaWithoutLeadingNewline.children[1].value, ' with spaces ');
30+
assert.equal(elementTextareaWithoutLeadingNewline.children[2].value, ' \nnewline after leading space');
31+
assert.equal(elementTextareaWithMultipleLeadingNewline.value, '\n\nmultiple leading newlines');
32+
assert.equal(elementDivWithTextareaWithMultipleLeadingNewline.children[0].value, '\n\nmultiple leading newlines');
33+
}
34+
};
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<textarea id="textarea">
2+
A
3+
B
4+
</textarea>
5+
6+
<div id="div-with-textarea">
7+
<textarea>
8+
A
9+
B
10+
</textarea>
11+
</div>
12+
13+
<div id="textarea-with-leading-newline">
14+
<textarea>
15+
leading newline</textarea>
16+
<textarea>
17+
leading newline and spaces</textarea>
18+
<textarea>
19+
20+
leading newlines</textarea>
21+
</div>
22+
23+
<div id="textarea-without-leading-newline">
24+
<textarea>without spaces</textarea>
25+
<textarea> with spaces </textarea>
26+
<textarea>
27+
newline after leading space</textarea>
28+
</div>
29+
30+
<textarea id="textarea-with-multiple-leading-newlines">
31+
32+
33+
multiple leading newlines</textarea>
34+
35+
<div id="div-with-textarea-with-multiple-leading-newlines">
36+
<textarea>
37+
38+
39+
multiple leading newlines</textarea>
40+
</div>

test/server-side-rendering/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ describe('ssr', () => {
8484

8585
try {
8686
if (config.withoutNormalizeHtml) {
87-
assert.strictEqual(html.trim(), expectedHtml.trim().replace(/\r\n/g, '\n'));
87+
assert.strictEqual(html.trim().replace(/\r\n/g, '\n'), expectedHtml.trim().replace(/\r\n/g, '\n'));
8888
} else {
8989
(compileOptions.preserveComments
9090
? assert.htmlEqualWithComments
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
[{main.svelte,_expected.html}]
2+
trim_trailing_whitespace = unset

0 commit comments

Comments
 (0)