TanStack / TanStack/redact

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

Open
#17 0 comments 0 reactions 0 assignees View on GitHub

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.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.

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.