
Hydration Guardian
- 94 installs
- 14 repo stars
- Updated March 2, 2026
- oakoss/agent-skills
Helps with ai & agent building tasks during AI-assisted development.
About
hydration-guardian is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- hydration-guardian
- AI & Agent Building
- AI-coding skill
Hydration Guardian by the numbers
- 94 all-time installs (skills.sh)
- +4 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #4,644 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/oakoss/agent-skills --skill hydration-guardianAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 94 |
|---|---|
| repo stars | ★ 14 |
| Last updated | March 2, 2026 |
| Repository | oakoss/agent-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
Files
Hydration Guardian
Overview
Ensures zero-mismatch integrity between server-rendered HTML and client-side React trees. Covers hydration error diagnosis, selective hydration via Suspense boundaries, deterministic data bridges with the React 19 use() hook, 'use cache' for eliminating data drift, two-pass rendering for client-only content, React 19's single-diff hydration error reporting for pinpointing exact mismatches, and automated validation of rendered DOM state.
When to use: Debugging hydration mismatch errors, fixing text content mismatches, handling browser extension DOM pollution, implementing deterministic data bridges, optimizing SSR/client hydration performance, setting up error monitoring with onRecoverableError.
When NOT to use: Client-only React applications without SSR, static sites without hydration, API-only backends.
Quick Reference
| Pattern | Approach | Key Points |
|---|---|---|
| Selective hydration | <Suspense fallback={...}> boundary | Hydrates independently; prioritizes user interaction |
| Deterministic bridge | use(serverPromise) instead of useEffect | Direct server-to-client data transition (React 19) |
| Cache directive | 'use cache' in data fetchers | Share exact server result with client during hydration |
| Two-pass rendering | useState + useEffect for client-only | First render matches server; second adds client content |
| Client-only skip | next/dynamic with ssr: false | Exclude component from server render entirely |
| Error monitoring | onRecoverableError on hydrateRoot | Detect and report silent hydration recovery |
| Error reporting | React 19 single-diff error format | Pinpoints exact mismatch location with unified diff output |
| Error callbacks | onUncaughtError, onCaughtError | Granular error handling on createRoot/hydrateRoot |
| Date/time safety | UTC normalization or server-synced context | Prevent locale-dependent hydration mismatches |
| Extension resilience | Test with common browser extensions active | Detect DOM pollution from translators, dark-mode tools |
Hydration Error Diagnosis
| Error Message | Likely Cause | Corrective Action |
|---|---|---|
Text content did not match | Non-deterministic render (dates, random values) | Use two-pass rendering or suppressHydrationWarning |
Expected server HTML to contain | Client renders content server did not | Move client-only code to useEffect or dynamic import |
Hydration failed | Invalid HTML nesting (<p> inside <p>) | Fix HTML structure; browsers auto-correct causing drift |
Extra attributes from server | Server-only attributes not on client | Ensure attribute parity or use suppressHydrationWarning |
There was an error while hydrating | Extension-modified DOM or major mismatch | Check for browser extensions; verify HTML validity |
Common Mistakes
| Mistake | Correct Pattern |
|---|---|
Using suppressHydrationWarning on container elements | Fix the root cause; suppress only on leaf elements with unavoidable differences |
Accessing window or document in the render body | Wrap client-only code in useEffect or use next/dynamic with ssr: false |
Using Math.random() or new Date() without stable seeds | Use UTC normalization, server-cached values, or two-pass rendering |
| Ignoring silent hydration recovery in production | Configure onRecoverableError on hydrateRoot to log and monitor |
Using dangerouslySetInnerHTML with server/client mismatch | Ensure identical content or use a dedicated key change to force remount |
Checking typeof window !== 'undefined' in render | Use two-pass rendering; the check runs on server too (it returns false) |
Nesting <p> inside <p> or <div> inside <p> | Fix invalid HTML nesting; browsers correct it causing server/client drift |
Delegation
- Scan rendered pages for hidden hydration warnings: Use
Exploreagent with Chrome DevTools to run the hydration audit script - Fix hydration mismatches across multiple routes: Use
Taskagent to isolate, correct, and verify each affected component - Design hydration-safe architecture for new features: Use
Planagent to select between Suspense boundaries, two-pass rendering, and cache patterns
References
- Common Mismatches -- causes, diagnosis, and fixes for hydration mismatch errors including dates, locales, HTML nesting, and browser extensions
- Selective Hydration -- Suspense-based selective hydration, streaming SSR, two-pass rendering, and client-only components
- Use Cache Patterns -- data drift prevention, Next.js use cache directive, React 19 use() hook, deterministic data bridges
- Validation Techniques -- automated DOM verification, mutation monitoring, onRecoverableError, and production hydration monitoring
Common Mismatches
Hydration mismatches occur when the HTML generated by the server does not match what React expects during client-side hydration. React recovers from some mismatches by re-rendering, but this destroys performance and can attach event handlers to wrong elements.
Non-Deterministic Values
The most common hydration trap. Server renders at time T1, client hydrates at time T2 with different values.
Date and Time
// WRONG: Different output on server vs client
function Timestamp() {
return <span>{new Date().toLocaleTimeString()}</span>;
}
// CORRECT: Two-pass rendering for client-only time display
function Timestamp() {
const [time, setTime] = useState<string | null>(null);
useEffect(() => {
setTime(new Date().toLocaleTimeString());
}, []);
if (!time) return <span>Loading...</span>;
return <span>{time}</span>;
}Random Values
// WRONG: Different random value on server vs client
function RandomGreeting() {
const greetings = ['Hello', 'Hi', 'Hey'];
return <span>{greetings[Math.floor(Math.random() * 3)]}</span>;
}
// CORRECT: Use a stable seed from server or useId for determinism
import { useId } from 'react';
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return hash;
}
function RandomGreeting() {
const id = useId();
const greetings = ['Hello', 'Hi', 'Hey'];
const index = Math.abs(hashString(id)) % greetings.length;
return <span>{greetings[index]}</span>;
}Locale and Formatting Differences
Server and client may use different locales, causing formatting mismatches in numbers, currencies, and dates.
// WRONG: Browser locale may differ from server locale
function Price({ amount }: { amount: number }) {
return (
<span>
{amount.toLocaleString(undefined, { style: 'currency', currency: 'USD' })}
</span>
);
}
// CORRECT: Pass explicit locale from server context
function Price({ amount, locale }: { amount: number; locale: string }) {
return (
<span>
{amount.toLocaleString(locale, { style: 'currency', currency: 'USD' })}
</span>
);
}For relative timestamps like "2 minutes ago", render a static format on the server and update to relative format on the client via useEffect.
Invalid HTML Nesting
Browsers auto-correct invalid HTML, causing the DOM to differ from what React expects.
// WRONG: <p> cannot contain block elements; browser removes inner <p>
function Article() {
return (
<p>
Intro text
<p>Nested paragraph</p>
</p>
);
}
// CORRECT: Use appropriate nesting
function Article() {
return (
<div>
<p>Intro text</p>
<p>Nested paragraph</p>
</div>
);
}Common invalid nesting that causes hydration errors:
| Invalid | Why | Fix |
|---|---|---|
<p> inside <p> | <p> cannot contain block elements | Use <div> or <span> |
<div> inside <p> | Block inside inline | Restructure with <div> as parent |
<a> inside <a> | Interactive inside interactive | Restructure; use separate links |
<table> without <tbody> | Browser auto-inserts <tbody> | Explicitly include <tbody> |
Browser Extensions
Extensions like Dark Reader, Google Translate, and password managers inject or modify DOM elements, causing mismatches between what React rendered on the server and what it finds in the browser.
Common extension-induced issues:
- Translation extensions wrap text nodes in
<font>or<span>tags - Dark Reader injects
<style>elements and modifies inline styles - Ad blockers remove or hide DOM elements
- Password managers inject input overlays
Mitigation strategies:
// Use suppressHydrationWarning on elements commonly modified by extensions
<html suppressHydrationWarning>
<body suppressHydrationWarning>
<App />
</body>
</html>For content areas specifically targeted by translation extensions, consider wrapping with translate="no" to prevent translation-induced mismatches:
<span translate="no">{criticalText}</span>The typeof window Check
A frequent mistake is checking typeof window !== 'undefined' directly in the render path.
// WRONG: This check runs on both server (false) and client (true),
// producing different output and causing a hydration mismatch
function Navigation() {
if (typeof window !== 'undefined') {
return <ClientNav />;
}
return <ServerNav />;
}
// CORRECT: Two-pass rendering
function Navigation() {
const [isClient, setIsClient] = useState(false);
useEffect(() => {
setIsClient(true);
}, []);
return isClient ? <ClientNav /> : <ServerNav />;
}suppressHydrationWarning
React provides suppressHydrationWarning as an escape hatch for unavoidable single-element mismatches. It only works one level deep and does NOT patch mismatched content.
// Acceptable: leaf element with unavoidable time difference
<time suppressHydrationWarning dateTime={date.toISOString()}>
{date.toLocaleTimeString()}
</time>
// WRONG: suppressing on containers hides real bugs
<div suppressHydrationWarning>
<ComplexComponent />
</div>Rules for suppressHydrationWarning:
- Use only on leaf text elements (not containers)
- The mismatch must be genuinely unavoidable (timestamps, UUIDs)
- React will NOT patch the content; the server value persists until a re-render
- Never use it to silence bugs; fix the root cause instead
Debugging Hydration Errors
1. Read the error message -- React provides a diff showing expected vs actual content 2. Check for non-deterministic code in the render path (Date, Math.random, browser APIs) 3. Validate HTML nesting -- use the W3C validator or browser DevTools 4. Disable browser extensions to isolate extension-caused mismatches 5. Compare server HTML with client DOM using view-source: vs DevTools Elements panel 6. Check `onRecoverableError` logs for silent recovery events (see validation-techniques reference)
Selective Hydration
React 18+ supports selective hydration via Suspense boundaries. Each Suspense boundary can hydrate independently, and React prioritizes hydrating components the user interacts with.
How Selective Hydration Works
With hydrateRoot and Suspense, React solves three SSR bottlenecks:
1. No waiting for all data -- Streaming HTML sends content as it becomes ready 2. No waiting for all JS -- Code-split components hydrate when their code loads 3. No waiting for all hydration -- User interactions trigger priority hydration
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root')!, <App />);Suspense Boundaries for Hydration Control
Wrap independent sections in Suspense to create hydration boundaries. Each boundary hydrates independently without blocking siblings.
import { Suspense } from 'react';
function ProductPage({ product }: { product: Product }) {
return (
<main>
<ProductHeader product={product} />
<Suspense fallback={<DetailsSkeleton />}>
<ProductDetails product={product} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews productId={product.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<RecommendedProducts categoryId={product.categoryId} />
</Suspense>
</main>
);
}Hydration behavior:
ProductHeaderhydrates first (not wrapped in Suspense)- Each Suspense section hydrates independently when its code loads
- If a user clicks on
ProductReviewsbefore it hydrates, React prioritizes hydrating that section immediately
Streaming SSR with renderToPipeableStream
Server-side streaming sends HTML progressively as components resolve their data.
import { renderToPipeableStream } from 'react-dom/server';
function handleRequest(req: Request, res: Response) {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/client.js'],
onShellReady() {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(error) {
res.statusCode = 500;
res.send('Server error');
},
});
}The shell (content outside Suspense boundaries) sends immediately. Suspended content streams in as it resolves, with inline <script> tags that swap fallbacks for real content.
Two-Pass Rendering
For content that must differ between server and client (browser-only APIs, user preferences), use the two-pass rendering pattern.
import { useState, useEffect } from 'react';
function ThemeToggle() {
const [theme, setTheme] = useState<string | null>(null);
useEffect(() => {
setTheme(localStorage.getItem('theme') ?? 'light');
}, []);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme ?? 'light'}
</button>
);
}How it works:
1. First render (server + client initial): theme is null, both render the same fallback 2. Second render (client only): useEffect fires, theme updates, component re-renders with real value
Trade-offs:
- Components render twice on the client (slower hydration)
- Users see a brief flash as content changes
- Use sparingly for genuinely client-dependent content
Client-Only Components with next/dynamic
For components that cannot run on the server at all (depend on window, document, or browser-only libraries), use Next.js dynamic imports.
import dynamic from 'next/dynamic';
const MapView = dynamic(() => import('./MapView'), {
ssr: false,
loading: () => <MapSkeleton />,
});
function LocationPage() {
return (
<div>
<h1>Our Location</h1>
<MapView />
</div>
);
}When to use `ssr: false`:
| Scenario | Use ssr: false | Alternative |
|---|---|---|
| Browser-only library (Leaflet, Chart.js) | Yes | None; library crashes on server |
Content using window.matchMedia | Maybe | Two-pass rendering with useEffect |
Content using localStorage | Maybe | Two-pass rendering with useEffect |
| Content that just differs by time | No | suppressHydrationWarning or two-pass |
Important: 'use client' does NOT mean "client-only." Client components still render on the server during SSR. Only next/dynamic with ssr: false truly skips server rendering.
Reusable Client-Only Wrapper
import { useState, useEffect, type ReactNode } from 'react';
function ClientOnly({
children,
fallback = null,
}: {
children: ReactNode;
fallback?: ReactNode;
}) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return mounted ? children : fallback;
}
// Usage
function App() {
return (
<ClientOnly fallback={<TimeSkeleton />}>
<LocalizedClock />
</ClientOnly>
);
}Hydration with Error Recovery
Configure hydrateRoot to monitor and report hydration recovery events in production.
import { hydrateRoot } from 'react-dom/client';
hydrateRoot(document.getElementById('root')!, <App />, {
onRecoverableError(error, errorInfo) {
console.error('Hydration recovery:', error);
reportToMonitoring({
type: 'hydration-recovery',
error: error.message,
componentStack: errorInfo.componentStack,
});
},
});When React encounters a hydration mismatch, it attempts to recover by re-rendering the affected subtree. onRecoverableError fires for each recovery event, enabling production monitoring of silent hydration issues.
Next.js Automatic Suspense
Next.js App Router automatically wraps route segments in Suspense boundaries via loading.tsx files. Each route segment becomes an independent hydration boundary.
app/
layout.tsx
loading.tsx <-- Suspense boundary for root
page.tsx
products/
loading.tsx <-- Suspense boundary for products
page.tsx
[id]/
loading.tsx <-- Suspense boundary for product detail
page.tsxThis provides selective hydration at the route level without manual Suspense placement.
Use Cache Patterns
Data drift occurs when data fetched on the server changes by the time the client hydrates. The Next.js 'use cache' directive and React 19 use() hook work together to eliminate this class of hydration errors.
The Problem: Data Drift
Without caching, the server renders with data at time T1, but the client hydrates at time T2 when the data may have changed. This causes hydration mismatches even though the code is correct.
Common drift sources:
- Real-time data (stock prices, counters, notifications)
- Time-dependent formatting (relative timestamps like "2 minutes ago")
- Session-dependent content (user name, avatar loaded asynchronously)
- A/B test variants resolved at different times
Next.js use cache Directive
The 'use cache' directive caches the return value of async functions and components. The cached result is embedded in the server-rendered payload, ensuring the client uses the exact same data.
Enabling use cache
// next.config.ts
const nextConfig = {
experimental: {
cacheComponents: true,
},
};
export default nextConfig;Caching a Data Fetcher
async function getProductData(id: string) {
'use cache';
const product = await db.product.findUnique({ where: { id } });
return product;
}Arguments automatically become part of the cache key, so different inputs produce separate cache entries.
Cache Variants
| Directive | Storage | Use Case |
|---|---|---|
'use cache' | In-memory (server) | Default; fast for typical data |
'use cache: remote' | External cache store | Durable caching across deployments |
'use cache: private' | Browser memory only | Personalized data with cookies()/headers() |
Controlling Cache Lifetime
import { cacheLife } from 'next/cache';
async function getProductData(id: string) {
'use cache';
cacheLife('hours');
return await db.product.findUnique({ where: { id } });
}Built-in profiles: 'seconds', 'minutes', 'hours', 'days', 'weeks', 'max'.
Cache Revalidation
import { cacheTag } from 'next/cache';
import { revalidateTag } from 'next/cache';
async function getProductData(id: string) {
'use cache';
cacheTag(`product-${id}`);
return await db.product.findUnique({ where: { id } });
}
// In a mutation or webhook handler
async function updateProduct(id: string) {
'use server';
await db.product.update({ where: { id }, data: { ... } });
revalidateTag(`product-${id}`);
}React 19 use() Hook
The use() API reads the value of a Promise or context. Unlike other hooks, use() can be called inside conditionals and loops.
Server-to-Client Data Bridge
Pass a Promise from a Server Component to a Client Component. The client resolves it via use() without re-fetching.
// Server Component
import { Suspense } from 'react';
import { getProductData } from './data';
import { ProductDetail } from './ProductDetail';
export default function ProductPage({ params }: { params: { id: string } }) {
const dataPromise = getProductData(params.id);
return (
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail dataPromise={dataPromise} />
</Suspense>
);
}// Client Component
'use client';
import { use } from 'react';
export function ProductDetail({
dataPromise,
}: {
dataPromise: Promise<Product>;
}) {
const product = use(dataPromise);
return (
<article>
<h1>{product.name}</h1>
<p>{product.description}</p>
<span>{formatPrice(product.price)}</span>
</article>
);
}Why this prevents hydration mismatches: The Promise created on the server is serialized into the HTML payload. The client use() call resolves the same data, producing identical output.
use() vs useEffect + useState
// LEGACY: Double-fetch pattern (causes hydration mismatch)
function LegacyProduct({ id }: { id: string }) {
const [data, setData] = useState<Product | null>(null);
useEffect(() => {
fetchProduct(id).then(setData);
}, [id]);
if (!data) return <Skeleton />;
return <ProductView product={data} />;
}
// MODERN: Deterministic bridge via use() (zero mismatch)
function ModernProduct({ dataPromise }: { dataPromise: Promise<Product> }) {
const data = use(dataPromise);
return <ProductView product={data} />;
}Error Handling with use()
use() cannot be called in a try-catch block. Use Error Boundaries or Promise .catch():
// Option 1: Error Boundary
import { ErrorBoundary } from 'react-error-boundary';
function ProductPage({ dataPromise }: { dataPromise: Promise<Product> }) {
return (
<ErrorBoundary fallback={<ProductError />}>
<Suspense fallback={<ProductSkeleton />}>
<ProductDetail dataPromise={dataPromise} />
</Suspense>
</ErrorBoundary>
);
}
// Option 2: Promise .catch() for fallback value
function ProductDetail({ dataPromise }: { dataPromise: Promise<Product> }) {
const safePromise = dataPromise.catch(() => DEFAULT_PRODUCT);
const product = use(safePromise);
return <ProductView product={product} />;
}Limitations of use() with Promises
- Promises created inside Client Components are not yet supported (only via frameworks or Suspense-compatible libraries)
- Pass Promises from Server Components to Client Components for the intended pattern
use()integrates with Suspense; the parent must have a Suspense boundary
Complete Hydration-Safe Pattern
Combining 'use cache', use(), and Suspense for a fully resilient component:
// data.ts (Server)
async function getDashboardData(userId: string) {
'use cache';
cacheTag(`dashboard-${userId}`);
return {
userName: await fetchName(userId),
stats: await fetchStats(userId),
};
}
// DashboardPage.tsx (Server Component)
import { Suspense } from 'react';
export default function DashboardPage({ userId }: { userId: string }) {
const dataPromise = getDashboardData(userId);
return (
<section>
<h2>Dashboard</h2>
<Suspense fallback={<DashboardSkeleton />}>
<DashboardContent dataPromise={dataPromise} />
</Suspense>
</section>
);
}
// DashboardContent.tsx (Client Component)
('use client');
import { use } from 'react';
export function DashboardContent({
dataPromise,
}: {
dataPromise: Promise<DashboardData>;
}) {
const data = use(dataPromise);
return (
<div>
<span>Welcome back, {data.userName}</span>
<StatsGrid stats={data.stats} />
</div>
);
}Cache Payload Considerations
- Audit payload size to ensure cached data does not bloat the initial HTML document
- Cache only fields needed for the initial render, not entire database rows
- Set appropriate TTL based on data freshness requirements via
cacheLife - Use `cacheTag` for on-demand revalidation when data changes
Troubleshooting
| Issue | Likely Cause | Corrective Action |
|---|---|---|
| Data still mismatches | 'use cache' not applied to fetcher | Verify the directive is inside the async function |
| Large initial HTML payload | Too much data cached | Cache only fields needed for initial render |
| Stale data after mutation | No revalidation configured | Use cacheTag and revalidateTag |
use() throws during hydration | Promise rejected on server | Add Error Boundary around the consuming component |
use() suspends indefinitely | No Suspense boundary above | Wrap in <Suspense fallback={...}> |
Validation Techniques
Many hydration errors are "soft" failures that do not crash the app. React recovers by re-rendering the affected subtree, which preserves functionality but destroys performance and causes visual artifacts. These techniques detect silent hydration issues.
Production Error Monitoring with onRecoverableError
The most important validation technique. Configure hydrateRoot to report every hydration recovery event.
import { hydrateRoot } from 'react-dom/client';
const root = hydrateRoot(document.getElementById('root')!, <App />, {
onRecoverableError(error, errorInfo) {
if (error.message.includes('hydrat')) {
reportToMonitoring({
type: 'hydration-recovery',
message: error.message,
cause: (error as any).cause?.message,
componentStack: errorInfo.componentStack,
url: window.location.href,
timestamp: Date.now(),
});
}
},
onCaughtError(error, errorInfo) {
reportToMonitoring({
type: 'caught-error',
message: error.message,
componentStack: errorInfo.componentStack,
});
},
});Why this matters: In production, React does not show console warnings for hydration mismatches. Without onRecoverableError, silent hydration failures go completely undetected.
Hydration Audit Script
Run via Chrome DevTools console or browser automation to detect silent hydration warnings in development.
(function auditHydration() {
const originalError = console.error;
const hydrationErrors = [];
console.error = function (...args) {
const message = args.join(' ');
if (
message.includes('hydrat') ||
message.includes('did not match') ||
message.includes('server-rendered')
) {
hydrationErrors.push({ message, timestamp: Date.now() });
}
originalError.apply(console, args);
};
setTimeout(() => {
console.error = originalError;
if (hydrationErrors.length > 0) {
console.warn(
`AUDIT: ${hydrationErrors.length} hydration issue(s) detected`,
hydrationErrors,
);
} else {
console.log('AUDIT: Hydration clean.');
}
}, 3000);
})();Run this before page load (via DevTools snippets or injection) to capture hydration warnings that appear during the initial render.
Flash Detection via Mutation Monitoring
Monitor DOM mutations during the hydration window. A high mutation count indicates React is re-rendering subtrees to recover from mismatches.
(function detectHydrationFlash() {
let mutations = 0;
const observer = new MutationObserver((list) => {
mutations += list.length;
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
});
window.addEventListener('load', () => {
setTimeout(() => {
observer.disconnect();
const status =
mutations <= 10
? 'CLEAN'
: mutations <= 50
? 'INVESTIGATE'
: 'FIX REQUIRED';
console.log(`AUDIT: Mutation count: ${mutations} (${status})`);
}, 500);
});
})();Thresholds:
| Mutation Count | Assessment | Action |
|---|---|---|
| 0-10 | Clean hydration | No action needed |
| 11-50 | Minor mismatches | Investigate specific components |
| 51+ | Significant hydration flash | Fix required; users see visual artifacts |
Environment Simulation Checklist
Hydration errors often appear only under specific conditions. Test across these scenarios:
Timezone Testing
# Run dev server with different timezone
TZ='America/New_York' npm run dev
TZ='Asia/Tokyo' npm run dev
TZ='UTC' npm run devVerify that date-dependent components produce identical server and client output regardless of timezone.
Locale Testing
Test with browser language settings that differ from the server. Number formatting, currency symbols, and date formats vary by locale.
// Deterministic locale: pass from server metadata
function Price({
amount,
locale,
currency,
}: {
amount: number;
locale: string;
currency: string;
}) {
return (
<span>
{new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount)}
</span>
);
}Browser Extension Testing
Test with these common DOM-polluting extensions active:
| Extension | DOM Impact | Detection |
|---|---|---|
| Google Translate | Wraps text in <font> tags | Check for unexpected <font> elements |
| Dark Reader | Injects <style>, modifies inline styles | Check for injected style elements |
| Ad blockers | Remove/hide DOM elements | Check for missing expected elements |
| Password managers | Inject input overlays | Check for unexpected elements near inputs |
| Grammarly | Adds wrapper spans around text | Check for <grammarly-extension> elements |
Slow Network Simulation
Use Chrome DevTools Network throttling (Slow 3G) to reveal race conditions where the client hydrates before all resources are available.
Comparing Server HTML vs Client DOM
Open the page and compare:
1. Server HTML: view-source:http://localhost:3000/page (raw server output) 2. Client DOM: DevTools Elements panel (post-hydration state)
Differences indicate hydration mismatches. Focus on:
- Text content differences
- Missing or extra attributes
- Different element structure
- Style differences
React DevTools Profiler
Use the React DevTools Profiler to identify components that re-render during hydration:
1. Open React DevTools Profiler tab 2. Enable "Record why each component rendered" 3. Reload the page 4. Look for components that rendered with reason "Hydration mismatch"
Automated CI Validation
Integrate hydration checks into CI by capturing console errors during E2E tests.
// Playwright example
import { test, expect } from '@playwright/test';
test('no hydration errors on homepage', async ({ page }) => {
const hydrationErrors: string[] = [];
page.on('console', (msg) => {
if (msg.type() === 'error') {
const text = msg.text();
if (
text.includes('hydrat') ||
text.includes('did not match') ||
text.includes('server-rendered')
) {
hydrationErrors.push(text);
}
}
});
await page.goto('/');
await page.waitForLoadState('networkidle');
expect(hydrationErrors).toEqual([]);
});Validation Checklist
- Browser console free of
react-domhydration warnings onRecoverableErrorconfigured and reporting to monitoring- Mutation observer count below 10 for static page sections
- Input focus maintained if hydration happens while user is typing
- CSS-in-JS styles injected before first paint (no unstyled flash)
- Dates and currencies render identically on server and client
- App functions correctly with translation extensions active
- E2E tests assert zero hydration errors on critical pages
- Lighthouse Total Blocking Time within acceptable range