why-hydration 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,29 +2,36 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/why-hydration.svg)](https://www.npmjs.com/package/why-hydration)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/why-hydration.svg)](https://www.npmjs.com/package/why-hydration)
5
- [![minzipped size](https://img.shields.io/bundlephobia/minzip/why-hydration.svg)](https://bundlephobia.com/package/why-hydration)
5
+ [![CI](https://github.com/razan-aboushi/why-hydration/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/razan-aboushi/why-hydration/actions/workflows/ci.yml)
6
+ [![prod bundle: 99 B gzipped](https://img.shields.io/badge/prod%20bundle-99%20B%20gzipped-brightgreen)](#production-behavior)
7
+ [![node: >=18](https://img.shields.io/node/v/why-hydration)](#install)
8
+ [![license: MIT](https://img.shields.io/npm/l/why-hydration.svg)](LICENSE)
6
9
 
7
- 📦 **npm:** https://www.npmjs.com/package/why-hydration
10
+ 📦 **npm:** https://www.npmjs.com/package/why-hydration  ·  🐙 **GitHub:** https://github.com/razan-aboushi/why-hydration  ·  💼 **Author:** [Razan Aboushi](https://www.linkedin.com/in/razan-aboushi/)
8
11
 
9
12
  **Tells you which component broke hydration, what differed, and how to fix it — in dev, with zero production cost.**
10
13
 
11
- A **React hydration error / hydration mismatch debugger** for **Next.js** (App
12
- Router & Pages Router), **Remix**, **Vite**, and any React SSR app. It points at
13
- the exact **component, file, and value** behind "Hydration failed because the
14
- server rendered HTML didn't match the client" — and tells you how to fix it.
14
+ A **React hydration mismatch debugger** for **Next.js** (App Router & Pages
15
+ Router), **Vite**, **CRA**, **Remix**, and any React app that hydrates
16
+ server-rendered HTML. It points at the exact **component, source file/line,
17
+ and value** behind a hydration warning and explains the likely root cause and
18
+ the fix — instead of leaving you to bisect the tree by hand.
15
19
 
16
20
  <p align="center">
17
21
  <img src="docs/screenshots/overlay-hero.png" alt="why-hydration overlay showing classified React hydration mismatches in a Next.js app with component names" width="440">
18
22
  </p>
19
23
 
20
- React's hydration warnings tell you _that_ something mismatched, rarely _which
21
- component_, _which value_, or _why_. `why-hydration` diffs the server DOM against
22
- the client DOM, classifies the root cause, and hands you a specific fix.
24
+ React's own hydration warning tells you *that* something mismatched, and
25
+ sometimes prints a diff — but not which component owns it, not a plain-English
26
+ root cause, and not a fix. `why-hydration` captures the server-rendered HTML
27
+ before hydration, compares it against the live DOM and against React's own
28
+ warning, classifies the mismatch into a known cause, and reports all of that
29
+ together.
23
30
 
24
31
  ```text
25
32
  ▸ React: Warning: Text content did not match. Server: "١٢٣٤" Client: "1234"
26
33
 
27
- ▸ why-hydration: ⬡ locale-format (92%) — PriceTag · body > main > span:nth-child(2)
34
+ ▸ why-hydration: ⬡ locale-format (92%) — <PriceTag> · body > main > span:nth-child(2)
28
35
  Server: ١٢٣٤ Client: 1234
29
36
  Why: Same value, different digit scripts (Arabic-Indic vs Latin).
30
37
  The server and client resolved to different locales.
@@ -32,33 +39,84 @@ the client DOM, classifies the root cause, and hands you a specific fix.
32
39
  or format the value after mount.
33
40
  ```
34
41
 
35
- - 🔍 **Where** — the **component name** (e.g. `<PriceTag>`) and **source file:line**,
36
- read from React's own fiber/diff, plus the exact DOM node + selector path.
37
- - 🔀 **What** — server value vs client value, side by side.
38
- - 🧠 **Why** — the cause, classified into a known category with a confidence score.
39
- - 🛠️ **Fix** — a specific, actionable suggestion with a docs link.
40
- - 🧹 **Low noise** — skips third-party/extension injections (ads, consent, chat,
41
- Grammarly) so you see _your_ bug, not their DOM.
42
- - 🫧 **Zero prod cost** — everything is gated on `process.env.NODE_ENV` and
43
- tree-shakes to a **no-op** (~0 B, size-budgeted in CI).
44
- - 🌐 **Arabic-first** — digit-script mismatches (٠١٢ vs 012) are a first-class cause.
42
+ > React's actual hydration warning format differs by version (some print an
43
+ > explicit `Server: … Client: …` message, newer versions print a JSX diff with
44
+ > `+`/`-` lines). `why-hydration` parses both.
45
+
46
+ - 🔍 **Where** — the **component name** (e.g. `<PriceTag>`) and, when
47
+ available, its **source file:line**, read from React's own fiber tree and
48
+ hydration diff, plus the DOM node's selector path.
49
+ - 🔀 **What** — the server-rendered value vs. the client-rendered value, side
50
+ by side.
51
+ - 🧠 **Why** — the likely cause, classified into one of nine categories with a
52
+ confidence score.
53
+ - 🛠️ **Fix** — a specific, actionable suggestion with a link to a fuller
54
+ explanation.
55
+ - 📋 **All of them, once** — every mismatch on the page is collected and
56
+ deduplicated, not just the first one found.
57
+ - 🧹 **Low noise** — skips DOM nodes injected by third-party scripts and
58
+ browser extensions (ads, consent banners, chat widgets, Grammarly) and
59
+ content inside a still-loading Suspense boundary, so you see *your* bug, not
60
+ incidental page noise.
61
+ - 🌐 **Arabic-first locale detection** — digit-script mismatches (٠١٢ vs 012)
62
+ are a first-class cause, not an afterthought.
63
+ - 🔤 **RTL-safe overlay** — the overlay always renders left-to-right, even on
64
+ pages with `<html dir="rtl">`, since its content (paths, values, code) is
65
+ English.
66
+ - 🫧 **Zero production cost** — every code path is gated behind
67
+ `process.env.NODE_ENV`, and CI fails the build if the production bundle for
68
+ any entry point isn't tree-shaken to a no-op.
69
+
70
+ Output goes to an in-browser **overlay**, a grouped **console** message, and
71
+ an **`onReport`** callback so you can pipe reports to your own logging.
72
+ Supports **React 18 and 19**.
45
73
 
46
- Output goes to an in-browser **overlay**, structured **console** output, and an
47
- **`onReport`** callback so you can pipe it to your own logging. React 18 & 19.
74
+ ---
75
+
76
+ ## Table of contents
77
+
78
+ - [Install](#install)
79
+ - [Quick setup](#quick-setup)
80
+ - [What is a hydration mismatch](#what-is-a-hydration-mismatch)
81
+ - [What why-hydration detects](#what-why-hydration-detects)
82
+ - [How detection works](#how-detection-works)
83
+ - [What you will see](#what-you-will-see)
84
+ - [RTL and Arabic support](#rtl-and-arabic-support)
85
+ - [Detection scope](#detection-scope)
86
+ - [Options and API](#options-and-api)
87
+ - [Report structure](#report-structure)
88
+ - [Cause categories](#cause-categories)
89
+ - [Production behavior](#production-behavior)
90
+ - [Privacy and performance](#privacy-and-performance)
91
+ - [Troubleshooting](#troubleshooting)
92
+ - [Contributing](#contributing)
93
+ - [License](#license)
48
94
 
49
95
  ---
50
96
 
51
97
  ## Install
52
98
 
53
99
  ```bash
54
- npm i -D why-hydration
100
+ npm install --save-dev why-hydration
55
101
  ```
56
102
 
57
- `react` and `react-dom` are peer dependencies (`^18 || ^19`).
103
+ `react` and `react-dom` are peer dependencies (`^18.0.0 || ^19.0.0`). The
104
+ package has **zero runtime dependencies** of its own. Building from source
105
+ requires Node.js 18+; the published package has no Node-version requirement of
106
+ its own beyond what your framework already needs.
107
+
108
+ ---
109
+
110
+ ## Quick setup
58
111
 
59
- ## 30-second setup
112
+ Pick the setup that matches how your app hydrates. In every case:
113
+ `<HydrationInspector>` (or `createHydrationInspector`) starts detection, and a
114
+ snapshot script must run **before** hydration so the server-rendered HTML can
115
+ be compared against it. Skipping the snapshot script still works — detection
116
+ falls back to parsing React's own warning — but you lose the precise DOM-level
117
+ diff for attribute and text mismatches that the DOM path can catch.
60
118
 
61
- ### Next.js — App Router
119
+ ### Next.js App Router
62
120
 
63
121
  ```tsx
64
122
  // app/layout.tsx
@@ -79,7 +137,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
79
137
  }
80
138
  ```
81
139
 
82
- ### Next.js — Pages Router
140
+ ### Next.js Pages Router
83
141
 
84
142
  ```tsx
85
143
  // pages/_document.tsx → inside <Head>
@@ -98,7 +156,10 @@ export default function App({ Component, pageProps }) {
98
156
  }
99
157
  ```
100
158
 
101
- ### Vite / CRA / Remix (you own `hydrateRoot`)
159
+ ### Vite, CRA, Remix, and custom `hydrateRoot`
160
+
161
+ Use this path for any setup where **you** call `hydrateRoot` — the tool wires
162
+ into React's `onRecoverableError` hook for richer detection context.
102
163
 
103
164
  ```tsx
104
165
  import { hydrateRoot } from 'react-dom/client';
@@ -106,13 +167,17 @@ import { createHydrationInspector } from 'why-hydration/react';
106
167
 
107
168
  const inspector = createHydrationInspector();
108
169
 
109
- hydrateRoot(document.getElementById('root')!, <App />, {
110
- onRecoverableError: inspector.onRecoverableError,
111
- });
170
+ hydrateRoot(
171
+ document.getElementById('root')!,
172
+ <inspector.Provider>
173
+ <App />
174
+ </inspector.Provider>,
175
+ { onRecoverableError: inspector.onRecoverableError },
176
+ );
112
177
  ```
113
178
 
114
- Add the snapshot script to your HTML `<head>` so the server DOM is captured
115
- before hydration (Vite `index.html`, before your entry `<script>`). Copy-paste:
179
+ Add the snapshot script to your HTML `<head>`, before your entry `<script>`
180
+ (e.g. Vite's `index.html`):
116
181
 
117
182
  ```html
118
183
  <head>
@@ -143,50 +208,241 @@ before hydration (Vite `index.html`, before your entry `<script>`). Copy-paste:
143
208
  </head>
144
209
  ```
145
210
 
146
- > Prefer to generate it? `import { getSnapshotScriptSource } from 'why-hydration'`
147
- > returns exactly this string. Without the snapshot script the tool still works
148
- > from React's console warnings, but the precise DOM value/attribute diff needs
149
- > it.
211
+ > Prefer to generate that script rather than copy-paste it?
212
+ > `import { getSnapshotScriptSource } from 'why-hydration'` returns exactly
213
+ > that string, so you can inject it from your own build tooling and pass a
214
+ > custom list of root selectors.
215
+
216
+ Runnable examples for a Next.js App Router setup and a Vite setup live in
217
+ [`examples/`](examples).
218
+
219
+ ---
220
+
221
+ ## What is a hydration mismatch
222
+
223
+ Server-side rendering (SSR) generates HTML on the server and sends it to the
224
+ browser, which the browser paints immediately. React then **hydrates** that
225
+ HTML: it re-renders the same component tree on the client and attaches event
226
+ handlers to the *existing* DOM nodes instead of replacing them, on the
227
+ assumption that the client render produces the exact same markup the server
228
+ sent.
229
+
230
+ A **hydration mismatch** happens when that assumption is false — the client's
231
+ first render produces different text, a different attribute value, or a
232
+ different tree shape than what the server sent. Common root causes are values
233
+ that are non-deterministic (`Math.random()`, `Date.now()`), values that depend
234
+ on the browser environment (`window`, `localStorage`, viewport width) but not
235
+ on the server, or formatting that differs because the server and client
236
+ resolved a different locale or timezone. React logs a warning to the console
237
+ when it detects this and, depending on what mismatched, may or may not repair
238
+ the DOM to match. `why-hydration` exists to make that warning actionable.
150
239
 
151
240
  ---
152
241
 
153
- ## What you'll see
242
+ ## What why-hydration detects
243
+
244
+ For every hydration mismatch on the page, `why-hydration` reports:
245
+
246
+ - **Where** — the component name (best-effort, from React's fiber tree) and
247
+ its source file/line when your build includes that debug information, plus
248
+ a CSS-selector-style path to the DOM node.
249
+ - **What** — the server-rendered value and the client-rendered value, shown
250
+ side by side, whether that's text content, an attribute (including `class`
251
+ and `style`, which React does not patch into the live DOM — see
252
+ [How detection works](#how-detection-works)), or a structural change
253
+ (a node added, removed, or swapped).
254
+ - **Why** — the mismatch classified into one of the [cause categories](#cause-categories)
255
+ below, with a confidence score, or `unknown` if no rule matches.
256
+ - **The fix** — a specific, actionable suggestion, plus a link to a fuller
257
+ explanation for that category.
258
+
259
+ It collects **every** mismatch found on a page load, deduplicates identical
260
+ ones, and reports them together rather than one at a time across repeated
261
+ refreshes.
262
+
263
+ It also actively avoids reporting things that are not your bug: DOM nodes
264
+ injected by third-party scripts or browser extensions after the server
265
+ render (ad frames, consent banners, analytics/chat widgets, Grammarly),
266
+ content inside a Suspense boundary that is still loading on the server, and
267
+ React/Next.js's own internal markup markers.
268
+
269
+ ---
270
+
271
+ ## How detection works
272
+
273
+ 1. **Snapshot.** A tiny inline script (`<HydrationSnapshotScript>`, or the
274
+ manual `<script>` for non-Next setups) captures each hydration root's
275
+ server-rendered `innerHTML` before hydration runs — specifically at the
276
+ point `document.readyState` becomes `"interactive"`, which happens before
277
+ deferred scripts execute.
278
+ 2. **Detect.** Two signals run together: a dev-only `console.error`
279
+ interceptor recognizes React's own hydration warnings and works in every
280
+ setup, including Next.js, where you don't call `hydrateRoot` yourself; and
281
+ `onRecoverableError`, which you wire up manually when you do own
282
+ `hydrateRoot` (Vite/CRA/Remix), adds the component stack React captured
283
+ for the mismatch.
284
+ 3. **Diff.** Once hydration has settled — the engine re-checks a few times
285
+ over roughly 1.5 seconds, because React can apply corrected values to a
286
+ mismatched subtree slightly after the initial commit — it walks the frozen
287
+ server snapshot against the live DOM. Children are aligned with a
288
+ longest-common-subsequence match (not by index), so a node injected
289
+ mid-tree by a third-party script doesn't shift the comparison and
290
+ misattribute every following sibling. This is a bounded check, not a
291
+ standing `MutationObserver`, so legitimate DOM changes from your app's own
292
+ state updates after this window are never mistaken for a hydration issue.
293
+
294
+ The passes are also bounded in cost. React logs its warnings in bursts, so
295
+ every signal that lands in the same frame is coalesced into **one** diff
296
+ rather than one diff each, and the captured server markup — re-materialising
297
+ it is a full HTML parse of your server render — is parsed **once per root**
298
+ and reused across every pass. Scheduling races an animation frame against a
299
+ 50 ms timer, so a page that is hidden at load (where the browser suspends
300
+ `requestAnimationFrame` entirely) still gets inspected.
301
+ 4. **Classify.** Each divergence is passed through an ordered list of rules
302
+ (see [Cause categories](#cause-categories)); the first rule whose
303
+ confidence clears the threshold wins, otherwise the mismatch is reported as
304
+ `unknown`. Custom rules passed via the `classify` option run before the
305
+ built-in ones.
306
+ 5. **Locate.** For mismatches the DOM diff found, the component name and
307
+ source location are read from the live DOM node's React fiber. `class` and
308
+ `style` mismatches are only visible in React's own warning — React
309
+ reconciles them silently without patching the DOM — so those are parsed
310
+ from the warning text instead, including the component name printed there.
311
+ 6. **Report.** Every unique mismatch (deduplicated by kind, attribute,
312
+ category, and value — not by DOM path, so the same logical mismatch found
313
+ via two signals is reported once) is sent to the overlay, a grouped console
314
+ message, and your `onReport` callback, up to `maxReports` (default 25).
315
+
316
+ ---
317
+
318
+ ## What you will see
319
+
320
+ When a mismatch is detected in dev, you get up to three things (all inert in
321
+ production):
322
+
323
+ 1. **An in-page overlay** — a dismissible panel in the corner of the screen
324
+ with one card per mismatch: category, confidence, server vs. client
325
+ values, a plain-English explanation, and the fix. The panel scrolls when
326
+ there are more mismatches than fit, with a "scroll to see all" hint.
327
+ 2. **A grouped console message** — the same information, formatted for easy
328
+ copy-paste into an issue or chat.
329
+ 3. **Your `onReport` callback**, if you passed one — the full structured
330
+ `HydrationReport` object for each mismatch.
331
+
332
+ A page with no mismatches renders **nothing** — no overlay, no console output.
154
333
 
155
- When a mismatch happens in dev, you get three things (all off in production):
334
+ ### Controlling the overlay
156
335
 
157
- 1. **An overlay** in the corner — one card per mismatch with the category,
158
- confidence, server vs client values, why it happened, and the fix. Dismissible.
159
- 2. **A grouped console block** — the same info, easy to copy into an issue.
160
- 3. **Your `onReport` callback** (if provided) — the structured `HydrationReport`.
336
+ | Action | Effect |
337
+ | ------ | ------ |
338
+ | **Dismiss** button, or **Esc** | Removes the panel for the rest of the page load. |
339
+ | **✕** on the hint bar | Closes just the "scroll to see all" hint; the panel stays. |
340
+ | `overlay={false}` | Never mounts it at all — `onReport` and the console output still work. |
341
+ | `overlay={{ position }}` | `bottom-right` (default), `bottom-left`, `top-right`, `top-left`. |
161
342
 
162
- A clean page with no mismatches shows **nothing** — no overlay, no logs.
343
+ The overlay is a *view* over the collected reports, not the collector itself:
344
+ dismissing it does not stop detection, and `onReport` keeps firing. If a
345
+ mismatch is found after you dismissed it — a late signal, or a second hydration
346
+ error — the panel returns showing that mismatch, starting from a clean count
347
+ rather than resuming a stale one.
348
+
349
+ It renders in an isolated Shadow DOM, is never part of your app's tree, and is
350
+ excluded from its own diff, so it can never be mistaken for a mismatch.
351
+ Mismatched values are rendered as **text**, and "Learn more" links are
352
+ restricted to `http(s)` URLs, so nothing in a mismatched value can inject markup
353
+ or script into the panel.
354
+
355
+ ### In a real app
356
+
357
+ Captured from a production Next.js app: every mismatch on the page collected
358
+ together, each with its component and source location, and a scroll hint when
359
+ there are more than fit:
360
+
361
+ <p align="center">
362
+ <img src="docs/screenshots/real-serp-facets.png" alt="why-hydration overlay on a real Next.js app: a Facets attribute-mismatch (forceHide class) with source components/OSLink/index.tsx:25, a Browser-only API report, and a '25 issues — scroll to see all' hint" width="370">
363
+ &nbsp;&nbsp;
364
+ <img src="docs/screenshots/real-gallery-bullets.png" alt="why-hydration overlay on a real Next.js app: an ImageGalleryBullets attribute-mismatch with styled-components class names and source Bullets/index.tsx:22, plus a Viewport branching report" width="370">
365
+ </p>
163
366
 
164
367
  ---
165
368
 
166
- ## When it detects (full load vs client navigation)
369
+ ## RTL and Arabic support
370
+
371
+ An Arabic app gets the same diagnosis quality as an English one. That covers
372
+ both how the overlay renders and what the engine can actually detect.
373
+
374
+ ### The overlay
375
+
376
+ - The overlay's own layout **always renders left-to-right**, on any page. Its
377
+ content — file paths, DOM selectors, code values, category names — is
378
+ English, so keeping it LTR keeps it readable regardless of the host page's
379
+ direction.
380
+ - This is automatic and needs no configuration. The overlay lives in an
381
+ isolated Shadow DOM, sets `direction: ltr` on both `:host` and the panel, and
382
+ carries a `dir="ltr"` attribute as well — belt and braces, because the CSS
383
+ `all` shorthand deliberately excludes `direction` (per spec), so
384
+ `:host { all: initial }` alone would still let `direction: rtl` leak in and
385
+ flip the server/client diff columns.
386
+ - Values are rendered as **text**, so Arabic, Hebrew and mixed bidi content
387
+ display intact inside the LTR panel without reordering the surrounding
388
+ layout.
389
+
390
+ ### Detection
391
+
392
+ Detection is direction-agnostic: every rule matches on the *shape* of a value,
393
+ not its script. Three Arabic-specific cases are worth calling out, because the
394
+ first is the one most people expect and the other two are the ones that
395
+ actually bite:
396
+
397
+ - **Different digit scripts.** Arabic-Indic `٠١٢` on one side, Latin `012` on
398
+ the other — the classic symptom of `Intl`/`toLocaleString` resolving to a
399
+ different locale on the server than in the browser. Reported as
400
+ [`locale-format`](#cause-locale-format) at 92% confidence.
401
+ - **Same digit script, different formatting.** An Arabic-first app renders
402
+ Arabic-Indic digits on *both* sides, so there is no script difference to key
403
+ off — only a grouping separator (`١٬٤٠٠` vs `١٤٠٠`), a decimal separator
404
+ (`١٢٣٤٫٥٦` vs `١٢٣٤.٥٦`), a field order, or a time. These are folded to Latin
405
+ before the numeric/date shape tests run, so they are classified exactly like
406
+ their English equivalents instead of falling through to `unknown`. Persian /
407
+ Extended Arabic-Indic digits (`۰۱۲`) are handled the same way.
408
+ - **Invisible bidi marks.** `Intl` wraps numbers and date fields in
409
+ bidirectional control characters (LRM, RLM, ALM, isolates) in RTL locales,
410
+ and *which* ones it emits differs between ICU versions — so Node and the
411
+ browser routinely format the same date into strings that are visually
412
+ identical and byte-different. This is the hardest hydration mismatch to debug
413
+ by eye, since the console diff looks like the same text twice. It is detected
414
+ and named explicitly.
415
+
416
+ Both directions are covered by the test suite as a matched pair
417
+ (`test/i18n.test.tsx`), plus overlay directionality in `test/direction.test.ts`,
418
+ so English and Arabic behaviour cannot drift apart.
419
+
420
+ ---
167
421
 
168
- Hydration mismatches happen **only during the initial server render + hydration**
169
- — i.e. a **full page load, refresh, or direct URL entry** to an SSR'd route.
170
- `why-hydration` detects those.
422
+ ## Detection scope
171
423
 
172
- **Client-side navigation** (Next.js `<Link>` / `router.push`, React Router) does
173
- **not** re-hydrate — the destination is rendered entirely on the client, so there
174
- is no server HTML to diverge from and **no hydration mismatch can occur**. This is
175
- React's design, not a limitation. To reproduce a mismatch on a specific route,
176
- **refresh that route** (or open its URL directly).
424
+ Hydration mismatches can only happen during the **initial server render and
425
+ hydration** of a page — a full page load, a browser refresh, or a direct URL
426
+ visit to a server-rendered route. `why-hydration` detects those.
177
427
 
178
- Detection covers both React's console warning path and a real server-vs-client
179
- DOM diff, and re-checks across a short settling window (React applies client
180
- values to mismatched subtrees a few hundred ms after hydration), so time- and
181
- render-order-dependent mismatches are caught reliably.
428
+ **Client-side navigation** (Next.js `<Link>` / `router.push`, React Router,
429
+ etc.) does not re-hydrate the destination page — it is rendered entirely on
430
+ the client, so there is no server HTML for it to diverge from, and no
431
+ hydration mismatch can occur there. This is how React's hydration model works,
432
+ not a limitation of this tool. To check a specific route, load it directly or
433
+ refresh it.
182
434
 
183
435
  ---
184
436
 
185
- ## Options
437
+ ## Options and API
438
+
439
+ ### `<HydrationInspector>`
440
+
441
+ Wrap your app (or, for Next.js, use the `why-hydration/next` re-export).
186
442
 
187
443
  ```tsx
188
444
  <HydrationInspector
189
- overlay // boolean | { position: 'bottom-right' | ... } (default: on in dev)
445
+ overlay // boolean | OverlayOptions (default: true — shown in dev)
190
446
  onReport={(report) => sendToLogging(report)}
191
447
  ignore={['.grammarly-ext', (node) => node.hasAttribute('data-safe')]}
192
448
  classify={[myCustomRule]} // extra classifiers, run before the built-ins
@@ -196,41 +452,97 @@ render-order-dependent mismatches are caught reliably.
196
452
  </HydrationInspector>
197
453
  ```
198
454
 
199
- | Option | Type | Default | Description |
200
- | ------------ | --------------------------------------------------- | -------------- | -------------------------------------------------------- |
201
- | `overlay` | `boolean \| OverlayOptions` | `true` in dev | In-browser panel. `false` to disable. |
202
- | `onReport` | `(report: HydrationReport) => void` | – | Called once per unique report. |
203
- | `ignore` | `Array<string \| (node: Element) => boolean>` | `[]` | Suppress known-safe mismatches by selector or predicate. |
204
- | `classify` | `Classifier[]` | `[]` | Custom rules, run **before** the built-ins. |
205
- | `maxReports` | `number` | `25` | Cap on unique reports. |
455
+ | Option | Type | Default | Description |
456
+ | ------------ | ----------------------------------------------- | ------------- | --------------------------------------------------------- |
457
+ | `overlay` | `boolean \| OverlayOptions` | `true` in dev | The in-browser panel. Pass `false` to disable it entirely, or an `OverlayOptions` object to configure it. |
458
+ | `onReport` | `(report: HydrationReport) => void` | – | Called once per unique report. |
459
+ | `ignore` | `Array<string \| (node: Element) => boolean>` | `[]` | Suppress known-safe mismatches by CSS selector or predicate, matched against the live DOM node. |
460
+ | `classify` | `Classifier[]` | `[]` | Custom classification rules, run **before** the built-in ones. |
461
+ | `maxReports` | `number` | `25` | Cap on the number of unique reports collected per page load. |
462
+
463
+ ### `createHydrationInspector(options)`
464
+
465
+ For Vite/CRA/Remix, where you call `hydrateRoot` yourself. Accepts the same
466
+ options as `<HydrationInspector>`, plus:
467
+
468
+ | Option | Type | Default | Description |
469
+ | ------- | ---------- | ---------------------------------------------------- | ----------- |
470
+ | `roots` | `string[]` | the selectors captured by the snapshot, or `['#root', '#__next', 'body']` | Override which root selectors are diffed against the snapshot. |
471
+
472
+ Returns:
473
+
474
+ ```ts
475
+ interface HydrationInspectorHandle {
476
+ onRecoverableError: (error: unknown, info?: { componentStack?: string }) => void;
477
+ Provider: (props: { children?: React.ReactNode }) => React.ReactElement;
478
+ }
479
+ ```
480
+
481
+ `Provider` owns the inspector's lifetime: it must actually be mounted, and
482
+ unmounting it tears the inspector down (overlay removed, `console.error`
483
+ handed back untouched). Mounting it again restarts detection cleanly rather
484
+ than stacking a second overlay or a second console patch — which is what makes
485
+ it safe under `<React.StrictMode>`, where React deliberately runs every mount
486
+ effect setup → cleanup → setup.
487
+
488
+ ### `OverlayOptions`
489
+
490
+ ```ts
491
+ interface OverlayOptions {
492
+ position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; // default 'bottom-right'
493
+ }
494
+ ```
495
+
496
+ ### `<HydrationSnapshotScript>`
497
+
498
+ ```ts
499
+ interface HydrationSnapshotScriptProps {
500
+ selectors?: string[]; // default ['#root', '#__next', 'body']
501
+ nonce?: string; // forwarded to the inline <script>, for a CSP nonce
502
+ }
503
+ ```
504
+
505
+ Renders `null` outside development.
506
+
507
+ ### Core engine (`why-hydration`)
508
+
509
+ The framework-agnostic entry (`import ... from 'why-hydration'`) exposes the
510
+ diff engine, classifier, and report collector directly — useful if you're
511
+ building a custom integration or writing a custom `classify` rule. See
512
+ [`src/index.ts`](src/index.ts) for the full export list, including `classify`,
513
+ `ReportCollector`, `diffSnapshotAgainstDom`, and the `Classifier` type.
206
514
 
207
515
  ---
208
516
 
209
- ## The `HydrationReport`
517
+ ## Report structure
210
518
 
211
519
  ```ts
212
520
  interface HydrationReport {
213
521
  id: string;
214
522
  timestamp: number;
215
- component?: string;
216
- componentStack?: string;
217
- location?: { file?: string; line?: number; column?: number };
523
+ component?: string; // best-effort component name, e.g. "PriceTag"
524
+ componentStack?: string; // from onRecoverableError, when available
525
+ location?: {
526
+ file?: string;
527
+ line?: number;
528
+ column?: number;
529
+ };
218
530
  node: {
219
- path: string; // selector-style path to the node
531
+ path: string; // CSS-selector-style path to the DOM node
220
532
  tagName?: string;
221
- attribute?: string;
533
+ attribute?: string; // set for attribute mismatches
222
534
  kind: 'text' | 'attribute' | 'structure' | 'node-added' | 'node-removed';
223
535
  };
224
536
  server: string | null;
225
537
  client: string | null;
226
538
  cause: {
227
- category: HydrationCauseCategory;
228
- confidence: number; // 0–1
539
+ category: HydrationCauseCategory; // one of the categories below, or "unknown"
540
+ confidence: number; // 0–1
229
541
  explanation: string;
230
542
  suggestion: string;
231
543
  docsUrl?: string;
232
544
  };
233
- raw?: { reactMessage?: string };
545
+ raw?: { reactMessage?: string }; // the original React console message, if any
234
546
  }
235
547
  ```
236
548
 
@@ -238,13 +550,14 @@ interface HydrationReport {
238
550
 
239
551
  ## Cause categories
240
552
 
241
- The classifier runs an ordered list of rules; the first match above a confidence
242
- threshold wins, otherwise `unknown`. Custom `classify` rules run first.
553
+ The classifier runs the rules below **in this exact order**; the first rule
554
+ whose confidence clears the threshold (0.5) wins, otherwise the mismatch is
555
+ reported as `unknown`. Custom rules passed via the `classify` option run
556
+ before all of these.
243
557
 
244
- > **What is the "Learn more →" link?** Every report (overlay + console) carries a
245
- > `docsUrl` that deep-links to the matching section **below** (e.g. the locale
246
- > report links to [`#cause-locale-format`](#cause-locale-format)). Each section
247
- > also cites the authoritative React/MDN reference for that cause.
558
+ > **What is the "Learn more →" link?** Every report (overlay and console)
559
+ > carries a `docsUrl` that deep-links to the matching section below (e.g. the
560
+ > locale-format report links to [`#cause-locale-format`](#cause-locale-format)).
248
561
 
249
562
  <a id="cause-non-deterministic-value"></a>
250
563
 
@@ -264,10 +577,18 @@ Server and client rendered different random-looking values (UUID, token, React
264
577
 
265
578
  <img src="docs/screenshots/cause-date-time.png" alt="date-time report" width="420">
266
579
 
267
- Values are dates/times that differ by a small delta — the clock or timezone moved
268
- between server and client render.
269
- **Fix:** render time after mount, or pass one server timestamp down and pin the
270
- timezone when formatting.
580
+ Values are dates/times that differ by a small delta — the clock or timezone
581
+ moved between server and client render.
582
+
583
+ Matched on **shape**, not on whether `Date.parse` happens to accept the value:
584
+ ISO dates, `D/M/YYYY`-style dates, clock times, month names with a number, and
585
+ 10–13 digit epoch timestamps. That distinction matters because `Date.parse` is
586
+ extremely permissive — it reads `100` as the year 100 and `server-0` as the
587
+ year 2000 — so an ordinary price, count, or id would otherwise be diagnosed as
588
+ a clock drift.
589
+
590
+ **Fix:** render time after mount, or pass one server timestamp down and pin
591
+ the timezone when formatting.
271
592
  **Reference:** [React — different client/server content](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
272
593
 
273
594
  <a id="cause-locale-format"></a>
@@ -276,32 +597,60 @@ timezone when formatting.
276
597
 
277
598
  <img src="docs/screenshots/cause-locale-format.png" alt="locale-format report" width="420">
278
599
 
279
- Same underlying value, different formatting: **Arabic-Indic ٠١٢ vs Latin 012**,
280
- decimal/thousand separators (`1,234.56` vs `1.234,56`), or date order (MM/DD vs
281
- DD/MM).
282
- **Fix:** pass an explicit `locale` + timezone to `Intl` on both sides, or format
283
- after mount.
600
+ Same underlying value, different formatting. Covers:
601
+
602
+ - **Different digit scripts** — Arabic-Indic `٠١٢` vs Latin `012`, or Persian
603
+ `۰۱۲` vs Latin.
604
+ - **Different separators or field order** — `1,234.56` vs `1.234,56`,
605
+ `١٬٤٠٠` vs `١٤٠٠`, MM/DD vs DD/MM. Detected in Arabic-Indic and Persian
606
+ digits as well as Latin, so an app that renders the same digit script on both
607
+ sides is still diagnosed.
608
+ - **Invisible bidirectional marks** — values that are identical on screen but
609
+ differ by LRM/RLM/ALM or isolate characters, which `Intl` adds around numbers
610
+ and dates in RTL locales and which different ICU versions (Node vs the
611
+ browser) emit differently.
612
+
613
+ **Fix:** pass an explicit `locale` and timezone to `Intl` on both sides, or
614
+ format after mount. See also [RTL and Arabic support](#rtl-and-arabic-support).
284
615
  **Reference:** [MDN `Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat).
285
616
 
617
+ <a id="cause-third-party-dom-mutation"></a>
618
+
619
+ ### third-party-dom-mutation
620
+
621
+ <img src="docs/screenshots/cause-third-party-dom-mutation.png" alt="third-party-dom-mutation report" width="420">
622
+
623
+ Either an attribute was injected by a browser extension (Grammarly, ColorZilla,
624
+ …) or an early third-party script before hydration, or an entire node — an ad
625
+ iframe, a consent banner, an analytics or chat widget — was mounted by a
626
+ third-party script after the server render.
627
+ **Fix:** usually harmless. Add `suppressHydrationWarning` to the affected
628
+ element or its nearest server-rendered wrapper, or defer third-party script
629
+ initialization until after hydration (e.g. Next.js's
630
+ `<Script strategy="afterInteractive">`).
631
+ **Reference:** [React `suppressHydrationWarning`](https://react.dev/reference/react-dom/components/common#suppressing-unavoidable-hydration-mismatch-errors).
632
+
286
633
  <a id="cause-browser-only-api"></a>
287
634
 
288
635
  ### browser-only-api
289
636
 
290
637
  <img src="docs/screenshots/cause-browser-only-api.png" alt="browser-only-api report" width="420">
291
638
 
292
- The client rendered content the server left empty — a read of `window`,
293
- `document`, `localStorage`, `navigator`, or `matchMedia` during render.
294
- **Fix:** gate behind a mounted flag / `useEffect`, or use `useSyncExternalStore`
295
- with a server snapshot.
639
+ The client rendered content the server left empty — the signature of reading
640
+ `window`, `document`, `localStorage`, `navigator`, or `matchMedia` during
641
+ render.
642
+ **Fix:** gate behind a mounted flag or `useEffect`, or use
643
+ `useSyncExternalStore` with a server snapshot.
296
644
  **Reference:** [React `useSyncExternalStore` (server rendering)](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
297
645
 
298
646
  <a id="cause-viewport-branching"></a>
299
647
 
300
648
  ### viewport-branching
301
649
 
302
- A whole subtree was added/removed/swapped — usually a JS width check that
303
- branches the tree at first render.
304
- **Fix:** switch with CSS media queries at first paint, not JavaScript.
650
+ A whole subtree was added, removed, or swapped — typically a JavaScript width
651
+ or viewport check that branches the tree at first render.
652
+ **Fix:** switch with CSS media queries at first paint instead of branching in
653
+ JavaScript.
305
654
  **Reference:** [MDN — CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries).
306
655
 
307
656
  <a id="cause-invalid-html-nesting"></a>
@@ -310,141 +659,192 @@ branches the tree at first render.
310
659
 
311
660
  <img src="docs/screenshots/cause-invalid-html-nesting.png" alt="invalid-html-nesting report" width="420">
312
661
 
313
- A node was moved/ejected because the markup is invalid (`<div>` in `<p>`, nested
314
- `<a>`). The browser repairs the server DOM so it no longer matches React.
315
- **Fix:** correct the markup validity.
662
+ A node was moved or ejected because the markup is invalid HTML (e.g. a `<div>`
663
+ inside a `<p>`, or a nested `<a>`). The browser repairs the server-rendered DOM
664
+ so it no longer matches what React expects.
665
+ **Fix:** correct the markup validity — block elements cannot live inside
666
+ `<p>`, anchors cannot nest, etc.
316
667
  **Reference:** [MDN — `<p>` (permitted content)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p).
317
668
 
318
669
  <a id="cause-whitespace-minification"></a>
319
670
 
320
671
  ### whitespace-minification
321
672
 
322
- The mismatch is whitespace-only — an HTML minifier collapsed whitespace around
323
- the root differently from React.
673
+ The mismatch is whitespace-only — the text is identical apart from
674
+ spaces/newlines. An HTML minifier likely collapsed whitespace around the
675
+ hydration root differently from React.
324
676
  **Fix:** check your minifier settings (e.g. `conservativeCollapse`) around the
325
677
  app root.
326
678
  **Reference:** [React — text content hydration](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
327
679
 
328
- <a id="cause-third-party-dom-mutation"></a>
329
-
330
- ### third-party-dom-mutation
331
-
332
- <img src="docs/screenshots/cause-third-party-dom-mutation.png" alt="third-party-dom-mutation report" width="420">
333
-
334
- An attribute was injected by a browser extension (Grammarly, ColorZilla, …) or an
335
- early third-party script before hydration.
336
- **Fix:** usually harmless — add `suppressHydrationWarning` to the leaf, or defer
337
- third-party init to post-hydration.
338
- **Reference:** [React `suppressHydrationWarning`](https://react.dev/reference/react-dom/components/common#suppressing-unavoidable-hydration-mismatch-errors).
339
-
340
680
  <a id="cause-attribute-mismatch"></a>
341
681
 
342
682
  ### attribute-mismatch
343
683
 
344
- A `class`, `style`, or other attribute differs between server and client — the
345
- report lists the exact tokens (e.g. _added on client: `forceHide`_). Usually a
346
- class/style applied by a client-only conditional (viewport, media query, theme,
347
- or feature flag) during the first render. React does **not** patch mismatched
348
- attributes, so `why-hydration` reads these from React's own diff.
349
- **Fix:** render the same attribute on the server and the first client paint —
350
- move the condition into `useEffect`/a mounted flag, or use CSS media queries.
684
+ <img src="docs/screenshots/cause-attribute-mismatch.png" alt="attribute-mismatch report" width="420">
685
+
686
+ A `class`, `style`, or other attribute differs between server and client —
687
+ the report lists the exact tokens (e.g. *added on client: `forceHide`*).
688
+ Usually a class or style applied by a client-only conditional (viewport,
689
+ media query, theme, or feature flag) during the first render. React does
690
+ **not** patch mismatched attributes into the live DOM, so `why-hydration`
691
+ reads these from React's own hydration warning.
692
+ **Fix:** render the same attribute value on the server and the first client
693
+ paint — move the client-only condition into `useEffect`/a mounted flag, or
694
+ drive the visual change with a CSS media query instead of a JS class toggle.
351
695
  **Reference:** [React — different client/server content](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
352
696
 
353
697
  <a id="cause-unknown"></a>
354
698
 
355
699
  ### unknown
356
700
 
357
- A mismatch was detected but didn't match a known signature. The report still
358
- shows the exact server vs client values and node path so you can diagnose it.
701
+ A mismatch was detected but didn't match any of the rules above. The report
702
+ still shows the exact server vs. client values and node path so you can
703
+ diagnose it directly.
359
704
  **Fix:** compare the two values — the cause is usually one of the categories
360
- above. If you find a reliable signal, add a custom rule via the `classify`
361
- option (see [CONTRIBUTING.md](CONTRIBUTING.md)).
705
+ above. If you find a reliable signal for it, add a custom rule via the
706
+ `classify` option (see [Contributing](#contributing)).
362
707
  **Reference:** [React — hydration mismatch errors](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
363
708
 
364
709
  ---
365
710
 
366
- ## How it works
367
-
368
- 1. **Snapshot** — a tiny inline script captures each hydration root's server
369
- `innerHTML` before hydration (at `document.readyState === 'interactive'`,
370
- which runs before deferred scripts).
371
- 2. **Detect** — `onRecoverableError` where you own `hydrateRoot`; otherwise a
372
- dev-only `console.error` interceptor catches React's hydration warnings (the
373
- Next.js fallback).
374
- 3. **Diff** — once, right after hydration (and again on each detection signal),
375
- walk the server snapshot and the live DOM in parallel and find the first
376
- divergence + its selector path. Framework/extension noise (scripts, the
377
- overlay itself) is skipped. There is **no standing observer**, so legitimate
378
- post-hydration updates are never mistaken for mismatches.
379
- 4. **Classify** — ordered heuristics assign a category + confidence + fix. If the
380
- DOM diff can't locate it (e.g. invalid nesting the browser silently
381
- repaired), the tool falls back to React's message.
382
- 5. **Report** — overlay (plain DOM in a shadow root, never the app's React) +
383
- grouped console block + `onReport` callback. Deduped, capped at `maxReports`.
384
-
385
- Everything above is gated on `process.env.NODE_ENV !== 'production'` and
386
- tree-shakes to a no-op in production. CI enforces it with a size budget
387
- (`npm run size`).
388
-
389
- ## Is it safe? (production & privacy)
390
-
391
- - **Zero production cost.** Every code path is gated on
392
- `process.env.NODE_ENV !== 'production'`, written inline so bundlers fold it and
393
- tree-shake the whole implementation. In a production build
394
- `<HydrationInspector>` is a pass-through and `createHydrationInspector` returns
395
- no-ops. CI's size budget fails if a real production bundle is larger than a
396
- few bytes (measured with webpack + terser, what Next.js/CRA/Vite actually use).
397
- - **Read-only.** The tool never modifies your app's DOM — it only appends its own
398
- overlay container (isolated in a shadow root) and reads the server snapshot.
399
- - **No app-React dependency for the overlay.** The overlay is plain DOM, so it
400
- works even while your React tree is mid-recovery.
401
- - **SSR-safe.** Detection is client-only; nothing runs (or patches `console`) on
402
- the server.
403
- - **No network, no telemetry, no dependencies.** Nothing leaves the browser.
404
- `react`/`react-dom` are optional peers; there are **zero** runtime
405
- dependencies. Reports go only to your overlay, your console, and your own
406
- `onReport` callback.
407
- - **Values are rendered as text** (never `innerHTML`), and docs links are
408
- restricted to `http(s)` — no injection from mismatched content.
409
-
410
- ## FAQ / Troubleshooting
711
+ ## Production behavior
712
+
713
+ `why-hydration` is designed to cost **nothing** in a production build:
714
+
715
+ - Every code path — the overlay, the diff engine, the classifier, the console
716
+ interceptor — is gated behind an inline `process.env.NODE_ENV !== 'production'`
717
+ check, written so bundlers can statically fold it away. In a production
718
+ build, `<HydrationInspector>` becomes a plain pass-through and
719
+ `createHydrationInspector()` returns no-op functions.
720
+ - Production is **opt-out**, not opt-in: the internal dev check treats an
721
+ explicit `NODE_ENV === 'production'` as production and everything else,
722
+ including a bundle where `process` was never defined at all, as development.
723
+ That matters because Vite, Rollup and esbuild substitute
724
+ `process.env.NODE_ENV` without shimming `process` itself — requiring
725
+ `process` to exist would silently disable the inspector for all of them. It
726
+ cannot leak dev code into a production bundle, because the entry-point gates
727
+ above have already been folded away by then.
728
+ - This is enforced, not just claimed: the project's CI pipeline runs a size
729
+ budget (`npm run size`) against a real production bundle built with webpack
730
+ and terser — the same toolchain Next.js and CRA use for production — and
731
+ fails the build if the tree-shaken output for any entry point isn't reduced
732
+ to a near-empty stub. Verified independently against Rollup, which is what
733
+ Vite uses for production builds: **99 B** under webpack, **166 B** under
734
+ Rollup, versus ~40 KB for the same entry built for development.
735
+ - `<HydrationSnapshotScript>` also renders `null` outside development, so no
736
+ snapshot script is emitted into your production HTML.
411
737
 
412
- **Does it work outside Next.js?** Yes — anywhere React hydrates: Vite, CRA,
413
- Remix, or your own SSR. Use `createHydrationInspector` where you own
414
- `hydrateRoot`, or `<HydrationInspector>` + the snapshot script anywhere else. The
415
- core engine (`why-hydration`) is framework-agnostic.
738
+ ---
416
739
 
417
- **Nothing shows up.** Confirm `NODE_ENV` isn't `production`, that
418
- `<HydrationInspector>` wraps your app, and (for precise value diffs) that the
419
- snapshot script is in `<head>` and runs before hydration. Without the snapshot
420
- the tool still reports from React's console warnings.
740
+ ## Privacy and performance
741
+
742
+ - **Read-only.** The tool never modifies your application's DOM. It only
743
+ appends its own overlay container (isolated inside a Shadow DOM) and reads
744
+ the pre-hydration snapshot.
745
+ - **No dependency on your app's React instance for the overlay.** The overlay
746
+ is rendered with plain DOM APIs, so it still works even while your app's
747
+ React tree is mid-recovery from an error.
748
+ - **Server-safe.** Detection logic — including the `console.error`
749
+ interceptor — only runs in the browser (guarded by a `typeof window`
750
+ check), so nothing runs, and nothing patches `console`, during
751
+ server-side rendering.
752
+ - **No network requests, no telemetry, no runtime dependencies.** Nothing
753
+ leaves the browser. `react`/`react-dom` are peer dependencies; the package
754
+ itself has zero runtime dependencies. Reports go only to the in-page
755
+ overlay, your browser console, and your own `onReport` callback if you
756
+ provide one.
757
+ - **Values are rendered as text**, never interpreted as HTML, and the
758
+ overlay's "Learn more" links are restricted to `http(s)` URLs — so content
759
+ from a mismatched value cannot be used to inject markup or script into the
760
+ overlay.
761
+ - **Bounded, not continuous.** Detection re-checks the DOM a handful of times
762
+ in the ~1.5 seconds after hydration and then stops — there is no standing
763
+ `MutationObserver` watching your app for the rest of its lifetime.
764
+ - **Bounded in cost, too.** A burst of React warnings landing in one frame
765
+ triggers one diff pass, not one per warning. The captured server markup is
766
+ parsed once per root and reused across passes rather than re-parsed each
767
+ time. Retained warning text is capped, so an app erroring in a render loop
768
+ cannot grow the per-pass cost without limit, and reports are capped by
769
+ `maxReports` (default 25).
770
+ - **Symmetrical teardown.** Unmounting the inspector restores `console.error`
771
+ to exactly the function it replaced, removes the overlay, and clears every
772
+ pending timer — so a remount (including React Strict Mode's deliberate
773
+ double-mount in dev) leaves one overlay and one console patch, not two.
421
774
 
422
- **The "Learn more →" link 404s.** The links point to this README on GitHub
423
- (`github.com/razan-aboushi/why-hydration#cause-…`). Publish the repo under that
424
- name, or change `DOCS_BASE` in `src/core/classify/rules.ts` and the `repository`
425
- field in `package.json` to match your URL.
775
+ ---
426
776
 
427
- **Can I send reports to my logging?** Yes — pass `onReport`; you receive the full
428
- `HydrationReport`.
777
+ ## Troubleshooting
429
778
 
430
- **Why do results now show all at once and stay the same on refresh?** The diff
431
- aligns children with an LCS and collects every mismatch deterministically, so
432
- injected nodes (toasts, portals, ads) can't shift the comparison and change the
433
- results between refreshes.
779
+ **Does it work outside Next.js?** Yes — anywhere React hydrates: Vite, CRA,
780
+ Remix, or a custom SSR setup. Use `createHydrationInspector` where you own
781
+ `hydrateRoot`, or `<HydrationInspector>` plus the snapshot script otherwise.
782
+ The core engine (`why-hydration`) is framework-agnostic.
783
+
784
+ **Nothing shows up, but I know there's a mismatch.** Confirm `NODE_ENV` isn't
785
+ `production`, that `<HydrationInspector>` (or its `Provider`) is actually
786
+ **mounted** and wraps the part of the tree that mismatches, and that the
787
+ snapshot script is present in `<head>` and runs before your app's hydration
788
+ script. Without the snapshot script, the tool still reports mismatches it can
789
+ parse from React's own console warning, but loses the precise DOM-level diff.
790
+ If you passed `roots`, check the console for a
791
+ `[why-hydration] Skipping root:` warning — a root with no captured server HTML
792
+ can never produce a DOM-level report, and it says so rather than failing
793
+ silently.
794
+
795
+ **Does it work under `<React.StrictMode>`?** Yes. Strict Mode runs every mount
796
+ effect setup → cleanup → setup in development, which tears the inspector down
797
+ and rebuilds it. You get one overlay, one console patch, and each mismatch
798
+ reported once — the warnings React logged during the first pass are replayed
799
+ to the rebuilt inspector rather than lost.
800
+
801
+ **I loaded the page in a background tab and got nothing.** Fixed — browsers
802
+ suspend `requestAnimationFrame` on hidden pages, so scheduling races a frame
803
+ against a 50 ms timer and no longer depends on the page being painted.
804
+
805
+ **The "Learn more →" link 404s.** The links point at this README on GitHub
806
+ (`github.com/razan-aboushi/why-hydration#cause-…`). If you've forked the
807
+ package under a different name or repository, update `DOCS_BASE` in
808
+ `src/core/classify/rules.ts` and the `repository` field in `package.json` to
809
+ match your URL.
810
+
811
+ **Can I send reports to my own logging/monitoring?** Yes — pass `onReport`;
812
+ you receive the full `HydrationReport` object for every unique mismatch.
813
+
814
+ **Why do results show all mismatches at once and stay the same across
815
+ refreshes?** The diff aligns DOM children with a longest-common-subsequence
816
+ match rather than by index, so a node injected by a third-party script (a
817
+ toast, a portal, an ad) is treated as an insertion instead of shifting the
818
+ comparison for every following sibling — which is what used to cause
819
+ different, incomplete results on different refreshes.
820
+
821
+ **Does it slow my app down?** No measurable impact in dev, and none at all in
822
+ production. It's dev-only, runs a bounded diff across roughly 1.5 seconds
823
+ right after hydration, then stops.
824
+
825
+ **Does it work with `<html dir="rtl">`?** Yes — see
826
+ [RTL and Arabic support](#rtl-and-arabic-support). The overlay stays
827
+ left-to-right on purpose; this is not a bug. Detection covers Arabic-script
828
+ formatting mismatches on both sides, not just Arabic-vs-Latin.
829
+
830
+ **My Arabic app shows two identical-looking values as a mismatch.** They differ
831
+ by invisible bidirectional control characters — `Intl` adds LRM/RLM/isolate
832
+ marks around numbers and dates in RTL locales, and Node's ICU and the browser's
833
+ ICU do not always agree on which. The report names this explicitly under
834
+ [`locale-format`](#cause-locale-format). Format the value in one place and pass
835
+ the string down, or add `suppressHydrationWarning` if the marks are harmless.
434
836
 
435
- **Does it slow my app down?** No. It's dev-only, diffs across a short settling
436
- window right after hydration (React applies client values a few hundred ms
437
- later), then stops — no standing observers.
837
+ ---
438
838
 
439
839
  ## Contributing
440
840
 
441
841
  Adding a cause category is a self-contained change — see
442
- [CONTRIBUTING.md](CONTRIBUTING.md).
842
+ [CONTRIBUTING.md](CONTRIBUTING.md) for the project layout, how to add a
843
+ classifier rule, and the non-negotiables (zero production cost, read-only
844
+ DOM access).
443
845
 
444
- ## Author
846
+ ---
445
847
 
446
- Built by **[Razan Aboushi](https://www.linkedin.com/in/razan-aboushi/)** ·
447
- [GitHub](https://github.com/razan-aboushi) ·
448
- [LinkedIn](https://www.linkedin.com/in/razan-aboushi/)
848
+ ## License
449
849
 
450
- © 2026 Razan Aboushi. All rights reserved.
850
+ [MIT](LICENSE) © [Razan Aboushi](https://github.com/razan-aboushi)