diff --git a/.changeset/deep-bears-see.md b/.changeset/deep-bears-see.md new file mode 100644 index 0000000000..9f59b31d12 --- /dev/null +++ b/.changeset/deep-bears-see.md @@ -0,0 +1,5 @@ +--- +'svelte': patch +--- + +fix: merge consecutive large text nodes diff --git a/packages/svelte/src/internal/client/dom/operations.js b/packages/svelte/src/internal/client/dom/operations.js index 479c2ba0a5..61a48bcbfb 100644 --- a/packages/svelte/src/internal/client/dom/operations.js +++ b/packages/svelte/src/internal/client/dom/operations.js @@ -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; + } +} diff --git a/packages/svelte/src/internal/client/dom/template.js b/packages/svelte/src/internal/client/dom/template.js index 0d827218bc..567fbeabf0 100644 --- a/packages/svelte/src/internal/client/dom/template.js +++ b/packages/svelte/src/internal/client/dom/template.js @@ -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); diff --git a/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js new file mode 100644 index 0000000000..3a52b1ccd3 --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/_config.js @@ -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}`); + } +}); diff --git a/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte new file mode 100644 index 0000000000..d65358dabc --- /dev/null +++ b/packages/svelte/tests/runtime-browser/samples/hydrate-large-text-node/main.svelte @@ -0,0 +1,5 @@ + + +
{text}
diff --git a/packages/svelte/tests/runtime-browser/test.ts b/packages/svelte/tests/runtime-browser/test.ts index 525f9ba89f..54cdc0f8be 100644 --- a/packages/svelte/tests/runtime-browser/test.ts +++ b/packages/svelte/tests/runtime-browser/test.ts @@ -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'))" );