Skip to content

Commit 8933653

Browse files
Richman018MinerRich-Harris
authored
fix: merge consecutive text nodes during hydration for large text content (sveltejs#17587)
* fix: merge consecutive text nodes during hydration for large text content Fixes sveltejs#17582 Browsers automatically split text nodes exceeding 65536 characters into multiple consecutive text nodes during HTML parsing. This causes hydration mismatches when Svelte expects a single text node. The fix merges consecutive text nodes during hydration by: - Detecting when the current node is a text node - Finding all consecutive text node siblings - Merging their content into the first text node - Removing the extra text nodes This restores correct hydration behavior for large text content. * add test, fix * fix * fix * changeset --------- Co-authored-by: Miner <miner@example.com> Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent ebe583f commit 8933653

File tree

6 files changed

+93
-22
lines changed

6 files changed

+93
-22
lines changed

.changeset/deep-bears-see.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: merge consecutive large text nodes

packages/svelte/src/internal/client/dom/operations.js

Lines changed: 53 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,10 @@ export function child(node, is_text) {
122122
return text;
123123
}
124124

125+
if (is_text) {
126+
merge_text_nodes(/** @type {Text} */ (child));
127+
}
128+
125129
set_hydrate_node(child);
126130
return child;
127131
}
@@ -142,14 +146,18 @@ export function first_child(node, is_text = false) {
142146
return first;
143147
}
144148

145-
// if an {expression} is empty during SSR, there might be no
146-
// text node to hydrate — we must therefore create one
147-
if (is_text && hydrate_node?.nodeType !== TEXT_NODE) {
148-
var text = create_text();
149+
if (is_text) {
150+
// if an {expression} is empty during SSR, there might be no
151+
// text node to hydrate — we must therefore create one
152+
if (hydrate_node?.nodeType !== TEXT_NODE) {
153+
var text = create_text();
149154

150-
hydrate_node?.before(text);
151-
set_hydrate_node(text);
152-
return text;
155+
hydrate_node?.before(text);
156+
set_hydrate_node(text);
157+
return text;
158+
}
159+
160+
merge_text_nodes(/** @type {Text} */ (hydrate_node));
153161
}
154162

155163
return hydrate_node;
@@ -175,20 +183,24 @@ export function sibling(node, count = 1, is_text = false) {
175183
return next_sibling;
176184
}
177185

178-
// if a sibling {expression} is empty during SSR, there might be no
179-
// text node to hydrate — we must therefore create one
180-
if (is_text && next_sibling?.nodeType !== TEXT_NODE) {
181-
var text = create_text();
182-
// If the next sibling is `null` and we're handling text then it's because
183-
// the SSR content was empty for the text, so we need to generate a new text
184-
// node and insert it after the last sibling
185-
if (next_sibling === null) {
186-
last_sibling?.after(text);
187-
} else {
188-
next_sibling.before(text);
186+
if (is_text) {
187+
// if a sibling {expression} is empty during SSR, there might be no
188+
// text node to hydrate — we must therefore create one
189+
if (next_sibling?.nodeType !== TEXT_NODE) {
190+
var text = create_text();
191+
// If the next sibling is `null` and we're handling text then it's because
192+
// the SSR content was empty for the text, so we need to generate a new text
193+
// node and insert it after the last sibling
194+
if (next_sibling === null) {
195+
last_sibling?.after(text);
196+
} else {
197+
next_sibling.before(text);
198+
}
199+
set_hydrate_node(text);
200+
return text;
189201
}
190-
set_hydrate_node(text);
191-
return text;
202+
203+
merge_text_nodes(/** @type {Text} */ (next_sibling));
192204
}
193205

194206
set_hydrate_node(next_sibling);
@@ -258,3 +270,24 @@ export function set_attribute(element, key, value = '') {
258270
}
259271
return element.setAttribute(key, value);
260272
}
273+
274+
/**
275+
* Browsers split text nodes larger than 65536 bytes when parsing.
276+
* For hydration to succeed, we need to stitch them back together
277+
* @param {Text} text
278+
*/
279+
export function merge_text_nodes(text) {
280+
if (/** @type {string} */ (text.nodeValue).length < 65536) {
281+
return;
282+
}
283+
284+
let next = text.nextSibling;
285+
286+
while (next !== null && next.nodeType === TEXT_NODE) {
287+
next.remove();
288+
289+
/** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue);
290+
291+
next = text.nextSibling;
292+
}
293+
}

packages/svelte/src/internal/client/dom/template.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,13 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydra
44
import {
55
create_text,
66
get_first_child,
7+
get_next_sibling,
78
is_firefox,
89
create_element,
910
create_fragment,
1011
create_comment,
11-
set_attribute
12+
set_attribute,
13+
merge_text_nodes
1214
} from './operations.js';
1315
import { create_fragment_from_html } from './reconciler.js';
1416
import { active_effect } from '../runtime.js';
@@ -310,6 +312,8 @@ export function text(value = '') {
310312
// if an {expression} is empty during SSR, we need to insert an empty text node
311313
node.before((node = create_text()));
312314
set_hydrate_node(node);
315+
} else {
316+
merge_text_nodes(/** @type {Text} */ (node));
313317
}
314318

315319
assign_nodes(node, node);
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test } from '../../assert';
2+
3+
// Browsers split text nodes > 65536 characters into multiple consecutive text nodes
4+
// during HTML parsing. This test verifies that hydration correctly merges them.
5+
const LARGE_TEXT = 'x'.repeat(70000);
6+
7+
export default test({
8+
mode: ['hydrate'],
9+
skip_mode: ['client'],
10+
11+
props: {
12+
text: LARGE_TEXT
13+
},
14+
15+
async test({ assert, target }) {
16+
const [p] = target.querySelectorAll('p');
17+
18+
// The text content should be preserved after hydration
19+
assert.equal(p.textContent?.trim(), LARGE_TEXT);
20+
// After hydration, there should be only one text node (plus possible comment nodes)
21+
const textNodes = [...p.childNodes].filter((node) => node.nodeType === 3);
22+
assert.equal(textNodes.length, 1, `Expected 1 text node, got ${textNodes.length}`);
23+
}
24+
});
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<script>
2+
let { text } = $props();
3+
</script>
4+
5+
<p>{text}</p>

packages/svelte/tests/runtime-browser/test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ async function run_test(
213213
}
214214

215215
// uncomment to see what was generated
216-
// fs.writeFileSync(`${test_dir}/_actual.js`, build_result.outputFiles[0].text);
216+
// fs.writeFileSync(`${test_dir}/_output/bundle-${hydrate}.js`, build_result.outputFiles[0].text);
217217
const test_result = await page.evaluate(
218218
build_result.outputFiles[0].text + ";test.default(document.querySelector('main'))"
219219
);

0 commit comments

Comments
 (0)