why-hydration 0.1.4 → 0.2.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 (39) hide show
  1. package/CHANGELOG.md +108 -1
  2. package/README.md +220 -33
  3. package/dist/{chunk-WQLUD25W.js → chunk-3OAI2ZHY.js} +375 -118
  4. package/dist/chunk-3OAI2ZHY.js.map +1 -0
  5. package/dist/{chunk-OGQEPU7G.cjs → chunk-DXPTZB3Z.cjs} +44 -2
  6. package/dist/chunk-DXPTZB3Z.cjs.map +1 -0
  7. package/dist/{chunk-DSO337ME.cjs → chunk-KX6G7I4Y.cjs} +384 -118
  8. package/dist/chunk-KX6G7I4Y.cjs.map +1 -0
  9. package/dist/chunk-MK3TYHKM.js +966 -0
  10. package/dist/chunk-MK3TYHKM.js.map +1 -0
  11. package/dist/chunk-R7IWOK5M.cjs +989 -0
  12. package/dist/chunk-R7IWOK5M.cjs.map +1 -0
  13. package/dist/{chunk-FV3PEJQE.js → chunk-XIM33ZGB.js} +44 -2
  14. package/dist/chunk-XIM33ZGB.js.map +1 -0
  15. package/dist/index.cjs +32 -24
  16. package/dist/index.d.cts +43 -5
  17. package/dist/index.d.ts +43 -5
  18. package/dist/index.js +2 -2
  19. package/dist/next/index.cjs +5 -5
  20. package/dist/next/index.d.cts +1 -1
  21. package/dist/next/index.d.ts +1 -1
  22. package/dist/next/index.js +3 -3
  23. package/dist/next/script.cjs +2 -2
  24. package/dist/next/script.js +1 -1
  25. package/dist/react.cjs +5 -5
  26. package/dist/react.d.cts +23 -3
  27. package/dist/react.d.ts +23 -3
  28. package/dist/react.js +3 -3
  29. package/dist/{types-6wTih14e.d.cts → types-C14vcpBO.d.cts} +9 -0
  30. package/dist/{types-6wTih14e.d.ts → types-C14vcpBO.d.ts} +9 -0
  31. package/package.json +1 -1
  32. package/dist/chunk-3KULWJ7A.js +0 -569
  33. package/dist/chunk-3KULWJ7A.js.map +0 -1
  34. package/dist/chunk-5CBGYE7A.cjs +0 -592
  35. package/dist/chunk-5CBGYE7A.cjs.map +0 -1
  36. package/dist/chunk-DSO337ME.cjs.map +0 -1
  37. package/dist/chunk-FV3PEJQE.js.map +0 -1
  38. package/dist/chunk-OGQEPU7G.cjs.map +0 -1
  39. package/dist/chunk-WQLUD25W.js.map +0 -1
@@ -1,12 +1,20 @@
1
- import { getServerHtmlForRoot } from './chunk-FV3PEJQE.js';
1
+ import { getServerHtmlForRoot } from './chunk-XIM33ZGB.js';
2
2
 
3
3
  // src/core/env.ts
4
- var isDev = typeof process !== "undefined" && process.env != null && process.env.NODE_ENV !== "production";
4
+ function readNodeEnv() {
5
+ try {
6
+ return process.env.NODE_ENV;
7
+ } catch {
8
+ return void 0;
9
+ }
10
+ }
11
+ var isDev = readNodeEnv() !== "production";
5
12
 
6
13
  // src/core/diff.ts
7
14
  var IGNORED_ATTRIBUTES = /* @__PURE__ */ new Set(["data-reactroot"]);
8
15
  function parseServerHtml(html, rootTagName) {
9
- const container = document.createElement(rootTagName || "div");
16
+ const doc = document.implementation.createHTMLDocument("");
17
+ const container = doc.createElement(rootTagName || "div");
10
18
  container.innerHTML = html;
11
19
  return container;
12
20
  }
@@ -157,21 +165,21 @@ function diffAttributes(serverEl, clientEl, path, parentTag) {
157
165
  }
158
166
  return null;
159
167
  }
