Hydration bugs: async-resume mismatch escapes uncaught; head-script claiming; innerHTML probe escaping; foreign-node strictness
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 270
- Forks
- 8
- Avg merge
- 46m
- Merged PRs (30d)
- 12
Description
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 →
onRecoverableErrorfires, client re-render, app interactive. Correct. - Same element inside
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):
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
srcnortypecan never match its own DOM node — every key hits the both-null branch,matchedstaysfalse. - It does match any script element that merely has a
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 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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in dist/dom/features/hydration/full.js at hydrateRootImpl, deferHydration-related recovery, headAttrsMatch, validateHydrationProps, and HydrationCursor.has(), then trace the scheduled flush in dist/dom/reconcile.js. Run the four supplied hydration repros, including the lazy Suspense mismatch, raw-text script, head-script ordering, and injected iframe cases. Done means mismatches recover without uncaught errors, matching raw-text content does not fail, scripts claim the correct nodes, and tolerated injected elements do not abort hydration.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- frontend
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100