Disclosure: This issue is submitted by a human. I used an AI assistant to help
investigate the root cause and draft this write-up — including the reproduction, the
root-cause analysis, and the suggested fix. I've reviewed all of it and confirmed the
behavior reproduces.
Summary
When a clientEntry island server-renders a single text node longer than the browser's
per-text-node character cap (65536 in Chromium/Blink), hydration duplicates part of the
text. Everything after the first 65536 characters ends up rendered twice in the DOM.
The root cause is that the browser's HTML parser splits a long text node into several
adjacent Text nodes, and the hydration reconciler only handles the opposite case
(one DOM text node that must be split across several vnodes) — not one vnode text that
the parser has spread across several DOM text nodes.
Environment
remix 3.0.0-beta.5
@remix-run/ui 0.4.0
- Browser: any Chromium-based browser (text-node cap = 65536 chars). Other engines split
at their own limits, so the same class of bug applies wherever a text node exceeds the
engine's cap.
Reproduction
An island whose render produces one text child longer than 65536 characters:
// app/assets/BigText.tsx
import { clientEntry } from 'remix/ui';
export const BigText = clientEntry(import.meta.url, function BigText() {
// ~160 KB of distinct, countable lines — comfortably over the 65536-char cap.
const text = Array.from({ length: 20000 }, (_, i) => `row-${i}`).join('\n');
return () => <pre>{text}</pre>;
});
Render it on a page, load that page in the browser, and after hydration inspect the
<pre>:
const pre = document.querySelector('pre');
console.log(pre.textContent.split('\n').length); // expected 20000, actually far more
console.log(pre.childNodes.length); // > 1: leftover split text nodes
- Expected: 20000 lines,
row-0 … row-19999, each appearing once.
- Actual: the lines after roughly the 65536th character are duplicated — the total
line count is well above 20000, and row-N for the tail range appears twice.
There is also a console warning emitted during hydration:
text mismatch <first 65536 chars> <full text>
Root cause
While parsing the server HTML, the browser splits the >64 KB text into multiple adjacent
Text nodes (chunks of ≤65536 chars). The vnode side has a single text node holding the
full string.
In @remix-run/ui/src/runtime/reconcile.ts (the text branch, ~L727–745):
if (isTextNode(node)) {
if (cursor instanceof Text) {
node._parent = vParent
if (cursor.data !== node._text) {
if (cursor.data.startsWith(node._text) && node._text.length < cursor.data.length) {
// Consolidation case: one DOM text node covers several vnodes → split it.
let remainder = cursor.splitText(node._text.length)
node._dom = cursor
return remainder
}
// Genuine mismatch - correct it
logHydrationMismatch('text mismatch', cursor.data, node._text)
cursor.data = node._text // writes the full text into the FIRST split node
}
node._dom = cursor
return cursor.nextSibling // the remaining split nodes are left in place
}
...
}
With a parser-split node:
cursor.data is the first chunk (≤65536 chars); node._text is the full string.
cursor.data.startsWith(node._text) is false (the short chunk can't start with the
longer full string), so the consolidation branch is skipped.
- Control falls to the "genuine mismatch" branch, which sets
cursor.data = node._text
(writing the entire body into the first chunk node) and returns cursor.nextSibling.
- The remaining sibling chunk nodes (chunk 2..N, i.e. everything past 65536 chars) are
never consumed or removed, so they remain in the DOM after the now-full first node —
duplicating that tail.
Suggested fix
Handle the mirror of the existing consolidation case: when the DOM text node is a prefix
of the vnode text (the parser split one long text node), consume the trailing sibling
Text nodes that make up the remainder before writing the full value. Roughly:
if (cursor.data !== node._text) {
if (cursor.data.startsWith(node._text) && node._text.length < cursor.data.length) {
// one DOM node, several vnodes
let remainder = cursor.splitText(node._text.length)
node._dom = cursor
return remainder
}
if (node._text.startsWith(cursor.data) && cursor.data.length < node._text.length) {
// one vnode, several DOM nodes (parser split a >64K text node)
let remainder = node._text.slice(cursor.data.length)
let sibling = cursor.nextSibling
while (remainder.length > 0 && sibling instanceof Text && remainder.startsWith(sibling.data)) {
remainder = remainder.slice(sibling.data.length)
let next = sibling.nextSibling
sibling.remove()
sibling = next
}
cursor.data = node._text
node._dom = cursor
return sibling
}
// genuine mismatch
logHydrationMismatch('text mismatch', cursor.data, node._text)
cursor.data = node._text
}
Workaround (userland)
Emit long text as several element-wrapped chunks each below the cap, so the parser never
splits a text node and the server/client structure line up (element boundaries survive
serialization; text-node boundaries do not):
const LIMIT = 32768;
function chunked(text: string) {
if (text.length <= LIMIT) return text;
const count = Math.ceil(text.length / LIMIT);
return Array.from({ length: count }, (_, i) => (
<span key={i}>{text.slice(i * LIMIT, (i + 1) * LIMIT)}</span>
));
}
// <pre>{chunked(text)}</pre>
Summary
When a
clientEntryisland server-renders a single text node longer than the browser'sper-text-node character cap (65536 in Chromium/Blink), hydration duplicates part of the
text. Everything after the first 65536 characters ends up rendered twice in the DOM.
The root cause is that the browser's HTML parser splits a long text node into several
adjacent
Textnodes, and the hydration reconciler only handles the opposite case(one DOM text node that must be split across several vnodes) — not one vnode text that
the parser has spread across several DOM text nodes.
Environment
remix3.0.0-beta.5@remix-run/ui0.4.0at their own limits, so the same class of bug applies wherever a text node exceeds the
engine's cap.
Reproduction
An island whose render produces one text child longer than 65536 characters:
Render it on a page, load that page in the browser, and after hydration inspect the
<pre>:row-0 … row-19999, each appearing once.line count is well above 20000, and
row-Nfor the tail range appears twice.There is also a console warning emitted during hydration:
Root cause
While parsing the server HTML, the browser splits the >64 KB text into multiple adjacent
Textnodes (chunks of ≤65536 chars). The vnode side has a single text node holding thefull string.
In
@remix-run/ui/src/runtime/reconcile.ts(the text branch, ~L727–745):With a parser-split node:
cursor.datais the first chunk (≤65536 chars);node._textis the full string.cursor.data.startsWith(node._text)isfalse(the short chunk can't start with thelonger full string), so the consolidation branch is skipped.
cursor.data = node._text(writing the entire body into the first chunk node) and returns
cursor.nextSibling.never consumed or removed, so they remain in the DOM after the now-full first node —
duplicating that tail.
Suggested fix
Handle the mirror of the existing consolidation case: when the DOM text node is a prefix
of the vnode text (the parser split one long text node), consume the trailing sibling
Textnodes that make up the remainder before writing the full value. Roughly:Workaround (userland)
Emit long text as several element-wrapped chunks each below the cap, so the parser never
splits a text node and the server/client structure line up (element boundaries survive
serialization; text-node boundaries do not):