160
- function normalizeAttr(name, value) {
161
- if (value == null) return null;
168
+ function normalizeAttr(name, value2) {
169
+ if (value2 == null) return null;
162
170
  if (name === "class") {
163
- return value.trim().split(/\s+/).filter(Boolean).sort().join(" ");
171
+ return value2.trim().split(/\s+/).filter(Boolean).sort().join(" ");
164
172
  }
165
173
  if (name === "style") {
166
- return normalizeStyle(value);
174
+ return normalizeStyle(value2);
167
175
  }
168
- return value;
176
+ return value2;
169
177
  }
170
- function normalizeStyle(value) {
178
+ function normalizeStyle(value2) {
171
179
  if (typeof document !== "undefined") {
172
180
  try {
173
181
  const el = document.createElement("div");
174
- el.style.cssText = value;
182
+ el.style.cssText = value2;
175
183
  const decls = [];
176
184
  for (let i = 0; i < el.style.length; i++) {
177
185
  const prop = el.style.item(i);
@@ -181,7 +189,7 @@ function normalizeStyle(value) {
181
189
  } catch {
182
190
  }
183
191
  }
184
- return value.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
192
+ return value2.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
185
193
  }
186
194
  function nodeKey(node) {
187
195
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -351,8 +359,26 @@ var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
351
359
  var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
352
360
  var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
353
361
  var LATIN_DIGITS = /[0-9]/;
354
- function isRandomLike(value) {
355
- const v = value.trim();
362
+ var BIDI_CONTROLS = /[\u200e\u200f\u061c\u2066-\u2069]/g;
363
+ function stripBidiControls(value2) {
364
+ return value2.replace(BIDI_CONTROLS, "");
365
+ }
366
+ function differsOnlyByBidiControls(a, b) {
367
+ const sa = stripBidiControls(a);
368
+ const sb = stripBidiControls(b);
369
+ return a !== b && sa === sb && sa.trim() !== "";
370
+ }
371
+ function normalizeNumerals(value2) {
372
+ return stripBidiControls(value2).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
373
+ if (ch === "\u066B") return ".";
374
+ if (ch === "\u066C") return ",";
375
+ const code2 = ch.charCodeAt(0);
376
+ const zero = code2 >= 1776 ? 1776 : 1632;
377
+ return String(code2 - zero);
378
+ });
379
+ }
380
+ function isRandomLike(value2) {
381
+ const v = value2.trim();
356
382
  if (!v) return false;
357
383
  if (UUID_RE.test(v)) return true;
358
384
  if (REACT_ID_RE.test(v)) return true;
@@ -361,16 +387,26 @@ function isRandomLike(value) {
361
387
  if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
362
388
  return false;
363
389
  }
364
- function looksLikeTime(value) {
365
- return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(value.trim());
390
+ function looksLikeTime(value2) {
391
+ return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
392
+ normalizeNumerals(value2).trim()
393
+ );
394
+ }
395
+ var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
396
+ var NUMERIC_DATE = /^\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4}$/;
397
+ var TIME_OF_DAY = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?$/;
398
+ var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
399
+ function looksLikeDate(v) {
400
+ return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
366
401
  }
367
- function toTimestamp(value) {
368
- const v = value.trim();
402
+ function toTimestamp(value2) {
403
+ const v = value2.trim();
369
404
  if (!v) return null;
370
405
  if (/^\d{10,13}$/.test(v)) {
371
406
  const n = Number(v);
372
407
  return v.length === 10 ? n * 1e3 : n;
373
408
  }
409
+ if (!looksLikeDate(v)) return null;
374
410
  const parsed = Date.parse(v);
375
411
  if (!Number.isNaN(parsed)) return parsed;
376
412
  if (looksLikeTime(v)) {
@@ -379,23 +415,23 @@ function toTimestamp(value) {
379
415
  }
380
416
  return null;
381
417
  }
382
- function hasArabicIndicDigits(value) {
383
- return ARABIC_INDIC_DIGITS.test(value);
418
+ function hasArabicIndicDigits(value2) {
419
+ return ARABIC_INDIC_DIGITS.test(value2);
384
420
  }
385
- function hasLatinDigits(value) {
386
- return LATIN_DIGITS.test(value);
421
+ function hasLatinDigits(value2) {
422
+ return LATIN_DIGITS.test(value2);
387
423
  }
388
424
  var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
389
425
  function isSameNumberDifferentSeparators(a, b) {
390
- const at = a.trim();
391
- const bt = b.trim();
426
+ const at = normalizeNumerals(a).trim();
427
+ const bt = normalizeNumerals(b).trim();
392
428
  if (!NUMERIC_LIKE.test(at) || !NUMERIC_LIKE.test(bt)) return false;
393
429
  const digitsOnly = (s) => s.replace(/\D/g, "");
394
430
  const da = digitsOnly(at);
395
431
  const db = digitsOnly(bt);
396
432
  if (!da || da !== db) return false;
397
433
  const hasSep = (s) => /[.,\s\u00a0\u2009]/.test(s);
398
- return (hasSep(at) || hasSep(bt)) && at !== bt;
434
+ return (hasSep(at) || hasSep(bt)) && a.trim() !== b.trim();
399
435
  }
400
436
  var CONTENT_ATTRIBUTES = /* @__PURE__ */ new Set([
401
437
  "value",
@@ -419,9 +455,10 @@ function looksLikeThirdPartyNode(html, tagName) {
419
455
  if (!h) return false;
420
456
  return THIRD_PARTY_MARKERS.test(h) || h.includes("about:blank");
421
457
  }
458
+ var DATE_PARTS_RE = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/;
422
459
  function isSameDateDifferentOrder(a, b) {
423
- const partsA = a.trim().match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/);
424
- const partsB = b.trim().match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/);
460
+ const partsA = normalizeNumerals(a).trim().match(DATE_PARTS_RE);
461
+ const partsB = normalizeNumerals(b).trim().match(DATE_PARTS_RE);
425
462
  if (!partsA || !partsB) return false;
426
463
  const setA = [partsA[1], partsA[2], partsA[3]].sort().join("|");
427
464
  const setB = [partsB[1], partsB[2], partsB[3]].sort().join("|");
@@ -487,6 +524,163 @@ function messageIndicatesInvalidNesting(message) {
487
524
  return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
488
525
  }
489
526
 
527
+ // src/core/classify/messages.ts
528
+ var code = (text) => ({ code: text });
529
+ var value = (text) => ({ value: text });
530
+ function param(params, name) {
531
+ const v = params[name];
532
+ return typeof v === "string" ? v : "";
533
+ }
534
+ function paramList(params, name) {
535
+ const v = params[name];
536
+ return Array.isArray(v) ? v : [];
537
+ }
538
+ function valueList(items, separator) {
539
+ const out = [];
540
+ items.forEach((item, i) => {
541
+ if (i > 0) out.push(separator);
542
+ out.push(value(item));
543
+ });
544
+ return out;
545
+ }
546
+ function renderMessage(template, params = {}) {
547
+ if (typeof template === "function") return template(params);
548
+ const out = [];
549
+ template.split("`").forEach((part, i) => {
550
+ if (i % 2 === 1) {
551
+ out.push(
552
+ code(part.replace(/\{(\w+)\}/g, (_, n) => param(params, n)))
553
+ );
554
+ return;
555
+ }
556
+ let last = 0;
557
+ for (const m of part.matchAll(/\{(\w+)\}/g)) {
558
+ const at = m.index ?? 0;
559
+ if (at > last) out.push(part.slice(last, at));
560
+ out.push(value(param(params, m[1])));
561
+ last = at + m[0].length;
562
+ }
563
+ if (last < part.length) out.push(part.slice(last));
564
+ });
565
+ return out.filter((s) => s !== "");
566
+ }
567
+ function plainText(segments) {
568
+ return segments.map(
569
+ (s) => typeof s === "string" ? s : "code" in s ? `\`${s.code}\`` : s.value
570
+ ).join("");
571
+ }
572
+ function classDetail(params, words) {
573
+ const added = paramList(params, "added");
574
+ const removed = paramList(params, "removed");
575
+ if (!added.length && !removed.length) return [];
576
+ const out = [words.open];
577
+ if (added.length) {
578
+ out.push(words.added, ...valueList(added, words.listSeparator));
579
+ }
580
+ if (removed.length) {
581
+ if (added.length) out.push(words.partSeparator);
582
+ out.push(words.removed, ...valueList(removed, words.listSeparator));
583
+ }
584
+ out.push(words.close);
585
+ return out;
586
+ }
587
+ var EN_MESSAGES = {
588
+ "non-deterministic-value": {
589
+ explanation: "The server and client rendered different random-looking values (an id, token, or Math.random() output). Anything non-deterministic in render produces a different value on each side.",
590
+ suggestion: "Use React `useId()` for ids. For random values, generate them after mount (in `useEffect`) or pass a value down from the server so both sides agree. Never call `Math.random()`/`crypto` during render."
591
+ },
592
+ "date-time": {
593
+ explanation: "The values are dates/times that differ between server render and client render \u2014 the clock moved (or the timezone differs) between the two environments.",
594
+ suggestion: "Render the current time after mount, or pass a single server timestamp down and format it identically on both sides. Pin an explicit timezone when formatting."
595
+ },
596
+ "locale-format.bidi": {
597
+ explanation: "The values differ only by invisible bidirectional control marks (LRM/RLM/isolates). `Intl` adds these around numbers and dates in RTL locales, and different ICU versions \u2014 Node vs the browser \u2014 emit different ones for the same input.",
598
+ suggestion: "Format the value in one place and pass the string down, or pin the same locale and timezone on both sides. If the marks are harmless, add `suppressHydrationWarning` to the element."
599
+ },
600
+ "locale-format.script": {
601
+ explanation: "The same value was formatted with different digit scripts (Arabic-Indic \u0660\u0661\u0662 vs Latin 012). The server and client resolved to different locales.",
602
+ suggestion: "Pass an explicit `locale` (and timezone) to `Intl.NumberFormat` / `toLocaleString` on both server and client, or format the value after mount so only the client locale is ever used."
603
+ },
604
+ "locale-format.separators": {
605
+ explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
606
+ suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match."
607
+ },
608
+ "locale-format.date-order": {
609
+ explanation: "The same date was rendered in a different field order (MM/DD vs DD/MM) between server and client.",
610
+ suggestion: "Format dates with an explicit locale and timezone via `Intl` on both sides."
611
+ },
612
+ "browser-only-api": {
613
+ explanation: "The client rendered content the server left empty \u2014 the signature of reading a browser-only API (`window`, `document`, `localStorage`, `navigator`, `matchMedia`) during render.",
614
+ suggestion: "Gate browser-only reads behind a mounted flag or `useEffect`, or use `useSyncExternalStore` with a server snapshot so the first client render matches the server."
615
+ },
616
+ "viewport-branching": {
617
+ explanation: "A whole subtree was added, removed, or swapped between server and client \u2014 typically a JavaScript width/viewport check that branches the tree at first render.",
618
+ suggestion: "Render both branches and switch between them with CSS media queries at first paint instead of branching in JavaScript, or defer the JS-driven branch until after mount."
619
+ },
620
+ "invalid-html-nesting": {
621
+ explanation: "A node was moved or ejected because the markup is invalid HTML (e.g. a `<div>` inside a `<p>`, or nested `<a>`). The browser repairs the server DOM, so it no longer matches what React expects.",
622
+ suggestion: "Fix the markup validity: block elements cannot live inside `<p>`, anchors cannot nest, etc. Replace the invalid parent with a `<div>` or restructure the tree."
623
+ },
624
+ "whitespace-minification": {
625
+ explanation: "The mismatch is whitespace-only \u2014 the text is identical apart from spaces/newlines. An HTML minifier likely collapsed whitespace around the hydration root differently from React.",
626
+ suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup."
627
+ },
628
+ "third-party-dom-mutation.extension-attribute": {
629
+ explanation: "The attribute `{attribute}` was injected by a browser extension or third-party script (e.g. Grammarly, ColorZilla) before hydration, so the client DOM no longer matches the server.",
630
+ suggestion: "This is usually harmless. Add `suppressHydrationWarning` to the affected element, or defer third-party script init until after hydration."
631
+ },
632
+ "third-party-dom-mutation.root-attribute": {
633
+ explanation: "An attribute (`{attribute}`) appeared on a root element that the server never sent \u2014 a hallmark of an extension or early third-party script mutating the DOM.",
634
+ suggestion: "Add `suppressHydrationWarning` to the root element, or defer the third-party script until after hydration."
635
+ },
636
+ "third-party-dom-mutation.injected-node": {
637
+ explanation: "A {tag} was injected by a third-party script or browser extension (ads, consent, analytics, chat) after the server render. It is not part of your app's hydration, so this is usually harmless noise.",
638
+ suggestion: 'If React warns about it, add `suppressHydrationWarning` to the nearest server-rendered wrapper, or load the third-party script after hydration (e.g. Next.js `<Script strategy="afterInteractive">`).'
639
+ },
640
+ "attribute-mismatch.class": {
641
+ explanation: (p) => [
642
+ "The ",
643
+ code("class"),
644
+ " differs between server and client",
645
+ ...classDetail(p, {
646
+ added: "added on client: ",
647
+ removed: "removed on client: ",
648
+ listSeparator: ", ",
649
+ partSeparator: "; ",
650
+ open: " (",
651
+ close: ")"
652
+ }),
653
+ ". A class was applied conditionally on the client \u2014 commonly a viewport, media-query, theme, or feature-flag check that runs during the first render."
654
+ ],
655
+ suggestion: "Render the same className on the server and the first client paint. Move client-only conditions into `useEffect`/a mounted flag, or drive the visual change with CSS media queries instead of a JS class toggle."
656
+ },
657
+ "attribute-mismatch.style": {
658
+ explanation: "The inline `style` differs between server and client \u2014 an inline style was computed from client-only state (viewport size, theme, scroll position) during render.",
659
+ suggestion: "Compute the style after mount (`useEffect`) so the first client render matches the server, or move it to a CSS class / media query."
660
+ },
661
+ "attribute-mismatch.generic": {
662
+ explanation: "The `{attribute}` attribute differs between server (`{server}`) and client (`{client}`) \u2014 its value was derived from something that differs between the server and the first client render.",
663
+ suggestion: "Make the attribute deterministic across server and client, or set it after mount so the first client render matches the server HTML."
664
+ },
665
+ unknown: {
666
+ explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
667
+ suggestion: "Compare the server and client values. Common causes are non-deterministic values, dates/locales, and browser-only APIs used during render."
668
+ },
669
+ "unknown.no-location": {
670
+ explanation: "React reported that hydration failed but did not say which node differed, and the DOM diff found no difference to point at.",
671
+ suggestion: "Make sure `<HydrationSnapshotScript>` (or the manual snapshot script) is in `<head>` so the DOM diff can locate the node, and read React's full warning in the browser console."
672
+ }
673
+ };
674
+ function describe(id, params = {}, catalog = EN_MESSAGES) {
675
+ const entry = catalog[id];
676
+ return {
677
+ messageId: id,
678
+ ...Object.keys(params).length ? { params } : {},
679
+ explanation: plainText(renderMessage(entry.explanation, params)),
680
+ suggestion: plainText(renderMessage(entry.suggestion, params))
681
+ };
682
+ }
683
+
490
684
  // src/core/classify/rules.ts
491
685
  var DOCS_BASE = "https://github.com/razan-aboushi/why-hydration#cause-";
492
686
  function docs(category) {
@@ -509,8 +703,7 @@ var nonDeterministic = (d) => {
509
703
  return {
510
704
  category: "non-deterministic-value",
511
705
  confidence: 0.9,
512
- explanation: "The server and client rendered different random-looking values (an id, token, or Math.random() output). Anything non-deterministic in render produces a different value on each side.",
513
- suggestion: "Use React `useId()` for ids. For random values, generate them after mount (in `useEffect`) or pass a value down from the server so both sides agree. Never call `Math.random()`/`crypto` during render.",
706
+ ...describe("non-deterministic-value"),
514
707
  docsUrl: docs("non-deterministic-value")
515
708
  };
516
709
  };
@@ -528,8 +721,7 @@ var dateTime = (d) => {
528
721
  return {
529
722
  category: "date-time",
530
723
  confidence: smallDelta || bothTimes ? 0.85 : 0.7,
531
- explanation: "The values are dates/times that differ between server render and client render \u2014 the clock moved (or the timezone differs) between the two environments.",
532
- suggestion: "Render the current time after mount, or pass a single server timestamp down and format it identically on both sides. Pin an explicit timezone when formatting.",
724
+ ...describe("date-time"),
533
725
  docsUrl: docs("date-time")
534
726
  };
535
727
  };
@@ -537,13 +729,20 @@ var localeFormat = (d) => {
537
729
  if (!isValueDivergence(d)) return null;
538
730
  if (!bothDiffer(d)) return null;
539
731
  const { server, client } = d;
732
+ if (differsOnlyByBidiControls(server, client)) {
733
+ return {
734
+ category: "locale-format",
735
+ confidence: 0.88,
736
+ ...describe("locale-format.bidi"),
737
+ docsUrl: docs("locale-format")
738
+ };
739
+ }
540
740
  const scriptMismatch = hasArabicIndicDigits(server) && hasLatinDigits(client) || hasLatinDigits(server) && hasArabicIndicDigits(client);
541
741
  if (scriptMismatch) {
542
742
  return {
543
743
  category: "locale-format",
544
744
  confidence: 0.92,
545
- explanation: "The same value was formatted with different digit scripts (Arabic-Indic \u0660\u0661\u0662 vs Latin 012). The server and client resolved to different locales.",
546
- suggestion: "Pass an explicit `locale` (and timezone) to `Intl.NumberFormat` / `toLocaleString` on both server and client, or format the value after mount so only the client locale is ever used.",
745
+ ...describe("locale-format.script"),
547
746
  docsUrl: docs("locale-format")
548
747
  };
549
748
  }
@@ -551,8 +750,7 @@ var localeFormat = (d) => {
551
750
  return {
552
751
  category: "locale-format",
553
752
  confidence: 0.82,
554
- explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
555
- suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match.",
753
+ ...describe("locale-format.separators"),
556
754
  docsUrl: docs("locale-format")
557
755
  };
558
756
  }
@@ -560,8 +758,7 @@ var localeFormat = (d) => {
560
758
  return {
561
759
  category: "locale-format",
562
760
  confidence: 0.75,
563
- explanation: "The same date was rendered in a different field order (MM/DD vs DD/MM) between server and client.",
564
- suggestion: "Format dates with an explicit locale and timezone via `Intl` on both sides.",
761
+ ...describe("locale-format.date-order"),
565
762
  docsUrl: docs("locale-format")
566
763
  };
567
764
  }
@@ -575,8 +772,7 @@ var browserOnlyApi = (d) => {
575
772
  return {
576
773
  category: "browser-only-api",
577
774
  confidence: 0.75,
578
- explanation: "The client rendered content the server left empty \u2014 the signature of reading a browser-only API (`window`, `document`, `localStorage`, `navigator`, `matchMedia`) during render.",
579
- suggestion: "Gate browser-only reads behind a mounted flag or `useEffect`, or use `useSyncExternalStore` with a server snapshot so the first client render matches the server.",
775
+ ...describe("browser-only-api"),
580
776
  docsUrl: docs("browser-only-api")
581
777
  };
582
778
  };
@@ -586,11 +782,11 @@ var viewportBranching = (d) => {
586
782
  if (isInvalidNesting(d.parentTagName, d.tagName) || messageIndicatesInvalidNesting(d.reactMessage)) {
587
783
  return null;
588
784
  }
785
+ if (d.server == null && d.client == null && !d.tagName) return null;
589
786
  return {
590
787
  category: "viewport-branching",
591
788
  confidence: 0.6,
592
- explanation: "A whole subtree was added, removed, or swapped between server and client \u2014 typically a JavaScript width/viewport check that branches the tree at first render.",
593
- suggestion: "Render both branches and switch between them with CSS media queries at first paint instead of branching in JavaScript, or defer the JS-driven branch until after mount.",
789
+ ...describe("viewport-branching"),
594
790
  docsUrl: docs("viewport-branching")
595
791
  };
596
792
  };
@@ -601,8 +797,7 @@ var invalidNesting = (d) => {
601
797
  return {
602
798
  category: "invalid-html-nesting",
603
799
  confidence: byMessage ? 0.9 : 0.72,
604
- explanation: "A node was moved or ejected because the markup is invalid HTML (e.g. a `<div>` inside a `<p>`, or nested `<a>`). The browser repairs the server DOM, so it no longer matches what React expects.",
605
- suggestion: "Fix the markup validity: block elements cannot live inside `<p>`, anchors cannot nest, etc. Replace the invalid parent with a `<div>` or restructure the tree.",
800
+ ...describe("invalid-html-nesting"),
606
801
  docsUrl: docs("invalid-html-nesting")
607
802
  };
608
803
  };
@@ -615,8 +810,7 @@ var whitespaceMinification = (d) => {
615
810
  return {
616
811
  category: "whitespace-minification",
617
812
  confidence: 0.7,
618
- explanation: "The mismatch is whitespace-only \u2014 the text is identical apart from spaces/newlines. An HTML minifier likely collapsed whitespace around the hydration root differently from React.",
619
- suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup.",
813
+ ...describe("whitespace-minification"),
620
814
  docsUrl: docs("whitespace-minification")
621
815
  };
622
816
  };
@@ -626,18 +820,21 @@ var thirdPartyDomMutation = (d) => {
626
820
  return {
627
821
  category: "third-party-dom-mutation",
628
822
  confidence: 0.88,
629
- explanation: `The attribute \`${d.attribute}\` was injected by a browser extension or third-party script (e.g. Grammarly, ColorZilla) before hydration, so the client DOM no longer matches the server.`,
630
- suggestion: "This is usually harmless. Add `suppressHydrationWarning` to the affected element, or defer third-party script init until after hydration.",
823
+ ...describe("third-party-dom-mutation.extension-attribute", {
824
+ attribute: d.attribute
825
+ }),
631
826
  docsUrl: docs("third-party-dom-mutation")
632
827
  };
633
828
  }
634
- const atRoot = /^(html|body)\b/.test(d.path) && d.server == null;
829
+ const el = d.element;
830
+ const atRoot = d.server == null && el != null && /^(?:HTML|BODY)$/.test(el.tagName);
635
831
  if (atRoot) {
636
832
  return {
637
833
  category: "third-party-dom-mutation",
638
834
  confidence: 0.6,
639
- explanation: `An attribute (\`${d.attribute}\`) appeared on a root element that the server never sent \u2014 a hallmark of an extension or early third-party script mutating the DOM.`,
640
- suggestion: "Add `suppressHydrationWarning` to the root element, or defer the third-party script until after hydration.",
835
+ ...describe("third-party-dom-mutation.root-attribute", {
836
+ attribute: d.attribute
837
+ }),
641
838
  docsUrl: docs("third-party-dom-mutation")
642
839
  };
643
840
  }
@@ -647,8 +844,9 @@ var thirdPartyDomMutation = (d) => {
647
844
  return {
648
845
  category: "third-party-dom-mutation",
649
846
  confidence: 0.7,
650
- explanation: `A <${tag}> was injected by a third-party script or browser extension (ads, consent, analytics, chat) after the server render. It is not part of your app's hydration, so this is usually harmless noise.`,
651
- suggestion: 'If React warns about it, add `suppressHydrationWarning` to the nearest server-rendered wrapper, or load the third-party script after hydration (e.g. Next.js `<Script strategy="afterInteractive">`).',
847
+ ...describe("third-party-dom-mutation.injected-node", {
848
+ tag: `<${tag}>`
849
+ }),
652
850
  docsUrl: docs("third-party-dom-mutation")
653
851
  };
654
852
  }
@@ -664,15 +862,10 @@ var attributeMismatch = (d) => {
664
862
  const clientSet = new Set(client.split(/\s+/).filter(Boolean));
665
863
  const added = [...clientSet].filter((c) => !serverSet.has(c));
666
864
  const removed = [...serverSet].filter((c) => !clientSet.has(c));
667
- const parts = [];
668
- if (added.length) parts.push(`added on client: ${added.join(", ")}`);
669
- if (removed.length) parts.push(`removed on client: ${removed.join(", ")}`);
670
- const detail = parts.length ? ` (${parts.join("; ")})` : "";
671
865
  return {
672
866
  category: "attribute-mismatch",
673
867
  confidence: 0.8,
674
- explanation: `The \`class\` differs between server and client${detail}. A class was applied conditionally on the client \u2014 commonly a viewport, media-query, theme, or feature-flag check that runs during the first render.`,
675
- suggestion: "Render the same className on the server and the first client paint. Move client-only conditions into `useEffect`/a mounted flag, or drive the visual change with CSS media queries instead of a JS class toggle.",
868
+ ...describe("attribute-mismatch.class", { added, removed }),
676
869
  docsUrl: docs("attribute-mismatch")
677
870
  };
678
871
  }
@@ -680,16 +873,18 @@ var attributeMismatch = (d) => {
680
873
  return {
681
874
  category: "attribute-mismatch",
682
875
  confidence: 0.75,
683
- explanation: "The inline `style` differs between server and client \u2014 an inline style was computed from client-only state (viewport size, theme, scroll position) during render.",
684
- suggestion: "Compute the style after mount (`useEffect`) so the first client render matches the server, or move it to a CSS class / media query.",
876
+ ...describe("attribute-mismatch.style"),
685
877
  docsUrl: docs("attribute-mismatch")
686
878
  };
687
879
  }
688
880
  return {
689
881
  category: "attribute-mismatch",
690
882
  confidence: 0.6,
691
- explanation: `The \`${d.attribute}\` attribute differs between server (\`${server}\`) and client (\`${client}\`) \u2014 its value was derived from something that differs between the server and the first client render.`,
692
- suggestion: "Make the attribute deterministic across server and client, or set it after mount so the first client render matches the server HTML.",
883
+ ...describe("attribute-mismatch.generic", {
884
+ attribute: d.attribute,
885
+ server,
886
+ client
887
+ }),
693
888
  docsUrl: docs("attribute-mismatch")
694
889
  };
695
890
  };
@@ -711,13 +906,21 @@ var BUILT_IN_RULES = [
711
906
  var UNKNOWN_CAUSE = {
712
907
  category: "unknown",
713
908
  confidence: 0,
714
- explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
715
- suggestion: "Compare the server and client values. Common causes are non-deterministic values, dates/locales, and browser-only APIs used during render.",
909
+ ...describe("unknown"),
910
+ docsUrl: docs("unknown")
911
+ };
912
+ var UNKNOWN_NO_LOCATION_CAUSE = {
913
+ category: "unknown",
914
+ confidence: 0,
915
+ ...describe("unknown.no-location"),
716
916
  docsUrl: docs("unknown")
717
917
  };
718
918
 
719
919
  // src/core/classify/index.ts
720
920
  var CONFIDENCE_THRESHOLD = 0.5;
921
+ function isLocationless(d) {
922
+ return d.server == null && d.client == null && !d.tagName && !d.attribute;
923
+ }
721
924
  function classify(divergence, options = {}) {
722
925
  const threshold = options.threshold ?? CONFIDENCE_THRESHOLD;
723
926
  const rules = [
@@ -735,7 +938,7 @@ function classify(divergence, options = {}) {
735
938
  return result;
736
939
  }
737
940
  }
738
- return UNKNOWN_CAUSE;
941
+ return isLocationless(divergence) ? UNKNOWN_NO_LOCATION_CAUSE : UNKNOWN_CAUSE;
739
942
  }
740
943
 
741
944
  // src/core/report.ts
@@ -744,9 +947,12 @@ function nextId() {
744
947
  counter += 1;
745
948
  return `wh_${Date.now().toString(36)}_${counter}`;
746
949
  }
747
- function truncate(value, max = 300) {
748
- if (value == null) return null;
749
- return value.length > max ? `${value.slice(0, max)}\u2026` : value;
950
+ function truncate(value2, max = 300) {
951
+ if (value2 == null) return null;
952
+ if (value2.length <= max) return value2;
953
+ const last = value2.charCodeAt(max - 1);
954
+ const end = last >= 55296 && last <= 56319 ? max - 1 : max;
955
+ return `${value2.slice(0, end)}\u2026`;
750
956
  }
751
957
  function buildReport(divergence, cause, context = {}) {
752
958
  return {
@@ -784,34 +990,48 @@ var ReportCollector = class {
784
990
  this.options = options;
785
991
  this.maxReports = options.maxReports ?? 25;
786
992
  }
787
- addSink(sink) {
993
+ /**
994
+ * Register a sink. Pass `replay` for sinks that render *state* (the overlay)
995
+ * rather than react to *events* (`onReport`, the console): they need the
996
+ * reports collected before they were attached, which matters when a sink is
997
+ * re-attached after a stop/start cycle.
998
+ */
999
+ addSink(sink, options = {}) {
788
1000
  this.sinks.add(sink);
1001
+ if (options.replay) {
1002
+ for (const report of this.reports) this.emit(sink, report);
1003
+ }
789
1004
  return () => this.sinks.delete(sink);
790
1005
  }
1006
+ emit(sink, report) {
1007
+ try {
1008
+ sink(report);
1009
+ } catch {
1010
+ }
1011
+ }
791
1012
  getReports() {
792
1013
  return this.reports;
793
1014
  }
1015
+ /** The cause this collector would assign — same rules, same threshold. */
1016
+ classify(divergence) {
1017
+ return classify(divergence, {
1018
+ extra: this.options.extra,
1019
+ threshold: this.options.threshold
1020
+ });
1021
+ }
794
1022
  get isFull() {
795
1023
  return this.reports.length >= this.maxReports;
796
1024
  }
797
1025
  report(divergence, context = {}) {
798
1026
  if (this.isFull) return null;
799
1027
  if (this.options.ignore?.(divergence)) return null;
800
- const cause = classify(divergence, {
801
- extra: this.options.extra,
802
- threshold: this.options.threshold
803
- });
1028
+ const cause = this.classify(divergence);
804
1029
  const report = buildReport(divergence, cause, context);
805
1030
  const signature = signatureOf(report);
806
1031
  if (this.seen.has(signature)) return null;
807
1032
  this.seen.add(signature);
808
1033
  this.reports.push(report);
809
- for (const sink of this.sinks) {
810
- try {
811
- sink(report);
812
- } catch {
813
- }
814
- }
1034
+ for (const sink of this.sinks) this.emit(sink, report);
815
1035
  return report;
816
1036
  }
817
1037
  reset() {
@@ -855,11 +1075,19 @@ function extractComponentFromMessage(message) {
855
1075
  }
856
1076
  return names.length ? names[names.length - 1] : void 0;
857
1077
  }
1078
+ function diffLines(message) {
1079
+ const lines = message.split("\n");
1080
+ let link = -1;
1081
+ lines.forEach((line, i) => {
1082
+ if (/react\.dev\/link\/hydration-mismatch/.test(line)) link = i;
1083
+ });
1084
+ return link >= 0 ? lines.slice(link + 1) : lines;
1085
+ }
858
1086
  var ATTR_RE = /^([\w:-]+)=(?:"([\s\S]*)"|\{([\s\S]*)\})$/;
859
1087
  function parseModernDiff(message) {
860
1088
  const plus = [];
861
1089
  const minus = [];
862
- for (const raw of message.split("\n")) {
1090
+ for (const raw of diffLines(message)) {
863
1091
  const line = raw.trim();
864
1092
  const p = /^\+\s+(.+)$/.exec(line);
865
1093
  const mn = /^-\s+(.+)$/.exec(line);
@@ -985,45 +1213,47 @@ function parseHydrationMessage(message) {
985
1213
  }
986
1214
  function parseAllHydrationDivergences(message) {
987
1215
  if (!isHydrationMessage(message)) return [];
1216
+ const fallback = () => {
1217
+ const single = parseHydrationMessage(message);
1218
+ return single ? [single] : [];
1219
+ };
988
1220
  const plus = [];
989
1221
  const minus = [];
990
- for (const raw of message.split("\n")) {
1222
+ for (const raw of diffLines(message)) {
991
1223
  const line = raw.trim();
992
1224
  const p = /^\+\s+(.+)$/.exec(line);
993
1225
  const mn = /^-\s+(.+)$/.exec(line);
994
1226
  if (p && p[1]) plus.push(p[1].trim());
995
1227
  else if (mn && mn[1]) minus.push(mn[1].trim());
996
1228
  }
997
- if (plus.length === 0 && minus.length === 0) {
998
- const single = parseHydrationMessage(message);
999
- return single ? [single] : [];
1000
- }
1229
+ if (plus.length === 0 && minus.length === 0) return fallback();
1001
1230
  const out = [];
1231
+ const attrPlus = plus.filter((p) => ATTR_RE.test(p));
1002
1232
  const attrMinus = minus.filter((m) => ATTR_RE.test(m));
1003
1233
  const usedMinus = /* @__PURE__ */ new Set();
1004
- for (const p of plus) {
1005
- const pm = ATTR_RE.exec(p);
1006
- if (!pm) continue;
1007
- const name = pm[1];
1008
- const clientValue = pm[2] ?? pm[3] ?? "";
1009
- const idx = attrMinus.findIndex((m, k) => {
1010
- if (usedMinus.has(k)) return false;
1011
- const parsed = ATTR_RE.exec(m);
1012
- return parsed?.[1] === name;
1013
- });
1014
- if (idx >= 0) {
1015
- usedMinus.add(idx);
1016
- const parsed = ATTR_RE.exec(attrMinus[idx]);
1017
- out.push({
1018
- kind: "attribute",
1019
- path: "body",
1020
- attribute: name,
1021
- server: parsed ? parsed[2] ?? parsed[3] ?? "" : "",
1022
- client: clientValue,
1023
- reactMessage: message
1024
- });
1025
- }
1234
+ const attribute = (name, server, client) => ({
1235
+ kind: "attribute",
1236
+ path: "body",
1237
+ attribute: name,
1238
+ server,
1239
+ client,
1240
+ reactMessage: message
1241
+ });
1242
+ for (const line of attrPlus) {
1243
+ const name = attrName(line);
1244
+ if (!name) continue;
1245
+ const idx = attrMinus.findIndex(
1246
+ (m, k) => !usedMinus.has(k) && attrName(m) === name
1247
+ );
1248
+ if (idx >= 0) usedMinus.add(idx);
1249
+ const server = idx >= 0 ? attrValue(attrMinus[idx]) : null;
1250
+ out.push(attribute(name, server, attrValue(line)));
1026
1251
  }
1252
+ attrMinus.forEach((line, k) => {
1253
+ if (usedMinus.has(k)) return;
1254
+ const name = attrName(line);
1255
+ if (name) out.push(attribute(name, attrValue(line), null));
1256
+ });
1027
1257
  const textPlus = plus.filter((p) => !ATTR_RE.test(p));
1028
1258
  const textMinus = minus.filter((m) => !ATTR_RE.test(m));
1029
1259
  const len = Math.max(textPlus.length, textMinus.length);
@@ -1031,22 +1261,32 @@ function parseAllHydrationDivergences(message) {
1031
1261
  const client = textPlus[i] ?? null;
1032
1262
  const server = textMinus[i] ?? null;
1033
1263
  if (client !== null || server !== null) {
1034
- out.push({ kind: "text", path: "body", server, client, reactMessage: message });
1264
+ out.push({
1265
+ kind: "text",
1266
+ path: "body",
1267
+ server,
1268
+ client,
1269
+ reactMessage: message
1270
+ });
1035
1271
  }
1036
1272
  }
1037
- return out.length ? out : parseHydrationMessage(message) ? [parseHydrationMessage(message)] : [];
1273
+ return out.length ? out : fallback();
1274
+ }
1275
+ function attrName(line) {
1276
+ return ATTR_RE.exec(line)?.[1];
1277
+ }
1278
+ function attrValue(line) {
1279
+ const parsed = ATTR_RE.exec(line);
1280
+ return parsed ? parsed[2] ?? parsed[3] ?? "" : "";
1038
1281
  }
1039
1282
 
1040
1283
  // src/core/inspect.ts
1041
1284
  function inspectRoot(root, collector, context = {}, enrich) {
1042
1285
  const serverHtml = getServerHtmlForRoot(root);
1043
1286
  if (serverHtml == null) return 0;
1044
- const divergences = collectSnapshotAgainstDom(serverHtml, root);
1287
+ const divergences = collectDivergences(serverTreeFor(root, serverHtml), root);
1045
1288
  let reported = 0;
1046
1289
  for (const divergence of divergences) {
1047
- if (context.reactMessage && !divergence.reactMessage) {
1048
- divergence.reactMessage = context.reactMessage;
1049
- }
1050
1290
  const enriched = enrich ? enrich(divergence) : {};
1051
1291
  if (collector.report(divergence, { ...context, ...enriched }) != null) {
1052
1292
  reported += 1;
@@ -1054,18 +1294,35 @@ function inspectRoot(root, collector, context = {}, enrich) {
1054
1294
  }
1055
1295
  return reported;
1056
1296
  }
1057
- function reportFromMessage(message, collector, context = {}) {
1058
- const divergences = parseAllHydrationDivergences(message);
1297
+ var serverTrees = /* @__PURE__ */ new WeakMap();
1298
+ function serverTreeFor(root, html) {
1299
+ const cached = serverTrees.get(root);
1300
+ if (cached && cached.html === html) return cached.tree;
1301
+ const tree = parseServerHtml(html, root.tagName);
1302
+ serverTrees.set(root, { html, tree });
1303
+ return tree;
1304
+ }
1305
+ function reportFromMessage(message, collector, context = {}, options = {}) {
1306
+ const mode = options.locationless ?? "include";
1307
+ const divergences = parseAllHydrationDivergences(message).filter((d) => {
1308
+ if (mode === "include") return true;
1309
+ const bare = isLocationless(d) && collector.classify(d).messageId === "unknown.no-location";
1310
+ return mode === "skip" ? !bare : bare;
1311
+ });
1059
1312
  const component = extractComponentFromMessage(message) ?? context.component;
1060
1313
  let reported = 0;
1061
1314
  for (const divergence of divergences) {
1062
- if (collector.report(divergence, { ...context, component, reactMessage: message }) != null) {
1315
+ if (collector.report(divergence, {
1316
+ ...context,
1317
+ component,
1318
+ reactMessage: message
1319
+ }) != null) {
1063
1320
  reported += 1;
1064
1321
  }
1065
1322
  }
1066
1323
  return reported;
1067
1324
  }
1068
1325
 
1069
- export { BUILT_IN_RULES, CONFIDENCE_THRESHOLD, ReportCollector, UNKNOWN_CAUSE, buildReport, classify, diffSnapshotAgainstDom, diffTrees, formatConsoleArgs, inspectRoot, isDev, isHydrationMessage, parseHydrationMessage, parseServerHtml, reportFromMessage, signatureOf };
1070
- //# sourceMappingURL=chunk-WQLUD25W.js.map
1071
- //# sourceMappingURL=chunk-WQLUD25W.js.map
1326
+ export { BUILT_IN_RULES, CONFIDENCE_THRESHOLD, EN_MESSAGES, ReportCollector, UNKNOWN_CAUSE, UNKNOWN_NO_LOCATION_CAUSE, buildReport, classDetail, classify, code, diffSnapshotAgainstDom, diffTrees, formatConsoleArgs, inspectRoot, isDev, isHydrationMessage, isInternalComponent, isLocationless, param, parseHydrationMessage, parseServerHtml, plainText, renderMessage, reportFromMessage, signatureOf };
1327
+ //# sourceMappingURL=chunk-3OAI2ZHY.js.map
1328
+ //# sourceMappingURL=chunk-3OAI2ZHY.js.map