fix: merge consecutive text nodes during hydration for large text content (#17587)

* fix: merge consecutive text nodes during hydration for large text content

Fixes #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>
pull/17585/head
FORMI 4 days ago committed by GitHub
parent ebe583f2bb
commit 8933653fbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,5 @@
---
'svelte': patch
---
fix: merge consecutive large text nodes

@ -122,6 +122,10 @@ export function child(node, is_text) {
return text;
}
if (is_text) {
merge_text_nodes(/** @type {Text} */ (child));
}
set_hydrate_node(child);
return child;
}
@ -142,14 +146,18 @@ export function first_child(node, is_text = false) {
return first;
}
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && hydrate_node?.nodeType !== TEXT_NODE) {
var text = create_text();
if (is_text) {
// if an {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (hydrate_node?.nodeType !== TEXT_NODE) {
var text = create_text();
hydrate_node?.before(text);
set_hydrate_node(text);
return text;
hydrate_node?.before(text);
set_hydrate_node(text);
return text;
}
merge_text_nodes(/** @type {Text} */ (hydrate_node));
}
return hydrate_node;
@ -175,20 +183,24 @@ export function sibling(node, count = 1, is_text = false) {
return next_sibling;
}
// if a sibling {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (is_text && next_sibling?.nodeType !== TEXT_NODE) {
var text = create_text();
// If the next sibling is `null` and we're handling text then it's because
// the SSR content was empty for the text, so we need to generate a new text
// node and insert it after the last sibling
if (next_sibling === null) {
last_sibling?.after(text);
} else {
next_sibling.before(text);
if (is_text) {
// if a sibling {expression} is empty during SSR, there might be no
// text node to hydrate — we must therefore create one
if (next_sibling?.nodeType !== TEXT_NODE) {
var text = create_text();
// If the next sibling is `null` and we're handling text then it's because
// the SSR content was empty for the text, so we need to generate a new text
// node and insert it after the last sibling
if (next_sibling === null) {
last_sibling?.after(text);
} else {
next_sibling.before(text);
}
set_hydrate_node(text);
return text;
}
set_hydrate_node(text);
return text;
merge_text_nodes(/** @type {Text} */ (next_sibling));
}
set_hydrate_node(next_sibling);
@ -258,3 +270,24 @@ export function set_attribute(element, key, value = '') {
}
return element.setAttribute(key, value);
}
/**
* Browsers split text nodes larger than 65536 bytes when parsing.
* For hydration to succeed, we need to stitch them back together
* @param {Text} text
*/
export function merge_text_nodes(text) {
if (/** @type {string} */ (text.nodeValue).length < 65536) {
return;
}
let next = text.nextSibling;
while (next !== null && next.nodeType === TEXT_NODE) {
next.remove();
/** @type {string} */ (text.nodeValue) += /** @type {string} */ (next.nodeValue);
next = text.nextSibling;
}
}

@ -4,11 +4,13 @@ import { hydrate_next, hydrate_node, hydrating, set_hydrate_node } from './hydra
import {
create_text,
get_first_child,
get_next_sibling,
is_firefox,
create_element,
create_fragment,
create_comment,
set_attribute
set_attribute,
merge_text_nodes
} from './operations.js';
import { create_fragment_from_html } from './reconciler.js';
import { active_effect } from '../runtime.js';
@ -310,6 +312,8 @@ export function text(value = '') {
// if an {expression} is empty during SSR, we need to insert an empty text node
node.before((node = create_text()));
set_hydrate_node(node);
} else {
merge_text_nodes(/** @type {Text} */ (node));
}
assign_nodes(node, node);

@ -0,0 +1,24 @@
import { test } from '../../assert';
// Browsers split text nodes > 65536 characters into multiple consecutive text nodes
// during HTML parsing. This test verifies that hydration correctly merges them.
const LARGE_TEXT = 'x'.repeat(70000);
export default test({
mode: ['hydrate'],
skip_mode: ['client'],
props: {
text: LARGE_TEXT
},
async test({ assert, target }) {
const [p] = target.querySelectorAll('p');
// The text content should be preserved after hydration
assert.equal(p.textContent?.trim(), LARGE_TEXT);
// After hydration, there should be only one text node (plus possible comment nodes)
const textNodes = [...p.childNodes].filter((node) => node.nodeType === 3);
assert.equal(textNodes.length, 1, `Expected 1 text node, got ${textNodes.length}`);
}
});

@ -0,0 +1,5 @@
<script>
let { text } = $props();
</script>
<p>{text}</p>

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

Loading…
Cancel
Save