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/CHANGELOG.md +81 -1
- package/LICENSE +21 -0
- package/README.md +597 -197
- package/dist/{chunk-NQX4TBV5.js → chunk-3I6F4K4L.js} +132 -48
- package/dist/chunk-3I6F4K4L.js.map +1 -0
- package/dist/{chunk-WQLUD25W.js → chunk-AS5DZZHI.js} +138 -53
- package/dist/chunk-AS5DZZHI.js.map +1 -0
- package/dist/{chunk-DSO337ME.cjs → chunk-AT6A77X3.cjs} +139 -54
- package/dist/chunk-AT6A77X3.cjs.map +1 -0
- package/dist/{chunk-OGQEPU7G.cjs → chunk-DXPTZB3Z.cjs} +44 -2
- package/dist/chunk-DXPTZB3Z.cjs.map +1 -0
- package/dist/{chunk-MTAU6G26.cjs → chunk-OFVFNPE6.cjs} +141 -57
- package/dist/chunk-OFVFNPE6.cjs.map +1 -0
- package/dist/{chunk-FV3PEJQE.js → chunk-XIM33ZGB.js} +44 -2
- package/dist/chunk-XIM33ZGB.js.map +1 -0
- package/dist/index.cjs +24 -24
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +2 -2
- package/dist/next/index.cjs +5 -5
- package/dist/next/index.js +3 -3
- package/dist/next/script.cjs +2 -2
- package/dist/next/script.js +1 -1
- package/dist/react.cjs +5 -5
- package/dist/react.js +3 -3
- package/package.json +4 -3
- package/dist/chunk-DSO337ME.cjs.map +0 -1
- package/dist/chunk-FV3PEJQE.js.map +0 -1
- package/dist/chunk-MTAU6G26.cjs.map +0 -1
- package/dist/chunk-NQX4TBV5.js.map +0 -1
- package/dist/chunk-OGQEPU7G.cjs.map +0 -1
- package/dist/chunk-WQLUD25W.js.map +0 -1
package/README.md
CHANGED
|
@@ -2,29 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/why-hydration)
|
|
4
4
|
[](https://www.npmjs.com/package/why-hydration)
|
|
5
|
-
[](https://github.com/razan-aboushi/why-hydration/actions/workflows/ci.yml)
|
|
6
|
+
[](#production-behavior)
|
|
7
|
+
[](#install)
|
|
8
|
+
[](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
|
|
12
|
-
Router
|
|
13
|
-
the exact **component, file,
|
|
14
|
-
|
|
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
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
44
|
-
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
110
|
-
|
|
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
|
|
115
|
-
|
|
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
|
|
147
|
-
>
|
|
148
|
-
>
|
|
149
|
-
>
|
|
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
|
|
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
|
-
|
|
334
|
+
### Controlling the overlay
|
|
156
335
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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
|
-
|
|
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
|
+
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
**
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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 |
|
|
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
|
|
200
|
-
| ------------ |
|
|
201
|
-
| `overlay` | `boolean \| OverlayOptions`
|
|
202
|
-
| `onReport` | `(report: HydrationReport) => void`
|
|
203
|
-
| `ignore` | `Array<string \| (node: Element) => boolean>`
|
|
204
|
-
| `classify` | `Classifier[]`
|
|
205
|
-
| `maxReports` | `number`
|
|
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
|
-
##
|
|
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?: {
|
|
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;
|
|
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;
|
|
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
|
|
242
|
-
threshold wins, otherwise
|
|
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
|
|
245
|
-
> `docsUrl` that deep-links to the matching section
|
|
246
|
-
> report links to [`#cause-locale-format`](#cause-locale-format)).
|
|
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
|
|
268
|
-
between server and client render.
|
|
269
|
-
|
|
270
|
-
|
|
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:
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
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 —
|
|
293
|
-
`document`, `localStorage`, `navigator`, or `matchMedia` during
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
303
|
-
branches the tree at first render.
|
|
304
|
-
**Fix:** switch with CSS media queries at first paint
|
|
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
|
|
314
|
-
`<a>`). The browser repairs the server DOM
|
|
315
|
-
|
|
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 —
|
|
323
|
-
|
|
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
|
-
|
|
345
|
-
|
|
346
|
-
class
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
|
358
|
-
shows the exact server vs client values and node path so you can
|
|
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
|
|
361
|
-
option (see [
|
|
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
|
-
##
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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
|
-
|
|
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
|
-
|
|
428
|
-
`HydrationReport`.
|
|
777
|
+
## Troubleshooting
|
|
429
778
|
|
|
430
|
-
**
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
846
|
+
---
|
|
445
847
|
|
|
446
|
-
|
|
447
|
-
[GitHub](https://github.com/razan-aboushi) ·
|
|
448
|
-
[LinkedIn](https://www.linkedin.com/in/razan-aboushi/)
|
|
848
|
+
## License
|
|
449
849
|
|
|
450
|
-
©
|
|
850
|
+
[MIT](LICENSE) © [Razan Aboushi](https://github.com/razan-aboushi)
|