Skip to content

Hydration duplicates content when an island contains a text node larger than ~64 KB #11587

Description

@quentinadam

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>

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions