why-hydration 0.1.5 → 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.
@@ -167,21 +167,21 @@ function diffAttributes(serverEl, clientEl, path, parentTag) {
167
167
  }
168
168
  return null;
169
169
  }
170
- function normalizeAttr(name, value) {
171
- if (value == null) return null;
170
+ function normalizeAttr(name, value2) {
171
+ if (value2 == null) return null;
172
172
  if (name === "class") {
173
- return value.trim().split(/\s+/).filter(Boolean).sort().join(" ");
173
+ return value2.trim().split(/\s+/).filter(Boolean).sort().join(" ");
174
174
  }
175
175
  if (name === "style") {
176
- return normalizeStyle(value);
176
+ return normalizeStyle(value2);
177
177
  }
178
- return value;
178
+ return value2;
179
179
  }
180
- function normalizeStyle(value) {
180
+ function normalizeStyle(value2) {
181
181
  if (typeof document !== "undefined") {
182
182
  try {
183
183
  const el = document.createElement("div");
184
- el.style.cssText = value;
184
+ el.style.cssText = value2;
185
185
  const decls = [];
186
186
  for (let i = 0; i < el.style.length; i++) {
187
187
  const prop = el.style.item(i);
@@ -191,7 +191,7 @@ function normalizeStyle(value) {
191
191
  } catch {
192
192
  }
193
193
  }
194
- return value.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
194
+ return value2.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
195
195
  }
196
196
  function nodeKey(node) {
197
197
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -361,26 +361,26 @@ var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
361
361
  var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
362
362
  var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
363
363
  var LATIN_DIGITS = /[0-9]/;
364
- var BIDI_CONTROLS = /[‎‏؜⁦-⁩]/g;
365
- function stripBidiControls(value) {
366
- return value.replace(BIDI_CONTROLS, "");
364
+ var BIDI_CONTROLS = /[\u200e\u200f\u061c\u2066-\u2069]/g;
365
+ function stripBidiControls(value2) {
366
+ return value2.replace(BIDI_CONTROLS, "");
367
367
  }
368
368
  function differsOnlyByBidiControls(a, b) {
369
369
  const sa = stripBidiControls(a);
370
370
  const sb = stripBidiControls(b);
371
371
  return a !== b && sa === sb && sa.trim() !== "";
372
372
  }
373
- function normalizeNumerals(value) {
374
- return stripBidiControls(value).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
373
+ function normalizeNumerals(value2) {
374
+ return stripBidiControls(value2).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
375
375
  if (ch === "\u066B") return ".";
376
376
  if (ch === "\u066C") return ",";
377
- const code = ch.charCodeAt(0);
378
- const zero = code >= 1776 ? 1776 : 1632;
379
- return String(code - zero);
377
+ const code2 = ch.charCodeAt(0);
378
+ const zero = code2 >= 1776 ? 1776 : 1632;
379
+ return String(code2 - zero);
380
380
  });
381
381
  }
382
- function isRandomLike(value) {
383
- const v = value.trim();
382
+ function isRandomLike(value2) {
383
+ const v = value2.trim();
384
384
  if (!v) return false;
385
385
  if (UUID_RE.test(v)) return true;
386
386
  if (REACT_ID_RE.test(v)) return true;
@@ -389,9 +389,9 @@ function isRandomLike(value) {
389
389
  if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
390
390
  return false;
391
391
  }
392
- function looksLikeTime(value) {
392
+ function looksLikeTime(value2) {
393
393
  return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
394
- normalizeNumerals(value).trim()
394
+ normalizeNumerals(value2).trim()
395
395
  );
396
396
  }
397
397
  var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
@@ -401,8 +401,8 @@ var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/
401
401
  function looksLikeDate(v) {
402
402
  return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
403
403
  }
404
- function toTimestamp(value) {
405
- const v = value.trim();
404
+ function toTimestamp(value2) {
405
+ const v = value2.trim();
406
406
  if (!v) return null;
407
407
  if (/^\d{10,13}$/.test(v)) {
408
408
  const n = Number(v);
@@ -417,11 +417,11 @@ function toTimestamp(value) {
417
417
  }
418
418
  return null;
419
419
  }
420
- function hasArabicIndicDigits(value) {
421
- return ARABIC_INDIC_DIGITS.test(value);
420
+ function hasArabicIndicDigits(value2) {
421
+ return ARABIC_INDIC_DIGITS.test(value2);
422
422
  }
423
- function hasLatinDigits(value) {
424
- return LATIN_DIGITS.test(value);
423
+ function hasLatinDigits(value2) {
424
+ return LATIN_DIGITS.test(value2);
425
425
  }
426
426
  var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
427
427
  function isSameNumberDifferentSeparators(a, b) {
@@ -526,6 +526,163 @@ function messageIndicatesInvalidNesting(message) {
526
526
  return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
527
527
  }
528
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
+
529
686
  // src/core/classify/rules.ts
530
687
  var DOCS_BASE = "https://github.com/razan-aboushi/why-hydration#cause-";
531
688
  function docs(category) {
@@ -548,8 +705,7 @@ var nonDeterministic = (d) => {
548
705
  return {
549
706
  category: "non-deterministic-value",
550
707
  confidence: 0.9,
551
- 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.",
552
- 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"),
553
709
  docsUrl: docs("non-deterministic-value")
554
710
  };
555
711
  };
@@ -567,8 +723,7 @@ var dateTime = (d) => {
567
723
  return {
568
724
  category: "date-time",
569
725
  confidence: smallDelta || bothTimes ? 0.85 : 0.7,
570
- 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.",
571
- 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"),
572
727
  docsUrl: docs("date-time")
573
728
  };
574
729
  };
@@ -580,8 +735,7 @@ var localeFormat = (d) => {
580
735
  return {
581
736
  category: "locale-format",
582
737
  confidence: 0.88,
583
- 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.",
584
- 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.",
738
+ ...describe("locale-format.bidi"),
585
739
  docsUrl: docs("locale-format")
586
740
  };
587
741
  }
@@ -590,8 +744,7 @@ var localeFormat = (d) => {
590
744
  return {
591
745
  category: "locale-format",
592
746
  confidence: 0.92,
593
- 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.",
594
- 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"),
595
748
  docsUrl: docs("locale-format")
596
749
  };
597
750
  }
@@ -599,8 +752,7 @@ var localeFormat = (d) => {
599
752
  return {
600
753
  category: "locale-format",
601
754
  confidence: 0.82,
602
- explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
603
- suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match.",
755
+ ...describe("locale-format.separators"),
604
756
  docsUrl: docs("locale-format")
605
757
  };
606
758
  }
@@ -608,8 +760,7 @@ var localeFormat = (d) => {
608
760
  return {
609
761
  category: "locale-format",
610
762
  confidence: 0.75,
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.",
763
+ ...describe("locale-format.date-order"),
613
764
  docsUrl: docs("locale-format")
614
765
  };
615
766
  }
@@ -623,8 +774,7 @@ var browserOnlyApi = (d) => {
623
774
  return {
624
775
  category: "browser-only-api",
625
776
  confidence: 0.75,
626
- 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.",
627
- 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"),
628
778
  docsUrl: docs("browser-only-api")
629
779
  };
630
780
  };
@@ -638,8 +788,7 @@ var viewportBranching = (d) => {
638
788
  return {
639
789
  category: "viewport-branching",
640
790
  confidence: 0.6,
641
- 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.",
642
- 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"),
643
792
  docsUrl: docs("viewport-branching")
644
793
  };
645
794
  };
@@ -650,8 +799,7 @@ var invalidNesting = (d) => {
650
799
  return {
651
800
  category: "invalid-html-nesting",
652
801
  confidence: byMessage ? 0.9 : 0.72,
653
- 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.",
654
- 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"),
655
803
  docsUrl: docs("invalid-html-nesting")
656
804
  };
657
805
  };
@@ -664,8 +812,7 @@ var whitespaceMinification = (d) => {
664
812
  return {
665
813
  category: "whitespace-minification",
666
814
  confidence: 0.7,
667
- 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.",
668
- suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup.",
815
+ ...describe("whitespace-minification"),
669
816
  docsUrl: docs("whitespace-minification")
670
817
  };
671
818
  };
@@ -675,8 +822,9 @@ var thirdPartyDomMutation = (d) => {
675
822
  return {
676
823
  category: "third-party-dom-mutation",
677
824
  confidence: 0.88,
678
- 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.`,
679
- 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
+ }),
680
828
  docsUrl: docs("third-party-dom-mutation")
681
829
  };
682
830
  }
@@ -686,8 +834,9 @@ var thirdPartyDomMutation = (d) => {
686
834
  return {
687
835
  category: "third-party-dom-mutation",
688
836
  confidence: 0.6,
689
- 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.`,
690
- 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
+ }),
691
840
  docsUrl: docs("third-party-dom-mutation")
692
841
  };
693
842
  }
@@ -697,8 +846,9 @@ var thirdPartyDomMutation = (d) => {
697
846
  return {
698
847
  category: "third-party-dom-mutation",
699
848
  confidence: 0.7,
700
- 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.`,
701
- 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
+ }),
702
852
  docsUrl: docs("third-party-dom-mutation")
703
853
  };
704
854
  }
@@ -714,15 +864,10 @@ var attributeMismatch = (d) => {
714
864
  const clientSet = new Set(client.split(/\s+/).filter(Boolean));
715
865
  const added = [...clientSet].filter((c) => !serverSet.has(c));
716
866
  const removed = [...serverSet].filter((c) => !clientSet.has(c));
717
- const parts = [];
718
- if (added.length) parts.push(`added on client: ${added.join(", ")}`);
719
- if (removed.length) parts.push(`removed on client: ${removed.join(", ")}`);
720
- const detail = parts.length ? ` (${parts.join("; ")})` : "";
721
867
  return {
722
868
  category: "attribute-mismatch",
723
869
  confidence: 0.8,
724
- 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.`,
725
- 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 }),
726
871
  docsUrl: docs("attribute-mismatch")
727
872
  };
728
873
  }
@@ -730,16 +875,18 @@ var attributeMismatch = (d) => {
730
875
  return {
731
876
  category: "attribute-mismatch",
732
877
  confidence: 0.75,
733
- 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.",
734
- 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"),
735
879
  docsUrl: docs("attribute-mismatch")
736
880
  };
737
881
  }
738
882
  return {
739
883
  category: "attribute-mismatch",
740
884
  confidence: 0.6,
741
- 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.`,
742
- 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
+ }),
743
890
  docsUrl: docs("attribute-mismatch")
744
891
  };
745
892
  };
@@ -761,13 +908,21 @@ var BUILT_IN_RULES = [
761
908
  var UNKNOWN_CAUSE = {
762
909
  category: "unknown",
763
910
  confidence: 0,
764
- explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
765
- 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"),
766
918
  docsUrl: docs("unknown")
767
919
  };
768
920
 
769
921
  // src/core/classify/index.ts
770
922
  var CONFIDENCE_THRESHOLD = 0.5;
923
+ function isLocationless(d) {
924
+ return d.server == null && d.client == null && !d.tagName && !d.attribute;
925
+ }
771
926
  function classify(divergence, options = {}) {
772
927
  const threshold = options.threshold ?? CONFIDENCE_THRESHOLD;
773
928
  const rules = [
@@ -785,7 +940,7 @@ function classify(divergence, options = {}) {
785
940
  return result;
786
941
  }
787
942
  }
788
- return UNKNOWN_CAUSE;
943
+ return isLocationless(divergence) ? UNKNOWN_NO_LOCATION_CAUSE : UNKNOWN_CAUSE;
789
944
  }
790
945
 
791
946
  // src/core/report.ts
@@ -794,12 +949,12 @@ function nextId() {
794
949
  counter += 1;
795
950
  return `wh_${Date.now().toString(36)}_${counter}`;
796
951
  }
797
- function truncate(value, max = 300) {
798
- if (value == null) return null;
799
- if (value.length <= max) return value;
800
- const last = value.charCodeAt(max - 1);
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);
801
956
  const end = last >= 55296 && last <= 56319 ? max - 1 : max;
802
- return `${value.slice(0, end)}\u2026`;
957
+ return `${value2.slice(0, end)}\u2026`;
803
958
  }
804
959
  function buildReport(divergence, cause, context = {}) {
805
960
  return {
@@ -859,16 +1014,20 @@ var ReportCollector = class {
859
1014
  getReports() {
860
1015
  return this.reports;
861
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
+ }
862
1024
  get isFull() {
863
1025
  return this.reports.length >= this.maxReports;
864
1026
  }
865
1027
  report(divergence, context = {}) {
866
1028
  if (this.isFull) return null;
867
1029
  if (this.options.ignore?.(divergence)) return null;
868
- const cause = classify(divergence, {
869
- extra: this.options.extra,
870
- threshold: this.options.threshold
871
- });
1030
+ const cause = this.classify(divergence);
872
1031
  const report = buildReport(divergence, cause, context);
873
1032
  const signature = signatureOf(report);
874
1033
  if (this.seen.has(signature)) return null;
@@ -918,11 +1077,19 @@ function extractComponentFromMessage(message) {
918
1077
  }
919
1078
  return names.length ? names[names.length - 1] : void 0;
920
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
+ }
921
1088
  var ATTR_RE = /^([\w:-]+)=(?:"([\s\S]*)"|\{([\s\S]*)\})$/;
922
1089
  function parseModernDiff(message) {
923
1090
  const plus = [];
924
1091
  const minus = [];
925
- for (const raw of message.split("\n")) {
1092
+ for (const raw of diffLines(message)) {
926
1093
  const line = raw.trim();
927
1094
  const p = /^\+\s+(.+)$/.exec(line);
928
1095
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1054,7 +1221,7 @@ function parseAllHydrationDivergences(message) {
1054
1221
  };
1055
1222
  const plus = [];
1056
1223
  const minus = [];
1057
- for (const raw of message.split("\n")) {
1224
+ for (const raw of diffLines(message)) {
1058
1225
  const line = raw.trim();
1059
1226
  const p = /^\+\s+(.+)$/.exec(line);
1060
1227
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1137,8 +1304,13 @@ function serverTreeFor(root, html) {
1137
1304
  serverTrees.set(root, { html, tree });
1138
1305
  return tree;
1139
1306
  }
1140
- function reportFromMessage(message, collector, context = {}) {
1141
- const divergences = parseAllHydrationDivergences(message);
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
+ });
1142
1314
  const component = extractComponentFromMessage(message) ?? context.component;
1143
1315
  let reported = 0;
1144
1316
  for (const divergence of divergences) {
@@ -1155,19 +1327,28 @@ function reportFromMessage(message, collector, context = {}) {
1155
1327
 
1156
1328
  exports.BUILT_IN_RULES = BUILT_IN_RULES;
1157
1329
  exports.CONFIDENCE_THRESHOLD = CONFIDENCE_THRESHOLD;
1330
+ exports.EN_MESSAGES = EN_MESSAGES;
1158
1331
  exports.ReportCollector = ReportCollector;
1159
1332
  exports.UNKNOWN_CAUSE = UNKNOWN_CAUSE;
1333
+ exports.UNKNOWN_NO_LOCATION_CAUSE = UNKNOWN_NO_LOCATION_CAUSE;
1160
1334
  exports.buildReport = buildReport;
1335
+ exports.classDetail = classDetail;
1161
1336
  exports.classify = classify;
1337
+ exports.code = code;
1162
1338
  exports.diffSnapshotAgainstDom = diffSnapshotAgainstDom;
1163
1339
  exports.diffTrees = diffTrees;
1164
1340
  exports.formatConsoleArgs = formatConsoleArgs;
1165
1341
  exports.inspectRoot = inspectRoot;
1166
1342
  exports.isDev = isDev;
1167
1343
  exports.isHydrationMessage = isHydrationMessage;
1344
+ exports.isInternalComponent = isInternalComponent;
1345
+ exports.isLocationless = isLocationless;
1346
+ exports.param = param;
1168
1347
  exports.parseHydrationMessage = parseHydrationMessage;
1169
1348
  exports.parseServerHtml = parseServerHtml;
1349
+ exports.plainText = plainText;
1350
+ exports.renderMessage = renderMessage;
1170
1351
  exports.reportFromMessage = reportFromMessage;
1171
1352
  exports.signatureOf = signatureOf;
1172
- //# sourceMappingURL=chunk-AT6A77X3.cjs.map
1173
- //# sourceMappingURL=chunk-AT6A77X3.cjs.map
1353
+ //# sourceMappingURL=chunk-KX6G7I4Y.cjs.map
1354
+ //# sourceMappingURL=chunk-KX6G7I4Y.cjs.map