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.js — hydrateRootImpl (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+lazy → onRecoverableError 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.js — HEAD_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:
- 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.
- 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 & → & and < → <. 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.
Env:
@tanstack/redact@0.0.17(file refs below are into the published tarball'sdist/), 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.js—hydrateRootImpl(L111) wraps only the initialflushSyncWork(renderRoot)in the try/catch that converts hydration bailouts into a client re-render (L124–160). ButdeferHydration(dist/dom/reconcile.jsL605) keeps the hydration cursor alive across a thenable and resumes viathenable.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):
onRecoverableErrorfires, client re-render, app interactive. Correct.Suspense+lazy→onRecoverableErrorfires and the same error escapes towindow.onerror; the resumed subtree's hydration work is abandoned.Repro (SSR HTML contains
<meta name="theme-color" content="#ffffff">; tree renders#0b0b0c):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: darkresolved by an inline script pre-hydration, no cookie) mismatches the SSR'dtheme-colormeta, 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
hydrateRootImpluses (client-render the recovery container), instead of lettingabortHydration's throw escape the scheduler.2. Typeless inline head script skips its own node and claims the first script that has a
typeWhere:
full.js—HEAD_KEY_ATTRS(L180) keys scripts on[src, type];headAttrsMatch(L187):Two defects compose:
srcnortypecan never match its own DOM node — every key hits the both-null branch,matchedstaysfalse.typeattribute (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 againstcode(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.
dangerouslySetInnerHTMLvalidation re-serializes raw-text content — false mismatch on identical bytesWhere:
full.jsvalidateHydrationPropsL506:<script>/<style>are raw-text elements:el.innerHTMLreturns the text verbatim. The div probe HTML-parses and re-serializes it, entity-escaping&→&and<→<. 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(orel.innerHTML === __html) without the div round-trip.4. Leftover-node check rejects edge/extension-injected elements
Where:
full.jsHydrationCursor.has()L85 — skips leftoverSCRIPTelements and whitespace text, returnstrue(→Hydration mismatch: server rendered extra nodes inside <body>.viadist/dom/reconcile.jsL541-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 inhas().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.