Hydration units
Something in a visitor's browser can change a page before React hydrates it: an extension that turns email addresses into links, a translator, Cloudflare's email address obfuscation.
Read the Markdown source ↗On this page
Something in a visitor's browser can change a page before React hydrates it: an
extension that turns email addresses into links, a translator, Cloudflare's email
address obfuscation. React then throws a hydration mismatch and client-renders the
nearest Suspense boundary. TanStack Router has exactly one, around everything under
the root route, so the whole page is rebuilt and every entrance animation plays
again (a "blink"). Content the root route renders itself (<html>, header, footer)
is in no boundary at all, so a mismatch there re-renders the whole document.
These helpers keep a mismatch to the section it happened in, stop a rebuild from
replaying an intro, and make mismatches visible in dev and countable in production.
rer verify proves it on every public page (SEED-COMMAND-CONTRACT.md, "Hydration
units").
| File | Export | Use |
|---|---|---|
unit.tsx | HydrationUnit | <Suspense fallback={null}> with no wrapper element |
intro-guard.ts | introGuardScript, introCss | inline <head> script and CSS that stop intros replaying |
report.ts | createRecoverableErrorHandler(report?) | onRecoverableError for the client entry |
The convention
Page sections. A page section is an outermost header, nav, section,
article, aside or footer element in <body>: one that is not inside another
of those.
- Each page section on a public page is exactly one
HydrationUnit: one unit per section, never one unit around a group of sections (a rebuild of the group replaces its siblings). In a pair layout, wrap each section, not the pair. - Root chrome rendered by the root route component (header, nav, footer, banners) is wrapped too. Outside every boundary, a mismatch there re-renders the document.
- Text-bearing route content outside any section (a page title above the sections, a skip link with mixed text) goes in a unit of its own, or becomes lone text (below).
- Signed-in app pages may adopt the same convention; the check covers public pages.
What a unit may contain. A unit must not suspend during server rendering (no
unresolved lazy, use or awaited loader data inside it), or its empty fallback is
streamed first. It must not render <link rel="stylesheet" precedence>. Units cost
a little: they hydrate in a pass after the route boundary (about 17 ms later on the
lab page).
Root route restrictions. The root route never sets shellComponent,
wrapInSuspense or pendingComponent; those wrap <html> in a boundary that cannot
hydrate (TanStack/router#8053).
Intros. A one-time entrance animation is a CSS animation on an element carrying
data-intro, present in the server HTML, that plays from first paint without
waiting for JavaScript.
- Declare its rule inside
@media (prefers-reduced-motion: no-preference), with keyframes that end at the element's natural style, so turning the animation off changes nothing visible.animation-fill-mode: bothis fine. - Looping animations, hover and interaction effects, and elements that mount later
on the client never carry
data-intro. - A seed may keep its own selectors (
.hero-window { animation: … }); the attribute is what the guard keys on. - Intros play once per document load. After they finish, a client-side navigation back to the page shows it in its final state.
The root route renders the guard as early as it can in <head>:
<script dangerouslySetInnerHTML={{ __html: introGuardScript }} />
<style>{introCss}</style>While intros run, it pins a rebuilt intro element's animation to the original clock, so the element continues where its predecessor was; once every intro has finished it turns intro animations off, so a later rebuild appears in its final state. A seed with a Content Security Policy passes the router's SSR nonce to this inline script.
Lone text. React checks an element whose only child is a string by its text content alone, so a linkifier's split-and-wrap there changes nothing React checks. A text node beside sibling elements is checked node by node, and splitting it always mismatches. Email addresses, phone numbers, URLs, shell commands and street addresses in public copy are the only child of their element:
<p>Write to <a href="mailto:records@example.com">records@example.com</a></p>
<p><b>$</b> <span>{command}</span></p>suppressHydrationWarning only accepts in-place text and attribute changes one
level deep and does nothing for split or wrapped text; it is a per-element tool, not
part of this convention.
Client entry. src/client.tsx replaces Start's default entry:
startTransition(() => {
hydrateRoot(document, <StrictMode><StartClient /></StrictMode>,
{ onRecoverableError: createRecoverableErrorHandler() })
})In dev each recovered boundary prints one console.warn naming the hydration
mismatch, with React's error and the component stack. In production it is silent and
counts recoveries in window.__rerHydration ({ path, count, kinds, stacks };
count is the contract). Pass a reporter, createRecoverableErrorHandler(summary => …), to send that summary to an error tracker: it is called once on pagehide, only
when a mismatch happened. Other recoverable errors keep React's default.
Documents. Document responses send Cache-Control with no-transform (keeping
the seed's other directives), which turns off Cloudflare's email address obfuscation
and any other proxy rewrite of the HTML React hydrates. A prerendered page is such a
document too: rer deploy runs the Worker before built files, so the Worker answers
it. A seed that prerenders pages builds with the same routing (the Cloudflare Vite
plugin's build config gives assets: { binding: 'ASSETS', run_worker_first: true },
as the reference's vite.config.ts does), so rer preview serves them the same way
and not as bare assets without no-transform.
Adopting
One change per seed, after the Base commit that carries these files. An owner-reviewed homepage must look and behave exactly as approved: adoption adds units, attributes, spans and a header, and nothing visible changes.
- Copy
unit.tsx,intro-guard.tsandreport.tsbyte for byte from that Base commit into the seed's Base runtime folder (src/base/hydration/in the exported layout) and record the copy the seed's way (record-base,base-copies.jsonorsources.lock.json). - Add
src/client.tsxfrom Base'sdefault-seed(seed-owned; pass a reporter only if the seed has an error tracker). - Bring the shared check forward with the rest of the command implementation:
refresh
scripts/base-cli/to the same Base commit in vendored mode, or update the exact@runeditrun/rerpin in package mode. - Root route: the guard script and
introCssearly in<head>; wrap root chrome in units. Do not addshellComponent,wrapInSuspenseorpendingComponent. - Wrap each section of every public page in
HydrationUnit(one per section). Wrap loose text-bearing route content. - Put
data-introon every one-time entrance animation element; its rule sits underprefers-reduced-motion: no-preferenceand its keyframes end at the natural style. - Make addresses, phone numbers, URLs and commands lone text (the check's advisory lists the email cases).
- Add
no-transformto theCache-Controlof document responses, keeping the seed's other directives. A seed that prerenders pages builds with the deploy's asset routing (see Documents above). - Declare
publicPagesincommandAdapterif discovery from/(and the same-origin redirects it follows from/) does not reach the seed's public pages, or reaches pages it should not. A check that loads no page fails. - Prove it:
rer verifypasses with the check;rer devshows one hydration warning per induced mismatch; and screenshots of each owner-reviewed public page at 1440 and 390 wide, animations settled, are pixel-identical before and after.
A seed with its own intro resume logic may keep it if the check passes; it still
adds the attribute and the guard so the done flag covers late rebuilds. A plain-HTML page
(React never hydrates it, it loads no application script and raises no error) is
listed and skipped by the check; a page whose scripts load but never hydrate fails.
Application scripts are same-origin script requests, module scripts and
modulepreload links in the served document from any origin, and cross-origin
module fetches; a third-party classic <script src> is not one.
The check holds the client scripts in the network layer until its edits land; keep
the client entry an external module script, because a page that inlines its bundle
in an inline module script hydrates before the release and fails as not exercised.