Skip to content

Hydration bugs: async-resume mismatch escapes uncaught; head-script claiming; innerHTML probe escaping; foreign-node strictness #17

Description

@doeixd

Env: @tanstack/redact@0.0.17 (file refs below are into the published tarball's dist/), TanStack Start 1.168.26, Chromium 140 + WebKit. Four bugs, each with repro.

1. Mismatch during suspended hydration throws uncaught — bailout handling only covers the initial sync pass

Where: dist/dom/features/hydration/full.jshydrateRootImpl (L111) wraps only the initial flushSyncWork(renderRoot) in the try/catch that converts hydration bailouts into a client re-render (L124–160). But deferHydration (dist/dom/reconcile.js L605) keeps the hydration cursor alive across a thenable and resumes via thenable.then(clearAwait)scheduleUpdate(fiber) (L622) — a normal scheduled flush with no bailout handler. When the resumed subtree hits a mismatch, validateHydrationProps (full.js L500) → failHydration (L334) → abortHydration (L106) throws into the scheduled flush and escapes uncaught.

Behavior matrix (verified):

  • Mismatching element in the sync tree → onRecoverableError fires, client re-render, app interactive. Correct.
  • Same element inside Suspense+lazyonRecoverableError fires and the same error escapes to window.onerror; the resumed subtree's hydration work is abandoned.

Repro (SSR HTML contains <meta name="theme-color" content="#ffffff">; tree renders #0b0b0c):

import { Suspense, lazy } from '@tanstack/redact';
import { hydrateRoot } from '@tanstack/redact/dom-client';

const LazyHead = lazy(() => Promise.resolve({ default: () => (
  <><meta charSet="utf-8" /><meta name="theme-color" content="#0b0b0c" /><title>t</title></>
)}));
const App = () => (
  <html>
    <head><Suspense fallback={null}><LazyHead/></Suspense></head>
    <body><div id="app"></div></body>
  </html>
);
hydrateRoot(document, <App/>, { onRecoverableError: console.warn });
(recoverable) Hydration attribute mismatch on <meta> for "content": expected "#0b0b0c" but found "#ffffff".
Uncaught Error: Hydration attribute mismatch on <meta> for "content": expected "#0b0b0c" but found "#ffffff".

Move the meta out of the lazy subtree → recoverable only, no uncaught error.

Impact: TanStack Start suspends the whole tree for router hydration, so under Start ANY attribute mismatch is fatal — page renders, zero event listeners. Field case: the standard no-FOUC theme pattern (prefers-color-scheme: dark resolved by an inline script pre-hydration, no cookie) mismatches the SSR'd theme-color meta, leaving every OS-dark visitor with a dead app while OS-light visitors are fine.

Suggested fix: route hydration bailouts from resumed/scheduled flushes through the same recovery path hydrateRootImpl uses (client-render the recovery container), instead of letting abortHydration's throw escape the scheduler.

2. Typeless inline head script skips its own node and claims the first script that has a type

Where: full.jsHEAD_KEY_ATTRS (L180) keys scripts on [src, type]; headAttrsMatch (L187):

if (propVal == null && elVal == null) continue; // key ignored, `matched` stays false
matched = true;                                  // element has the attr, props don't…
if (propVal == null || elVal == null) continue;  // …and it still counts as a match

Two defects compose:

  1. A script fiber with neither src nor type can never match its own DOM node — every key hits the both-null branch, matched stays false.
  2. It does match any script element that merely has a type attribute (element-side value present + props-side null → matched = true, value comparison skipped).

Repro: head = <script dangerouslySetInnerHTML={{__html: code}} /> followed by <script type="application/ld+json">{…}</script>. The inline script's fiber adopts the JSON-LD node; its content is then validated against code (and fails, see #3).

Workaround: explicit type="text/javascript" makes key matching succeed.

Suggested fix: in headAttrsMatch, treat both-sides-absent as agreement (or fall back to positional claiming when no key attr exists on either side).

3. dangerouslySetInnerHTML validation re-serializes raw-text content — false mismatch on identical bytes

Where: full.js validateHydrationProps L506:

const probe = document.createElement("div");
probe.innerHTML = value?.__html ?? "";
if (el.innerHTML !== probe.innerHTML) { failHydration(...) }

<script>/<style> are raw-text elements: el.innerHTML returns the text verbatim. The div probe HTML-parses and re-serializes it, entity-escaping &&amp; and <&lt;. Any inline script containing &&, <, etc. fails even when the DOM text is byte-identical to __html (verified with a character-by-character diff: 928/928 equal, comparison still fails).

Repro: hydrate <script dangerouslySetInnerHTML={{__html: "if(a&&b){}" }} /> over matching SSR output.

Suggested fix: for raw-text elements compare el.textContent === __html (or el.innerHTML === __html) without the div round-trip.

4. Leftover-node check rejects edge/extension-injected elements

Where: full.js HydrationCursor.has() L85 — skips leftover SCRIPT elements and whitespace text, returns true (→ Hydration mismatch: server rendered extra nodes inside <body>. via dist/dom/reconcile.js L541-ish) for anything else.

Cloudflare bot management injects <script> + a hidden <iframe> into <body> at the edge, after SSR. The script is skipped; the iframe fails hydration on every page served through Cloudflare with that feature on — while local/dev testing passes. Browser extensions inject comparable nodes. react-dom deliberately tolerates unknown body-level elements for this reason.

Repro: append <iframe hidden></iframe> to the SSR HTML's <body> before hydration (or serve through Cloudflare with bot management enabled).

Workaround (pnpm patch, running in prod): add || n.tagName === "IFRAME" to the skip in has().

Suggested fix: skip (or warn-only on) leftover elements the hydration pass never expected to claim — foreign injection between SSR and hydration is normal on the open web.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions