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,14 +1,22 @@
1
1
  'use strict';
2
2
 
3
- var chunkOGQEPU7G_cjs = require('./chunk-OGQEPU7G.cjs');
3
+ var chunkDXPTZB3Z_cjs = require('./chunk-DXPTZB3Z.cjs');
4
4
 
5
5
  // src/core/env.ts
6
- var isDev = typeof process !== "undefined" && process.env != null && process.env.NODE_ENV !== "production";
6
+ function readNodeEnv() {
7
+ try {
8
+ return process.env.NODE_ENV;
9
+ } catch {
10
+ return void 0;
11
+ }
12
+ }
13
+ var isDev = readNodeEnv() !== "production";
7
14
 
8
15
  // src/core/diff.ts
9
16
  var IGNORED_ATTRIBUTES = /* @__PURE__ */ new Set(["data-reactroot"]);
10
17
  function parseServerHtml(html, rootTagName) {
11
- const container = document.createElement(rootTagName || "div");
18
+ const doc = document.implementation.createHTMLDocument("");
19
+ const container = doc.createElement(rootTagName || "div");
12
20
  container.innerHTML = html;
13
21
  return container;
14
22
  }
@@ -159,21 +167,21 @@ function diffAttributes(serverEl, clientEl, path, parentTag) {
159
167
  }
160
168
  return null;
161
169
  }
162
- function normalizeAttr(name, value) {
163
- if (value == null) return null;
170
+ function normalizeAttr(name, value2) {
171
+ if (value2 == null) return null;
164
172
  if (name === "class") {
165
- return value.trim().split(/\s+/).filter(Boolean).sort().join(" ");
173
+ return value2.trim().split(/\s+/).filter(Boolean).sort().join(" ");
166
174
  }
167
175
  if (name === "style") {
168
- return normalizeStyle(value);
176
+ return normalizeStyle(value2);
169
177
  }
170
- return value;
178
+ return value2;
171
179
  }
172
- function normalizeStyle(value) {
180
+ function normalizeStyle(value2) {
173
181
  if (typeof document !== "undefined") {
174
182
  try {
175
183
  const el = document.createElement("div");
176
- el.style.cssText = value;
184
+ el.style.cssText = value2;
177
185
  const decls = [];
178
186
  for (let i = 0; i < el.style.length; i++) {
179
187
  const prop = el.style.item(i);
@@ -183,7 +191,7 @@ function normalizeStyle(value) {
183
191
  } catch {
184
192
  }
185
193
  }
186
- return value.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
194
+ return value2.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
187
195
  }
188
196
  function nodeKey(node) {
189
197
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -353,8 +361,26 @@ var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
353
361
  var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
354
362
  var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
355
363
  var LATIN_DIGITS = /[0-9]/;
356
- function isRandomLike(value) {
357
- const v = value.trim();
364
+ var BIDI_CONTROLS = /[\u200e\u200f\u061c\u2066-\u2069]/g;
365
+ function stripBidiControls(value2) {
366
+ return value2.replace(BIDI_CONTROLS, "");
367
+ }
368
+ function differsOnlyByBidiControls(a, b) {
369
+ const sa = stripBidiControls(a);
370
+ const sb = stripBidiControls(b);
371
+ return a !== b && sa === sb && sa.trim() !== "";
372
+ }
373
+ function normalizeNumerals(value2) {
374
+ return stripBidiControls(value2).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
375
+ if (ch === "\u066B") return ".";
376
+ if (ch === "\u066C") return ",";
377
+ const code2 = ch.charCodeAt(0);
378
+ const zero = code2 >= 1776 ? 1776 : 1632;
379
+ return String(code2 - zero);
380
+ });
381
+ }
382
+ function isRandomLike(value2) {
383
+ const v = value2.trim();
358
384
  if (!v) return false;
359
385
  if (UUID_RE.test(v)) return true;
360
386
  if (REACT_ID_RE.test(v)) return true;
@@ -363,16 +389,26 @@ function isRandomLike(value) {
363
389
  if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
364
390
  return false;
365
391
  }
366
- function looksLikeTime(value) {
367
- return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(value.trim());
392
+ function looksLikeTime(value2) {
393
+ return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
394
+ normalizeNumerals(value2).trim()
395
+ );
396
+ }
397
+ var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
398
+ var NUMERIC_DATE = /^\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4}$/;
399
+ var TIME_OF_DAY = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?$/;
400
+ var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
401
+ function looksLikeDate(v) {
402
+ return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
368
403
  }
369
- function toTimestamp(value) {
370
- const v = value.trim();
404
+ function toTimestamp(value2) {
405
+ const v = value2.trim();
371
406
  if (!v) return null;
372
407
  if (/^\d{10,13}$/.test(v)) {
373
408
  const n = Number(v);
374
409
  return v.length === 10 ? n * 1e3 : n;
375
410
  }
411
+ if (!looksLikeDate(v)) return null;
376
412
  const parsed = Date.parse(v);
377
413
  if (!Number.isNaN(parsed)) return parsed;
378
414
  if (looksLikeTime(v)) {
@@ -381,23 +417,23 @@ function toTimestamp(value) {
381
417
  }
382
418
  return null;
383
419
  }
384
- function hasArabicIndicDigits(value) {
385
- return ARABIC_INDIC_DIGITS.test(value);
420
+ function hasArabicIndicDigits(value2) {
421
+ return ARABIC_INDIC_DIGITS.test(value2);
386
422
  }
387
- function hasLatinDigits(value) {
388
- return LATIN_DIGITS.test(value);
423
+ function hasLatinDigits(value2) {
424
+ return LATIN_DIGITS.test(value2);
389
425
  }
390
426
  var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
391
427
  function isSameNumberDifferentSeparators(a, b) {
392
- const at = a.trim();
393
- const bt = b.trim();
428
+ const at = normalizeNumerals(a).trim();
429
+ const bt = normalizeNumerals(b).trim();
394
430
  if (!NUMERIC_LIKE.test(at) || !NUMERIC_LIKE.test(bt)) return false;
395
431
  const digitsOnly = (s) => s.replace(/\D/g, "");
396
432
  const da = digitsOnly(at);
397
433
  const db = digitsOnly(bt);
398
434
  if (!da || da !== db) return false;
399
435
  const hasSep = (s) => /[.,\s\u00a0\u2009]/.test(s);
400
- return (hasSep(at) || hasSep(bt)) && at !== bt;
436
+ return (hasSep(at) || hasSep(bt)) && a.trim() !== b.trim();
401
437
  }
402
438
  var CONTENT_ATTRIBUTES = /* @__PURE__ */ new Set([
403
439
  "value",
@@ -421,9 +457,10 @@ function looksLikeThirdPartyNode(html, tagName) {
421
457
  if (!h) return false;
422
458
  return THIRD_PARTY_MARKERS.test(h) || h.includes("about:blank");
423
459
  }
460
+ var DATE_PARTS_RE = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/;
424
461
  function isSameDateDifferentOrder(a, b) {
425
- const partsA = a.trim().match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/);
426
- const partsB = b.trim().match(/^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/);
462
+ const partsA = normalizeNumerals(a).trim().match(DATE_PARTS_RE);
463
+ const partsB = normalizeNumerals(b).trim().match(DATE_PARTS_RE);
427
464
  if (!partsA || !partsB) return false;
428
465
  const setA = [partsA[1], partsA[2], partsA[3]].sort().join("|");
429
466
  const setB = [partsB[1], partsB[2], partsB[3]].sort().join("|");
@@ -489,6 +526,163 @@ function messageIndicatesInvalidNesting(message) {
489
526
  return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
490
527
  }
491
528
 
529
+ // src/core/classify/messages.ts
530
+ var code = (text) => ({ code: text });
531
+ var value = (text) => ({ value: text });
532
+ function param(params, name) {
533
+ const v = params[name];
534
+ return typeof v === "string" ? v : "";
535
+ }
536
+ function paramList(params, name) {
537
+ const v = params[name];
538
+ return Array.isArray(v) ? v : [];
539
+ }
540
+ function valueList(items, separator) {
541
+ const out = [];
542
+ items.forEach((item, i) => {
543
+ if (i > 0) out.push(separator);
544
+ out.push(value(item));
545
+ });
546
+ return out;
547
+ }
548
+ function renderMessage(template, params = {}) {
549
+ if (typeof template === "function") return template(params);
550
+ const out = [];
551
+ template.split("`").forEach((part, i) => {
552
+ if (i % 2 === 1) {
553
+ out.push(
554
+ code(part.replace(/\{(\w+)\}/g, (_, n) => param(params, n)))
555
+ );
556
+ return;
557
+ }
558
+ let last = 0;
559
+ for (const m of part.matchAll(/\{(\w+)\}/g)) {
560
+ const at = m.index ?? 0;
561
+ if (at > last) out.push(part.slice(last, at));
562
+ out.push(value(param(params, m[1])));
563
+ last = at + m[0].length;
564
+ }
565
+ if (last < part.length) out.push(part.slice(last));
566
+ });
567
+ return out.filter((s) => s !== "");
568
+ }
569
+ function plainText(segments) {
570
+ return segments.map(
571
+ (s) => typeof s === "string" ? s : "code" in s ? `\`${s.code}\`` : s.value
572
+ ).join("");
573
+ }
574
+ function classDetail(params, words) {
575
+ const added = paramList(params, "added");
576
+ const removed = paramList(params, "removed");
577
+ if (!added.length && !removed.length) return [];
578
+ const out = [words.open];
579
+ if (added.length) {
580
+ out.push(words.added, ...valueList(added, words.listSeparator));
581
+ }
582
+ if (removed.length) {
583
+ if (added.length) out.push(words.partSeparator);
584
+ out.push(words.removed, ...valueList(removed, words.listSeparator));
585
+ }
586
+ out.push(words.close);
587
+ return out;
588
+ }
589
+ var EN_MESSAGES = {
590
+ "non-deterministic-value": {
591
+ 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.",
592
+ 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."
593
+ },
594
+ "date-time": {
595
+ 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.",
596
+ 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."
597
+ },
598
+ "locale-format.bidi": {
599
+ 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.",
600
+ 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."
601
+ },
602
+ "locale-format.script": {
603
+ 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.",
604
+ 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."
605
+ },
606
+ "locale-format.separators": {
607
+ explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
608
+ suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match."
609
+ },
610
+ "locale-format.date-order": {
611
+ explanation: "The same date was rendered in a different field order (MM/DD vs DD/MM) between server and client.",
612
+ suggestion: "Format dates with an explicit locale and timezone via `Intl` on both sides."
613
+ },
614
+ "browser-only-api": {
615
+ 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.",
616
+ 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."
617
+ },
618
+ "viewport-branching": {
619
+ 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.",
620
+ 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."
621
+ },
622
+ "invalid-html-nesting": {
623
+ 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.",
624
+ 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."
625
+ },
626
+ "whitespace-minification": {
627
+ 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.",
628
+ suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup."
629
+ },
630
+ "third-party-dom-mutation.extension-attribute": {
631
+ 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.",
632
+ suggestion: "This is usually harmless. Add `suppressHydrationWarning` to the affected element, or defer third-party script init until after hydration."
633
+ },
634
+ "third-party-dom-mutation.root-attribute": {
635
+ 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.",
636
+ suggestion: "Add `suppressHydrationWarning` to the root element, or defer the third-party script until after hydration."
637
+ },
638
+ "third-party-dom-mutation.injected-node": {
639
+ 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.",
640
+ 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">`).'
641
+ },
642
+ "attribute-mismatch.class": {
643
+ explanation: (p) => [
644
+ "The ",
645
+ code("class"),
646
+ " differs between server and client",
647
+ ...classDetail(p, {
648
+ added: "added on client: ",
649
+ removed: "removed on client: ",
650
+ listSeparator: ", ",
651
+ partSeparator: "; ",
652
+ open: " (",
653
+ close: ")"
654
+ }),
655
+ ". 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."
656
+ ],
657
+ 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."
658
+ },
659
+ "attribute-mismatch.style": {
660
+ 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.",
661
+ suggestion: "Compute the style after mount (`useEffect`) so the first client render matches the server, or move it to a CSS class / media query."
662
+ },
663
+ "attribute-mismatch.generic": {
664
+ 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.",
665
+ suggestion: "Make the attribute deterministic across server and client, or set it after mount so the first client render matches the server HTML."
666
+ },
667
+ unknown: {
668
+ explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
669
+ suggestion: "Compare the server and client values. Common causes are non-deterministic values, dates/locales, and browser-only APIs used during render."
670
+ },
671
+ "unknown.no-location": {
672
+ explanation: "React reported that hydration failed but did not say which node differed, and the DOM diff found no difference to point at.",
673
+ 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."
674
+ }
675
+ };
676
+ function describe(id, params = {}, catalog = EN_MESSAGES) {
677
+ const entry = catalog[id];
678
+ return {
679
+ messageId: id,
680
+ ...Object.keys(params).length ? { params } : {},
681
+ explanation: plainText(renderMessage(entry.explanation, params)),
682
+ suggestion: plainText(renderMessage(entry.suggestion, params))
683
+ };
684
+ }
685
+
492
686
  // src/core/classify/rules.ts
493
687
  var DOCS_BASE = "https://github.com/razan-aboushi/why-hydration#cause-";
494
688
  function docs(category) {
@@ -511,8 +705,7 @@ var nonDeterministic = (d) => {
511
705
  return {
512
706
  category: "non-deterministic-value",
513
707
  confidence: 0.9,
514
- 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.",
515
- 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.",
708
+ ...describe("non-deterministic-value"),
516
709
  docsUrl: docs("non-deterministic-value")
517
710
  };
518
711
  };
@@ -530,8 +723,7 @@ var dateTime = (d) => {
530
723
  return {
531
724
  category: "date-time",
532
725
  confidence: smallDelta || bothTimes ? 0.85 : 0.7,
533
- 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.",
534
- 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.",
726
+ ...describe("date-time"),
535
727
  docsUrl: docs("date-time")
536
728
  };
537
729
  };
@@ -539,13 +731,20 @@ var localeFormat = (d) => {
539
731
  if (!isValueDivergence(d)) return null;
540
732
  if (!bothDiffer(d)) return null;
541
733
  const { server, client } = d;
734
+ if (differsOnlyByBidiControls(server, client)) {
735
+ return {
736
+ category: "locale-format",
737
+ confidence: 0.88,
738
+ ...describe("locale-format.bidi"),
739
+ docsUrl: docs("locale-format")
740
+ };
741
+ }
542
742
  const scriptMismatch = hasArabicIndicDigits(server) && hasLatinDigits(client) || hasLatinDigits(server) && hasArabicIndicDigits(client);
543
743
  if (scriptMismatch) {
544
744
  return {
545
745
  category: "locale-format",
546
746
  confidence: 0.92,
547
- 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.",
548
- 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.",
747
+ ...describe("locale-format.script"),
549
748
  docsUrl: docs("locale-format")
550
749
  };
551
750
  }
@@ -553,8 +752,7 @@ var localeFormat = (d) => {
553
752
  return {
554
753
  category: "locale-format",
555
754
  confidence: 0.82,
556
- explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
557
- suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match.",
755
+ ...describe("locale-format.separators"),
558
756
  docsUrl: docs("locale-format")
559
757
  };
560
758
  }
@@ -562,8 +760,7 @@ var localeFormat = (d) => {
562
760
  return {
563
761
  category: "locale-format",
564
762
  confidence: 0.75,
565
- explanation: "The same date was rendered in a different field order (MM/DD vs DD/MM) between server and client.",
566
- suggestion: "Format dates with an explicit locale and timezone via `Intl` on both sides.",
763
+ ...describe("locale-format.date-order"),
567
764
  docsUrl: docs("locale-format")
568
765
  };
569
766
  }
@@ -577,8 +774,7 @@ var browserOnlyApi = (d) => {
577
774
  return {
578
775
  category: "browser-only-api",
579
776
  confidence: 0.75,
580
- 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.",
581
- 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.",
777
+ ...describe("browser-only-api"),
582
778
  docsUrl: docs("browser-only-api")
583
779
  };
584
780
  };
@@ -588,11 +784,11 @@ var viewportBranching = (d) => {
588
784
  if (isInvalidNesting(d.parentTagName, d.tagName) || messageIndicatesInvalidNesting(d.reactMessage)) {
589
785
  return null;
590
786
  }
787
+ if (d.server == null && d.client == null && !d.tagName) return null;
591
788
  return {
592
789
  category: "viewport-branching",
593
790
  confidence: 0.6,
594
- 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.",
595
- 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.",
791
+ ...describe("viewport-branching"),
596
792
  docsUrl: docs("viewport-branching")
597
793
  };
598
794
  };
@@ -603,8 +799,7 @@ var invalidNesting = (d) => {
603
799
  return {
604
800
  category: "invalid-html-nesting",
605
801
  confidence: byMessage ? 0.9 : 0.72,
606
- 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.",
607
- 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.",
802
+ ...describe("invalid-html-nesting"),
608
803
  docsUrl: docs("invalid-html-nesting")
609
804
  };
610
805
  };
@@ -617,8 +812,7 @@ var whitespaceMinification = (d) => {
617
812
  return {
618
813
  category: "whitespace-minification",
619
814
  confidence: 0.7,
620
- 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.",
621
- suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup.",
815
+ ...describe("whitespace-minification"),
622
816
  docsUrl: docs("whitespace-minification")
623
817
  };
624
818
  };
@@ -628,18 +822,21 @@ var thirdPartyDomMutation = (d) => {
628
822
  return {
629
823
  category: "third-party-dom-mutation",
630
824
  confidence: 0.88,
631
- 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.`,
632
- suggestion: "This is usually harmless. Add `suppressHydrationWarning` to the affected element, or defer third-party script init until after hydration.",
825
+ ...describe("third-party-dom-mutation.extension-attribute", {
826
+ attribute: d.attribute
827
+ }),
633
828
  docsUrl: docs("third-party-dom-mutation")
634
829
  };
635
830
  }
636
- const atRoot = /^(html|body)\b/.test(d.path) && d.server == null;
831
+ const el = d.element;
832
+ const atRoot = d.server == null && el != null && /^(?:HTML|BODY)$/.test(el.tagName);
637
833
  if (atRoot) {
638
834
  return {
639
835
  category: "third-party-dom-mutation",
640
836
  confidence: 0.6,
641
- 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.`,
642
- suggestion: "Add `suppressHydrationWarning` to the root element, or defer the third-party script until after hydration.",
837
+ ...describe("third-party-dom-mutation.root-attribute", {
838
+ attribute: d.attribute
839
+ }),
643
840
  docsUrl: docs("third-party-dom-mutation")
644
841
  };
645
842
  }
@@ -649,8 +846,9 @@ var thirdPartyDomMutation = (d) => {
649
846
  return {
650
847
  category: "third-party-dom-mutation",
651
848
  confidence: 0.7,
652
- 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.`,
653
- 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">`).',
849
+ ...describe("third-party-dom-mutation.injected-node", {
850
+ tag: `<${tag}>`
851
+ }),
654
852
  docsUrl: docs("third-party-dom-mutation")
655
853
  };
656
854
  }
@@ -666,15 +864,10 @@ var attributeMismatch = (d) => {
666
864
  const clientSet = new Set(client.split(/\s+/).filter(Boolean));
667
865
  const added = [...clientSet].filter((c) => !serverSet.has(c));
668
866
  const removed = [...serverSet].filter((c) => !clientSet.has(c));
669
- const parts = [];
670
- if (added.length) parts.push(`added on client: ${added.join(", ")}`);
671
- if (removed.length) parts.push(`removed on client: ${removed.join(", ")}`);
672
- const detail = parts.length ? ` (${parts.join("; ")})` : "";
673
867
  return {
674
868
  category: "attribute-mismatch",
675
869
  confidence: 0.8,
676
- 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.`,
677
- 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.",
870
+ ...describe("attribute-mismatch.class", { added, removed }),
678
871
  docsUrl: docs("attribute-mismatch")
679
872
  };
680
873
  }
@@ -682,16 +875,18 @@ var attributeMismatch = (d) => {
682
875
  return {
683
876
  category: "attribute-mismatch",
684
877
  confidence: 0.75,
685
- 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.",
686
- suggestion: "Compute the style after mount (`useEffect`) so the first client render matches the server, or move it to a CSS class / media query.",
878
+ ...describe("attribute-mismatch.style"),
687
879
  docsUrl: docs("attribute-mismatch")
688
880
  };
689
881
  }
690
882
  return {
691
883
  category: "attribute-mismatch",
692
884
  confidence: 0.6,
693
- 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.`,
694
- suggestion: "Make the attribute deterministic across server and client, or set it after mount so the first client render matches the server HTML.",
885
+ ...describe("attribute-mismatch.generic", {
886
+ attribute: d.attribute,
887
+ server,
888
+ client
889
+ }),
695
890
  docsUrl: docs("attribute-mismatch")
696
891
  };
697
892
  };
@@ -713,13 +908,21 @@ var BUILT_IN_RULES = [
713
908
  var UNKNOWN_CAUSE = {
714
909
  category: "unknown",
715
910
  confidence: 0,
716
- explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
717
- suggestion: "Compare the server and client values. Common causes are non-deterministic values, dates/locales, and browser-only APIs used during render.",
911
+ ...describe("unknown"),
912
+ docsUrl: docs("unknown")
913
+ };
914
+ var UNKNOWN_NO_LOCATION_CAUSE = {
915
+ category: "unknown",
916
+ confidence: 0,
917
+ ...describe("unknown.no-location"),
718
918
  docsUrl: docs("unknown")
719
919
  };
720
920
 
721
921
  // src/core/classify/index.ts
722
922
  var CONFIDENCE_THRESHOLD = 0.5;
923
+ function isLocationless(d) {
924
+ return d.server == null && d.client == null && !d.tagName && !d.attribute;
925
+ }
723
926
  function classify(divergence, options = {}) {
724
927
  const threshold = options.threshold ?? CONFIDENCE_THRESHOLD;
725
928
  const rules = [
@@ -737,7 +940,7 @@ function classify(divergence, options = {}) {
737
940
  return result;
738
941
  }
739
942
  }
740
- return UNKNOWN_CAUSE;
943
+ return isLocationless(divergence) ? UNKNOWN_NO_LOCATION_CAUSE : UNKNOWN_CAUSE;
741
944
  }
742
945
 
743
946
  // src/core/report.ts
@@ -746,9 +949,12 @@ function nextId() {
746
949
  counter += 1;
747
950
  return `wh_${Date.now().toString(36)}_${counter}`;
748
951
  }
749
- function truncate(value, max = 300) {
750
- if (value == null) return null;
751
- return value.length > max ? `${value.slice(0, max)}\u2026` : value;
952
+ function truncate(value2, max = 300) {
953
+ if (value2 == null) return null;
954
+ if (value2.length <= max) return value2;
955
+ const last = value2.charCodeAt(max - 1);
956
+ const end = last >= 55296 && last <= 56319 ? max - 1 : max;
957
+ return `${value2.slice(0, end)}\u2026`;
752
958
  }
753
959
  function buildReport(divergence, cause, context = {}) {
754
960
  return {
@@ -786,34 +992,48 @@ var ReportCollector = class {
786
992
  this.options = options;
787
993
  this.maxReports = options.maxReports ?? 25;
788
994
  }
789
- addSink(sink) {
995
+ /**
996
+ * Register a sink. Pass `replay` for sinks that render *state* (the overlay)
997
+ * rather than react to *events* (`onReport`, the console): they need the
998
+ * reports collected before they were attached, which matters when a sink is
999
+ * re-attached after a stop/start cycle.
1000
+ */
1001
+ addSink(sink, options = {}) {
790
1002
  this.sinks.add(sink);
1003
+ if (options.replay) {
1004
+ for (const report of this.reports) this.emit(sink, report);
1005
+ }
791
1006
  return () => this.sinks.delete(sink);
792
1007
  }
1008
+ emit(sink, report) {
1009
+ try {
1010
+ sink(report);
1011
+ } catch {
1012
+ }
1013
+ }
793
1014
  getReports() {
794
1015
  return this.reports;
795
1016
  }
1017
+ /** The cause this collector would assign — same rules, same threshold. */
1018
+ classify(divergence) {
1019
+ return classify(divergence, {
1020
+ extra: this.options.extra,
1021
+ threshold: this.options.threshold
1022
+ });
1023
+ }
796
1024
  get isFull() {
797
1025
  return this.reports.length >= this.maxReports;
798
1026
  }
799
1027
  report(divergence, context = {}) {
800
1028
  if (this.isFull) return null;
801
1029
  if (this.options.ignore?.(divergence)) return null;
802
- const cause = classify(divergence, {
803
- extra: this.options.extra,
804
- threshold: this.options.threshold
805
- });
1030
+ const cause = this.classify(divergence);
806
1031
  const report = buildReport(divergence, cause, context);
807
1032
  const signature = signatureOf(report);
808
1033
  if (this.seen.has(signature)) return null;
809
1034
  this.seen.add(signature);
810
1035
  this.reports.push(report);
811
- for (const sink of this.sinks) {
812
- try {
813
- sink(report);
814
- } catch {
815
- }
816
- }
1036
+ for (const sink of this.sinks) this.emit(sink, report);
817
1037
  return report;
818
1038
  }
819
1039
  reset() {
@@ -857,11 +1077,19 @@ function extractComponentFromMessage(message) {
857
1077
  }
858
1078
  return names.length ? names[names.length - 1] : void 0;
859
1079
  }
1080
+ function diffLines(message) {
1081
+ const lines = message.split("\n");
1082
+ let link = -1;
1083
+ lines.forEach((line, i) => {
1084
+ if (/react\.dev\/link\/hydration-mismatch/.test(line)) link = i;
1085
+ });
1086
+ return link >= 0 ? lines.slice(link + 1) : lines;
1087
+ }
860
1088
  var ATTR_RE = /^([\w:-]+)=(?:"([\s\S]*)"|\{([\s\S]*)\})$/;
861
1089
  function parseModernDiff(message) {
862
1090
  const plus = [];
863
1091
  const minus = [];
864
- for (const raw of message.split("\n")) {
1092
+ for (const raw of diffLines(message)) {
865
1093
  const line = raw.trim();
866
1094
  const p = /^\+\s+(.+)$/.exec(line);
867
1095
  const mn = /^-\s+(.+)$/.exec(line);
@@ -987,45 +1215,47 @@ function parseHydrationMessage(message) {
987
1215
  }
988
1216
  function parseAllHydrationDivergences(message) {
989
1217
  if (!isHydrationMessage(message)) return [];
1218
+ const fallback = () => {
1219
+ const single = parseHydrationMessage(message);
1220
+ return single ? [single] : [];
1221
+ };
990
1222
  const plus = [];
991
1223
  const minus = [];
992
- for (const raw of message.split("\n")) {
1224
+ for (const raw of diffLines(message)) {
993
1225
  const line = raw.trim();
994
1226
  const p = /^\+\s+(.+)$/.exec(line);
995
1227
  const mn = /^-\s+(.+)$/.exec(line);
996
1228
  if (p && p[1]) plus.push(p[1].trim());
997
1229
  else if (mn && mn[1]) minus.push(mn[1].trim());
998
1230
  }
999
- if (plus.length === 0 && minus.length === 0) {
1000
- const single = parseHydrationMessage(message);
1001
- return single ? [single] : [];
1002
- }
1231
+ if (plus.length === 0 && minus.length === 0) return fallback();
1003
1232
  const out = [];
1233
+ const attrPlus = plus.filter((p) => ATTR_RE.test(p));
1004
1234
  const attrMinus = minus.filter((m) => ATTR_RE.test(m));
1005
1235
  const usedMinus = /* @__PURE__ */ new Set();
1006
- for (const p of plus) {
1007
- const pm = ATTR_RE.exec(p);
1008
- if (!pm) continue;
1009
- const name = pm[1];
1010
- const clientValue = pm[2] ?? pm[3] ?? "";
1011
- const idx = attrMinus.findIndex((m, k) => {
1012
- if (usedMinus.has(k)) return false;
1013
- const parsed = ATTR_RE.exec(m);
1014
- return parsed?.[1] === name;
1015
- });
1016
- if (idx >= 0) {
1017
- usedMinus.add(idx);
1018
- const parsed = ATTR_RE.exec(attrMinus[idx]);
1019
- out.push({
1020
- kind: "attribute",
1021
- path: "body",
1022
- attribute: name,
1023
- server: parsed ? parsed[2] ?? parsed[3] ?? "" : "",
1024
- client: clientValue,
1025
- reactMessage: message
1026
- });
1027
- }
1236
+ const attribute = (name, server, client) => ({
1237
+ kind: "attribute",
1238
+ path: "body",
1239
+ attribute: name,
1240
+ server,
1241
+ client,
1242
+ reactMessage: message
1243
+ });
1244
+ for (const line of attrPlus) {
1245
+ const name = attrName(line);
1246
+ if (!name) continue;
1247
+ const idx = attrMinus.findIndex(
1248
+ (m, k) => !usedMinus.has(k) && attrName(m) === name
1249
+ );
1250
+ if (idx >= 0) usedMinus.add(idx);
1251
+ const server = idx >= 0 ? attrValue(attrMinus[idx]) : null;
1252
+ out.push(attribute(name, server, attrValue(line)));
1028
1253
  }
1254
+ attrMinus.forEach((line, k) => {
1255
+ if (usedMinus.has(k)) return;
1256
+ const name = attrName(line);
1257
+ if (name) out.push(attribute(name, attrValue(line), null));
1258
+ });
1029
1259
  const textPlus = plus.filter((p) => !ATTR_RE.test(p));
1030
1260
  const textMinus = minus.filter((m) => !ATTR_RE.test(m));
1031
1261
  const len = Math.max(textPlus.length, textMinus.length);
@@ -1033,22 +1263,32 @@ function parseAllHydrationDivergences(message) {
1033
1263
  const client = textPlus[i] ?? null;
1034
1264
  const server = textMinus[i] ?? null;
1035
1265
  if (client !== null || server !== null) {
1036
- out.push({ kind: "text", path: "body", server, client, reactMessage: message });
1266
+ out.push({
1267
+ kind: "text",
1268
+ path: "body",
1269
+ server,
1270
+ client,
1271
+ reactMessage: message
1272
+ });
1037
1273
  }
1038
1274
  }
1039
- return out.length ? out : parseHydrationMessage(message) ? [parseHydrationMessage(message)] : [];
1275
+ return out.length ? out : fallback();
1276
+ }
1277
+ function attrName(line) {
1278
+ return ATTR_RE.exec(line)?.[1];
1279
+ }
1280
+ function attrValue(line) {
1281
+ const parsed = ATTR_RE.exec(line);
1282
+ return parsed ? parsed[2] ?? parsed[3] ?? "" : "";
1040
1283
  }
1041
1284
 
1042
1285
  // src/core/inspect.ts
1043
1286
  function inspectRoot(root, collector, context = {}, enrich) {
1044
- const serverHtml = chunkOGQEPU7G_cjs.getServerHtmlForRoot(root);
1287
+ const serverHtml = chunkDXPTZB3Z_cjs.getServerHtmlForRoot(root);
1045
1288
  if (serverHtml == null) return 0;
1046
- const divergences = collectSnapshotAgainstDom(serverHtml, root);
1289
+ const divergences = collectDivergences(serverTreeFor(root, serverHtml), root);
1047
1290
  let reported = 0;
1048
1291
  for (const divergence of divergences) {
1049
- if (context.reactMessage && !divergence.reactMessage) {
1050
- divergence.reactMessage = context.reactMessage;
1051
- }
1052
1292
  const enriched = enrich ? enrich(divergence) : {};
1053
1293
  if (collector.report(divergence, { ...context, ...enriched }) != null) {
1054
1294
  reported += 1;
@@ -1056,12 +1296,29 @@ function inspectRoot(root, collector, context = {}, enrich) {
1056
1296
  }
1057
1297
  return reported;
1058
1298
  }
1059
- function reportFromMessage(message, collector, context = {}) {
1060
- const divergences = parseAllHydrationDivergences(message);
1299
+ var serverTrees = /* @__PURE__ */ new WeakMap();
1300
+ function serverTreeFor(root, html) {
1301
+ const cached = serverTrees.get(root);
1302
+ if (cached && cached.html === html) return cached.tree;
1303
+ const tree = parseServerHtml(html, root.tagName);
1304
+ serverTrees.set(root, { html, tree });
1305
+ return tree;
1306
+ }
1307
+ function reportFromMessage(message, collector, context = {}, options = {}) {
1308
+ const mode = options.locationless ?? "include";
1309
+ const divergences = parseAllHydrationDivergences(message).filter((d) => {
1310
+ if (mode === "include") return true;
1311
+ const bare = isLocationless(d) && collector.classify(d).messageId === "unknown.no-location";
1312
+ return mode === "skip" ? !bare : bare;
1313
+ });
1061
1314
  const component = extractComponentFromMessage(message) ?? context.component;
1062
1315
  let reported = 0;
1063
1316
  for (const divergence of divergences) {
1064
- if (collector.report(divergence, { ...context, component, reactMessage: message }) != null) {
1317
+ if (collector.report(divergence, {
1318
+ ...context,
1319
+ component,
1320
+ reactMessage: message
1321
+ }) != null) {
1065
1322
  reported += 1;
1066
1323
  }
1067
1324
  }
@@ -1070,19 +1327,28 @@ function reportFromMessage(message, collector, context = {}) {
1070
1327
 
1071
1328
  exports.BUILT_IN_RULES = BUILT_IN_RULES;
1072
1329
  exports.CONFIDENCE_THRESHOLD = CONFIDENCE_THRESHOLD;
1330
+ exports.EN_MESSAGES = EN_MESSAGES;
1073
1331
  exports.ReportCollector = ReportCollector;
1074
1332
  exports.UNKNOWN_CAUSE = UNKNOWN_CAUSE;
1333
+ exports.UNKNOWN_NO_LOCATION_CAUSE = UNKNOWN_NO_LOCATION_CAUSE;
1075
1334
  exports.buildReport = buildReport;
1335
+ exports.classDetail = classDetail;
1076
1336
  exports.classify = classify;
1337
+ exports.code = code;
1077
1338
  exports.diffSnapshotAgainstDom = diffSnapshotAgainstDom;
1078
1339
  exports.diffTrees = diffTrees;
1079
1340
  exports.formatConsoleArgs = formatConsoleArgs;
1080
1341
  exports.inspectRoot = inspectRoot;
1081
1342
  exports.isDev = isDev;
1082
1343
  exports.isHydrationMessage = isHydrationMessage;
1344
+ exports.isInternalComponent = isInternalComponent;
1345
+ exports.isLocationless = isLocationless;
1346
+ exports.param = param;
1083
1347
  exports.parseHydrationMessage = parseHydrationMessage;
1084
1348
  exports.parseServerHtml = parseServerHtml;
1349
+ exports.plainText = plainText;
1350
+ exports.renderMessage = renderMessage;
1085
1351
  exports.reportFromMessage = reportFromMessage;
1086
1352
  exports.signatureOf = signatureOf;
1087
- //# sourceMappingURL=chunk-DSO337ME.cjs.map
1088
- //# sourceMappingURL=chunk-DSO337ME.cjs.map
1353
+ //# sourceMappingURL=chunk-KX6G7I4Y.cjs.map
1354
+ //# sourceMappingURL=chunk-KX6G7I4Y.cjs.map