why-hydration 0.1.0

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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +394 -0
  3. package/dist/chunk-EPJM3LH3.cjs +717 -0
  4. package/dist/chunk-EPJM3LH3.cjs.map +1 -0
  5. package/dist/chunk-FV3PEJQE.js +42 -0
  6. package/dist/chunk-FV3PEJQE.js.map +1 -0
  7. package/dist/chunk-HYG6GVWO.js +16 -0
  8. package/dist/chunk-HYG6GVWO.js.map +1 -0
  9. package/dist/chunk-LNVWRW65.js +700 -0
  10. package/dist/chunk-LNVWRW65.js.map +1 -0
  11. package/dist/chunk-OGQEPU7G.cjs +49 -0
  12. package/dist/chunk-OGQEPU7G.cjs.map +1 -0
  13. package/dist/chunk-PVXOK7GJ.js +418 -0
  14. package/dist/chunk-PVXOK7GJ.js.map +1 -0
  15. package/dist/chunk-QGAEURQY.cjs +38 -0
  16. package/dist/chunk-QGAEURQY.cjs.map +1 -0
  17. package/dist/chunk-R6344ZUK.cjs +441 -0
  18. package/dist/chunk-R6344ZUK.cjs.map +1 -0
  19. package/dist/index.cjs +97 -0
  20. package/dist/index.cjs.map +1 -0
  21. package/dist/index.d.cts +65 -0
  22. package/dist/index.d.ts +65 -0
  23. package/dist/index.js +4 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/next/index.cjs +23 -0
  26. package/dist/next/index.cjs.map +1 -0
  27. package/dist/next/index.d.cts +4 -0
  28. package/dist/next/index.d.ts +4 -0
  29. package/dist/next/index.js +6 -0
  30. package/dist/next/index.js.map +1 -0
  31. package/dist/next/script.cjs +13 -0
  32. package/dist/next/script.cjs.map +1 -0
  33. package/dist/next/script.d.cts +9 -0
  34. package/dist/next/script.d.ts +9 -0
  35. package/dist/next/script.js +4 -0
  36. package/dist/next/script.js.map +1 -0
  37. package/dist/react.cjs +18 -0
  38. package/dist/react.cjs.map +1 -0
  39. package/dist/react.d.cts +38 -0
  40. package/dist/react.d.ts +38 -0
  41. package/dist/react.js +5 -0
  42. package/dist/react.js.map +1 -0
  43. package/dist/types-DTlltJb9.d.cts +53 -0
  44. package/dist/types-DTlltJb9.d.ts +53 -0
  45. package/package.json +134 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Razan Aboushi (https://github.com/razan-aboushi)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,394 @@
1
+ # why-hydration
2
+
3
+ **Tells you which component broke hydration, what differed, and how to fix it — in dev, with zero production cost.**
4
+
5
+ <p align="center">
6
+ <img src="docs/screenshots/overlay-hero.png" alt="why-hydration overlay showing three classified hydration mismatches" width="440">
7
+ </p>
8
+
9
+ React's hydration warnings tell you _that_ something mismatched, rarely _which
10
+ component_, _which value_, or _why_. `why-hydration` diffs the server DOM against
11
+ the client DOM, classifies the root cause, and hands you a specific fix.
12
+
13
+ ```text
14
+ ▸ React: Warning: Text content did not match. Server: "١٢٣٤" Client: "1234"
15
+
16
+ ▸ why-hydration: ⬡ locale-format (92%) — PriceTag · body > main > span:nth-child(2)
17
+ Server: ١٢٣٤ Client: 1234
18
+ Why: Same value, different digit scripts (Arabic-Indic vs Latin).
19
+ The server and client resolved to different locales.
20
+ Fix: Pass an explicit locale + timezone to Intl on both sides,
21
+ or format the value after mount.
22
+ ```
23
+
24
+ - 🔍 **Where** — component (best-effort) + exact DOM node + selector path.
25
+ - 🔀 **What** — server value vs client value, side by side.
26
+ - 🧠 **Why** — the cause, classified into a known category with a confidence score.
27
+ - 🛠️ **Fix** — a specific, actionable suggestion with a docs link.
28
+ - 🫧 **Zero prod cost** — everything is gated on `process.env.NODE_ENV` and
29
+ tree-shakes to a **no-op** (~0 B, size-budgeted in CI).
30
+ - 🌐 **Arabic-first** — digit-script mismatches (٠١٢ vs 012) are a first-class cause.
31
+
32
+ Output goes to an in-browser **overlay**, structured **console** output, and an
33
+ **`onReport`** callback so you can pipe it to your own logging. React 18 & 19.
34
+
35
+ ---
36
+
37
+ ## Install
38
+
39
+ ```bash
40
+ npm i -D why-hydration
41
+ ```
42
+
43
+ `react` and `react-dom` are peer dependencies (`^18 || ^19`).
44
+
45
+ ## 30-second setup
46
+
47
+ ### Next.js — App Router
48
+
49
+ ```tsx
50
+ // app/layout.tsx
51
+ import { HydrationSnapshotScript } from 'why-hydration/next/script';
52
+ import { HydrationInspector } from 'why-hydration/next';
53
+
54
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
55
+ return (
56
+ <html>
57
+ <head>
58
+ {process.env.NODE_ENV !== 'production' && <HydrationSnapshotScript />}
59
+ </head>
60
+ <body>
61
+ <HydrationInspector>{children}</HydrationInspector>
62
+ </body>
63
+ </html>
64
+ );
65
+ }
66
+ ```
67
+
68
+ ### Next.js — Pages Router
69
+
70
+ ```tsx
71
+ // pages/_document.tsx → inside <Head>
72
+ import { HydrationSnapshotScript } from 'why-hydration/next/script';
73
+ // ...
74
+ <Head>{process.env.NODE_ENV !== 'production' && <HydrationSnapshotScript />}</Head>
75
+
76
+ // pages/_app.tsx
77
+ import { HydrationInspector } from 'why-hydration/next';
78
+ export default function App({ Component, pageProps }) {
79
+ return (
80
+ <HydrationInspector>
81
+ <Component {...pageProps} />
82
+ </HydrationInspector>
83
+ );
84
+ }
85
+ ```
86
+
87
+ ### Vite / CRA / Remix (you own `hydrateRoot`)
88
+
89
+ ```tsx
90
+ import { hydrateRoot } from 'react-dom/client';
91
+ import { createHydrationInspector } from 'why-hydration/react';
92
+
93
+ const inspector = createHydrationInspector();
94
+
95
+ hydrateRoot(document.getElementById('root')!, <App />, {
96
+ onRecoverableError: inspector.onRecoverableError,
97
+ });
98
+ ```
99
+
100
+ Add the snapshot script to your HTML `<head>` so the server DOM is captured
101
+ before hydration (Vite `index.html`, before your entry `<script>`). Copy-paste:
102
+
103
+ ```html
104
+ <head>
105
+ <!-- why-hydration snapshot — dev only, runs before hydration -->
106
+ <script>
107
+ (function () {
108
+ var K = '__WHY_HYDRATION_SNAPSHOT__';
109
+ if (typeof window === 'undefined' || window[K]) return;
110
+ var S = ['#root'];
111
+ function cap() {
112
+ if (window[K]) return;
113
+ var r = {};
114
+ for (var i = 0; i < S.length; i++) {
115
+ var el = document.querySelector(S[i]);
116
+ if (el) r[S[i]] = el.innerHTML;
117
+ }
118
+ window[K] = { version: 1, capturedAt: Date.now(), roots: r };
119
+ }
120
+ if (document.readyState !== 'loading') cap();
121
+ else {
122
+ document.addEventListener('readystatechange', function () {
123
+ if (document.readyState === 'interactive') cap();
124
+ });
125
+ document.addEventListener('DOMContentLoaded', cap);
126
+ }
127
+ })();
128
+ </script>
129
+ </head>
130
+ ```
131
+
132
+ > Prefer to generate it? `import { getSnapshotScriptSource } from 'why-hydration'`
133
+ > returns exactly this string. Without the snapshot script the tool still works
134
+ > from React's console warnings, but the precise DOM value/attribute diff needs
135
+ > it.
136
+
137
+ ---
138
+
139
+ ## What you'll see
140
+
141
+ When a mismatch happens in dev, you get three things (all off in production):
142
+
143
+ 1. **An overlay** in the corner — one card per mismatch with the category,
144
+ confidence, server vs client values, why it happened, and the fix. Dismissible.
145
+ 2. **A grouped console block** — the same info, easy to copy into an issue.
146
+ 3. **Your `onReport` callback** (if provided) — the structured `HydrationReport`.
147
+
148
+ A clean page with no mismatches shows **nothing** — no overlay, no logs.
149
+
150
+ ---
151
+
152
+ ## Options
153
+
154
+ ```tsx
155
+ <HydrationInspector
156
+ overlay // boolean | { position: 'bottom-right' | ... } (default: on in dev)
157
+ onReport={(report) => sendToLogging(report)}
158
+ ignore={['.grammarly-ext', (node) => node.hasAttribute('data-safe')]}
159
+ classify={[myCustomRule]} // extra classifiers, run before the built-ins
160
+ maxReports={25}
161
+ >
162
+ {children}
163
+ </HydrationInspector>
164
+ ```
165
+
166
+ | Option | Type | Default | Description |
167
+ | ------------ | --------------------------------------------------- | -------------- | -------------------------------------------------------- |
168
+ | `overlay` | `boolean \| OverlayOptions` | `true` in dev | In-browser panel. `false` to disable. |
169
+ | `onReport` | `(report: HydrationReport) => void` | – | Called once per unique report. |
170
+ | `ignore` | `Array<string \| (node: Element) => boolean>` | `[]` | Suppress known-safe mismatches by selector or predicate. |
171
+ | `classify` | `Classifier[]` | `[]` | Custom rules, run **before** the built-ins. |
172
+ | `maxReports` | `number` | `25` | Cap on unique reports. |
173
+
174
+ ---
175
+
176
+ ## The `HydrationReport`
177
+
178
+ ```ts
179
+ interface HydrationReport {
180
+ id: string;
181
+ timestamp: number;
182
+ component?: string;
183
+ componentStack?: string;
184
+ location?: { file?: string; line?: number; column?: number };
185
+ node: {
186
+ path: string; // selector-style path to the node
187
+ tagName?: string;
188
+ attribute?: string;
189
+ kind: 'text' | 'attribute' | 'structure' | 'node-added' | 'node-removed';
190
+ };
191
+ server: string | null;
192
+ client: string | null;
193
+ cause: {
194
+ category: HydrationCauseCategory;
195
+ confidence: number; // 0–1
196
+ explanation: string;
197
+ suggestion: string;
198
+ docsUrl?: string;
199
+ };
200
+ raw?: { reactMessage?: string };
201
+ }
202
+ ```
203
+
204
+ ---
205
+
206
+ ## Cause categories
207
+
208
+ The classifier runs an ordered list of rules; the first match above a confidence
209
+ threshold wins, otherwise `unknown`. Custom `classify` rules run first.
210
+
211
+ > **What is the "Learn more →" link?** Every report (overlay + console) carries a
212
+ > `docsUrl` that deep-links to the matching section **below** (e.g. the locale
213
+ > report links to [`#cause-locale-format`](#cause-locale-format)). Each section
214
+ > also cites the authoritative React/MDN reference for that cause.
215
+
216
+ <a id="cause-non-deterministic-value"></a>
217
+
218
+ ### non-deterministic-value
219
+
220
+ <img src="docs/screenshots/cause-non-deterministic-value.png" alt="non-deterministic-value report" width="420">
221
+
222
+ Server and client rendered different random-looking values (UUID, token, React
223
+ `:r…:` id, or `Math.random()` output).
224
+ **Fix:** `useId()` for ids; generate randomness after mount; never call
225
+ `Math.random()`/`crypto` in render.
226
+ **Reference:** [React `useId()`](https://react.dev/reference/react/useId).
227
+
228
+ <a id="cause-date-time"></a>
229
+
230
+ ### date-time
231
+
232
+ <img src="docs/screenshots/cause-date-time.png" alt="date-time report" width="420">
233
+
234
+ Values are dates/times that differ by a small delta — the clock or timezone moved
235
+ between server and client render.
236
+ **Fix:** render time after mount, or pass one server timestamp down and pin the
237
+ timezone when formatting.
238
+ **Reference:** [React — different client/server content](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
239
+
240
+ <a id="cause-locale-format"></a>
241
+
242
+ ### locale-format
243
+
244
+ <img src="docs/screenshots/cause-locale-format.png" alt="locale-format report" width="420">
245
+
246
+ Same underlying value, different formatting: **Arabic-Indic ٠١٢ vs Latin 012**,
247
+ decimal/thousand separators (`1,234.56` vs `1.234,56`), or date order (MM/DD vs
248
+ DD/MM).
249
+ **Fix:** pass an explicit `locale` + timezone to `Intl` on both sides, or format
250
+ after mount.
251
+ **Reference:** [MDN `Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat).
252
+
253
+ <a id="cause-browser-only-api"></a>
254
+
255
+ ### browser-only-api
256
+
257
+ <img src="docs/screenshots/cause-browser-only-api.png" alt="browser-only-api report" width="420">
258
+
259
+ The client rendered content the server left empty — a read of `window`,
260
+ `document`, `localStorage`, `navigator`, or `matchMedia` during render.
261
+ **Fix:** gate behind a mounted flag / `useEffect`, or use `useSyncExternalStore`
262
+ with a server snapshot.
263
+ **Reference:** [React `useSyncExternalStore` (server rendering)](https://react.dev/reference/react/useSyncExternalStore#adding-support-for-server-rendering).
264
+
265
+ <a id="cause-viewport-branching"></a>
266
+
267
+ ### viewport-branching
268
+
269
+ A whole subtree was added/removed/swapped — usually a JS width check that
270
+ branches the tree at first render.
271
+ **Fix:** switch with CSS media queries at first paint, not JavaScript.
272
+ **Reference:** [MDN — CSS media queries](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_media_queries/Using_media_queries).
273
+
274
+ <a id="cause-invalid-html-nesting"></a>
275
+
276
+ ### invalid-html-nesting
277
+
278
+ <img src="docs/screenshots/cause-invalid-html-nesting.png" alt="invalid-html-nesting report" width="420">
279
+
280
+ A node was moved/ejected because the markup is invalid (`<div>` in `<p>`, nested
281
+ `<a>`). The browser repairs the server DOM so it no longer matches React.
282
+ **Fix:** correct the markup validity.
283
+ **Reference:** [MDN — `<p>` (permitted content)](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/p).
284
+
285
+ <a id="cause-whitespace-minification"></a>
286
+
287
+ ### whitespace-minification
288
+
289
+ The mismatch is whitespace-only — an HTML minifier collapsed whitespace around
290
+ the root differently from React.
291
+ **Fix:** check your minifier settings (e.g. `conservativeCollapse`) around the
292
+ app root.
293
+ **Reference:** [React — text content hydration](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
294
+
295
+ <a id="cause-third-party-dom-mutation"></a>
296
+
297
+ ### third-party-dom-mutation
298
+
299
+ <img src="docs/screenshots/cause-third-party-dom-mutation.png" alt="third-party-dom-mutation report" width="420">
300
+
301
+ An attribute was injected by a browser extension (Grammarly, ColorZilla, …) or an
302
+ early third-party script before hydration.
303
+ **Fix:** usually harmless — add `suppressHydrationWarning` to the leaf, or defer
304
+ third-party init to post-hydration.
305
+ **Reference:** [React `suppressHydrationWarning`](https://react.dev/reference/react-dom/components/common#suppressing-unavoidable-hydration-mismatch-errors).
306
+
307
+ <a id="cause-unknown"></a>
308
+
309
+ ### unknown
310
+
311
+ A mismatch was detected but didn't match a known signature. The report still
312
+ shows the exact server vs client values and node path so you can diagnose it.
313
+ **Fix:** compare the two values — the cause is usually one of the categories
314
+ above. If you find a reliable signal, add a custom rule via the `classify`
315
+ option (see [CONTRIBUTING.md](CONTRIBUTING.md)).
316
+ **Reference:** [React — hydration mismatch errors](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
317
+
318
+ ---
319
+
320
+ ## How it works
321
+
322
+ 1. **Snapshot** — a tiny inline script captures each hydration root's server
323
+ `innerHTML` before hydration (at `document.readyState === 'interactive'`,
324
+ which runs before deferred scripts).
325
+ 2. **Detect** — `onRecoverableError` where you own `hydrateRoot`; otherwise a
326
+ dev-only `console.error` interceptor catches React's hydration warnings (the
327
+ Next.js fallback).
328
+ 3. **Diff** — once, right after hydration (and again on each detection signal),
329
+ walk the server snapshot and the live DOM in parallel and find the first
330
+ divergence + its selector path. Framework/extension noise (scripts, the
331
+ overlay itself) is skipped. There is **no standing observer**, so legitimate
332
+ post-hydration updates are never mistaken for mismatches.
333
+ 4. **Classify** — ordered heuristics assign a category + confidence + fix. If the
334
+ DOM diff can't locate it (e.g. invalid nesting the browser silently
335
+ repaired), the tool falls back to React's message.
336
+ 5. **Report** — overlay (plain DOM in a shadow root, never the app's React) +
337
+ grouped console block + `onReport` callback. Deduped, capped at `maxReports`.
338
+
339
+ Everything above is gated on `process.env.NODE_ENV !== 'production'` and
340
+ tree-shakes to a no-op in production. CI enforces it with a size budget
341
+ (`npm run size`).
342
+
343
+ ## Is it safe? (production & privacy)
344
+
345
+ - **Zero production cost.** Every code path is gated on
346
+ `process.env.NODE_ENV !== 'production'`, written inline so bundlers fold it and
347
+ tree-shake the whole implementation. In a production build
348
+ `<HydrationInspector>` is a pass-through and `createHydrationInspector` returns
349
+ no-ops. CI's size budget fails if a real production bundle is larger than a
350
+ few bytes (measured with webpack + terser, what Next.js/CRA/Vite actually use).
351
+ - **Read-only.** The tool never modifies your app's DOM — it only appends its own
352
+ overlay container (isolated in a shadow root) and reads the server snapshot.
353
+ - **No app-React dependency for the overlay.** The overlay is plain DOM, so it
354
+ works even while your React tree is mid-recovery.
355
+ - **SSR-safe.** Detection is client-only; nothing runs (or patches `console`) on
356
+ the server.
357
+ - **No network, no telemetry, no dependencies.** Nothing leaves the browser.
358
+ `react`/`react-dom` are optional peers; there are **zero** runtime
359
+ dependencies. Reports go only to your overlay, your console, and your own
360
+ `onReport` callback.
361
+ - **Values are rendered as text** (never `innerHTML`), and docs links are
362
+ restricted to `http(s)` — no injection from mismatched content.
363
+
364
+ ## FAQ / Troubleshooting
365
+
366
+ **Does it work outside Next.js?** Yes — anywhere React hydrates: Vite, CRA,
367
+ Remix, or your own SSR. Use `createHydrationInspector` where you own
368
+ `hydrateRoot`, or `<HydrationInspector>` + the snapshot script anywhere else. The
369
+ core engine (`why-hydration`) is framework-agnostic.
370
+
371
+ **Nothing shows up.** Confirm `NODE_ENV` isn't `production`, that
372
+ `<HydrationInspector>` wraps your app, and (for precise value diffs) that the
373
+ snapshot script is in `<head>` and runs before hydration. Without the snapshot
374
+ the tool still reports from React's console warnings.
375
+
376
+ **The "Learn more →" link 404s.** The links point to this README on GitHub
377
+ (`github.com/razan-aboushi/why-hydration#cause-…`). Publish the repo under that
378
+ name, or change `DOCS_BASE` in `src/core/classify/rules.ts` and the `repository`
379
+ field in `package.json` to match your URL.
380
+
381
+ **Can I send reports to my logging?** Yes — pass `onReport`; you receive the full
382
+ `HydrationReport`.
383
+
384
+ **Does it slow my app down?** No. It's dev-only, runs the diff once after
385
+ hydration (plus once per real React signal), and holds no standing observers.
386
+
387
+ ## Contributing
388
+
389
+ Adding a cause category is a self-contained change — see
390
+ [CONTRIBUTING.md](CONTRIBUTING.md).
391
+
392
+ ## License
393
+
394
+ [MIT](LICENSE) © [Razan Aboushi](https://github.com/razan-aboushi)