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.
@@ -165,21 +165,21 @@ function diffAttributes(serverEl, clientEl, path, parentTag) {
165
165
  }
166
166
  return null;
167
167
  }
168
- function normalizeAttr(name, value) {
169
- if (value == null) return null;
168
+ function normalizeAttr(name, value2) {
169
+ if (value2 == null) return null;
170
170
  if (name === "class") {
171
- return value.trim().split(/\s+/).filter(Boolean).sort().join(" ");
171
+ return value2.trim().split(/\s+/).filter(Boolean).sort().join(" ");
172
172
  }
173
173
  if (name === "style") {
174
- return normalizeStyle(value);
174
+ return normalizeStyle(value2);
175
175
  }
176
- return value;
176
+ return value2;
177
177
  }
178
- function normalizeStyle(value) {
178
+ function normalizeStyle(value2) {
179
179
  if (typeof document !== "undefined") {
180
180
  try {
181
181
  const el = document.createElement("div");
182
- el.style.cssText = value;
182
+ el.style.cssText = value2;
183
183
  const decls = [];
184
184
  for (let i = 0; i < el.style.length; i++) {
185
185
  const prop = el.style.item(i);
@@ -189,7 +189,7 @@ function normalizeStyle(value) {
189
189
  } catch {
190
190
  }
191
191
  }
192
- return value.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
192
+ return value2.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
193
193
  }
194
194
  function nodeKey(node) {
195
195
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -359,26 +359,26 @@ var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
359
359
  var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
360
360
  var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
361
361
  var LATIN_DIGITS = /[0-9]/;
362
- var BIDI_CONTROLS = /[‎‏؜⁦-⁩]/g;
363
- function stripBidiControls(value) {
364
- return value.replace(BIDI_CONTROLS, "");
362
+ var BIDI_CONTROLS = /[\u200e\u200f\u061c\u2066-\u2069]/g;
363
+ function stripBidiControls(value2) {
364
+ return value2.replace(BIDI_CONTROLS, "");
365
365
  }
366
366
  function differsOnlyByBidiControls(a, b) {
367
367
  const sa = stripBidiControls(a);
368
368
  const sb = stripBidiControls(b);
369
369
  return a !== b && sa === sb && sa.trim() !== "";
370
370
  }
371
- function normalizeNumerals(value) {
372
- return stripBidiControls(value).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
371
+ function normalizeNumerals(value2) {
372
+ return stripBidiControls(value2).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
373
373
  if (ch === "\u066B") return ".";
374
374
  if (ch === "\u066C") return ",";
375
- const code = ch.charCodeAt(0);
376
- const zero = code >= 1776 ? 1776 : 1632;
377
- return String(code - zero);
375
+ const code2 = ch.charCodeAt(0);
376
+ const zero = code2 >= 1776 ? 1776 : 1632;
377
+ return String(code2 - zero);
378
378
  });
379
379
  }
380
- function isRandomLike(value) {
381
- const v = value.trim();
380
+ function isRandomLike(value2) {
381
+ const v = value2.trim();
382
382
  if (!v) return false;
383
383
  if (UUID_RE.test(v)) return true;
384
384
  if (REACT_ID_RE.test(v)) return true;
@@ -387,9 +387,9 @@ function isRandomLike(value) {
387
387
  if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
388
388
  return false;
389
389
  }
390
- function looksLikeTime(value) {
390
+ function looksLikeTime(value2) {
391
391
  return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
392
- normalizeNumerals(value).trim()
392
+ normalizeNumerals(value2).trim()
393
393
  );
394
394
  }
395
395
  var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
@@ -399,8 +399,8 @@ var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/
399
399
  function looksLikeDate(v) {
400
400
  return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
401
401
  }
402
- function toTimestamp(value) {
403
- const v = value.trim();
402
+ function toTimestamp(value2) {
403
+ const v = value2.trim();
404
404
  if (!v) return null;
405
405
  if (/^\d{10,13}$/.test(v)) {
406
406
  const n = Number(v);
@@ -415,11 +415,11 @@ function toTimestamp(value) {
415
415
  }
416
416
  return null;
417
417
  }
418
- function hasArabicIndicDigits(value) {
419
- return ARABIC_INDIC_DIGITS.test(value);
418
+ function hasArabicIndicDigits(value2) {
419
+ return ARABIC_INDIC_DIGITS.test(value2);
420
420
  }
421
- function hasLatinDigits(value) {
422
- return LATIN_DIGITS.test(value);
421
+ function hasLatinDigits(value2) {
422
+ return LATIN_DIGITS.test(value2);
423
423
  }
424
424
  var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
425
425
  function isSameNumberDifferentSeparators(a, b) {
@@ -524,6 +524,163 @@ function messageIndicatesInvalidNesting(message) {
524
524
  return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
525
525
  }
526
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
+
527
684
  // src/core/classify/rules.ts
528
685
  var DOCS_BASE = "https://github.com/razan-aboushi/why-hydration#cause-";
529
686
  function docs(category) {
@@ -546,8 +703,7 @@ var nonDeterministic = (d) => {
546
703
  return {
547
704
  category: "non-deterministic-value",
548
705
  confidence: 0.9,
549
- 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.",
550
- 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"),
551
707
  docsUrl: docs("non-deterministic-value")
552
708
  };
553
709
  };
@@ -565,8 +721,7 @@ var dateTime = (d) => {
565
721
  return {
566
722
  category: "date-time",
567
723
  confidence: smallDelta || bothTimes ? 0.85 : 0.7,
568
- 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.",
569
- 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"),
570
725
  docsUrl: docs("date-time")
571
726
  };
572
727
  };
@@ -578,8 +733,7 @@ var localeFormat = (d) => {
578
733
  return {
579
734
  category: "locale-format",
580
735
  confidence: 0.88,
581
- 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.",
582
- 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.",
736
+ ...describe("locale-format.bidi"),
583
737
  docsUrl: docs("locale-format")
584
738
  };
585
739
  }
@@ -588,8 +742,7 @@ var localeFormat = (d) => {
588
742
  return {
589
743
  category: "locale-format",
590
744
  confidence: 0.92,
591
- 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.",
592
- 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"),
593
746
  docsUrl: docs("locale-format")
594
747
  };
595
748
  }
@@ -597,8 +750,7 @@ var localeFormat = (d) => {
597
750
  return {
598
751
  category: "locale-format",
599
752
  confidence: 0.82,
600
- explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
601
- suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match.",
753
+ ...describe("locale-format.separators"),
602
754
  docsUrl: docs("locale-format")
603
755
  };
604
756
  }
@@ -606,8 +758,7 @@ var localeFormat = (d) => {
606
758
  return {
607
759
  category: "locale-format",
608
760
  confidence: 0.75,
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.",
761
+ ...describe("locale-format.date-order"),
611
762
  docsUrl: docs("locale-format")
612
763
  };
613
764
  }
@@ -621,8 +772,7 @@ var browserOnlyApi = (d) => {
621
772
  return {
622
773
  category: "browser-only-api",
623
774
  confidence: 0.75,
624
- 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.",
625
- 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"),
626
776
  docsUrl: docs("browser-only-api")
627
777
  };
628
778
  };
@@ -636,8 +786,7 @@ var viewportBranching = (d) => {
636
786
  return {
637
787
  category: "viewport-branching",
638
788
  confidence: 0.6,
639
- 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.",
640
- 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"),
641
790
  docsUrl: docs("viewport-branching")
642
791
  };
643
792
  };
@@ -648,8 +797,7 @@ var invalidNesting = (d) => {
648
797
  return {
649
798
  category: "invalid-html-nesting",
650
799
  confidence: byMessage ? 0.9 : 0.72,
651
- 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.",
652
- 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"),
653
801
  docsUrl: docs("invalid-html-nesting")
654
802
  };
655
803
  };
@@ -662,8 +810,7 @@ var whitespaceMinification = (d) => {
662
810
  return {
663
811
  category: "whitespace-minification",
664
812
  confidence: 0.7,
665
- 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.",
666
- suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup.",
813
+ ...describe("whitespace-minification"),
667
814
  docsUrl: docs("whitespace-minification")
668
815
  };
669
816
  };
@@ -673,8 +820,9 @@ var thirdPartyDomMutation = (d) => {
673
820
  return {
674
821
  category: "third-party-dom-mutation",
675
822
  confidence: 0.88,
676
- 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.`,
677
- 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
+ }),
678
826
  docsUrl: docs("third-party-dom-mutation")
679
827
  };
680
828
  }
@@ -684,8 +832,9 @@ var thirdPartyDomMutation = (d) => {
684
832
  return {
685
833
  category: "third-party-dom-mutation",
686
834
  confidence: 0.6,
687
- 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.`,
688
- 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
+ }),
689
838
  docsUrl: docs("third-party-dom-mutation")
690
839
  };
691
840
  }
@@ -695,8 +844,9 @@ var thirdPartyDomMutation = (d) => {
695
844
  return {
696
845
  category: "third-party-dom-mutation",
697
846
  confidence: 0.7,
698
- 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.`,
699
- 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
+ }),
700
850
  docsUrl: docs("third-party-dom-mutation")
701
851
  };
702
852
  }
@@ -712,15 +862,10 @@ var attributeMismatch = (d) => {
712
862
  const clientSet = new Set(client.split(/\s+/).filter(Boolean));
713
863
  const added = [...clientSet].filter((c) => !serverSet.has(c));
714
864
  const removed = [...serverSet].filter((c) => !clientSet.has(c));
715
- const parts = [];
716
- if (added.length) parts.push(`added on client: ${added.join(", ")}`);
717
- if (removed.length) parts.push(`removed on client: ${removed.join(", ")}`);
718
- const detail = parts.length ? ` (${parts.join("; ")})` : "";
719
865
  return {
720
866
  category: "attribute-mismatch",
721
867
  confidence: 0.8,
722
- 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.`,
723
- 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 }),
724
869
  docsUrl: docs("attribute-mismatch")
725
870
  };
726
871
  }
@@ -728,16 +873,18 @@ var attributeMismatch = (d) => {
728
873
  return {
729
874
  category: "attribute-mismatch",
730
875
  confidence: 0.75,
731
- 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.",
732
- 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"),
733
877
  docsUrl: docs("attribute-mismatch")
734
878
  };
735
879
  }
736
880
  return {
737
881
  category: "attribute-mismatch",
738
882
  confidence: 0.6,
739
- 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.`,
740
- 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
+ }),
741
888
  docsUrl: docs("attribute-mismatch")
742
889
  };
743
890
  };
@@ -759,13 +906,21 @@ var BUILT_IN_RULES = [
759
906
  var UNKNOWN_CAUSE = {
760
907
  category: "unknown",
761
908
  confidence: 0,
762
- explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
763
- 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"),
764
916
  docsUrl: docs("unknown")
765
917
  };
766
918
 
767
919
  // src/core/classify/index.ts
768
920
  var CONFIDENCE_THRESHOLD = 0.5;
921
+ function isLocationless(d) {
922
+ return d.server == null && d.client == null && !d.tagName && !d.attribute;
923
+ }
769
924
  function classify(divergence, options = {}) {
770
925
  const threshold = options.threshold ?? CONFIDENCE_THRESHOLD;
771
926
  const rules = [
@@ -783,7 +938,7 @@ function classify(divergence, options = {}) {
783
938
  return result;
784
939
  }
785
940
  }
786
- return UNKNOWN_CAUSE;
941
+ return isLocationless(divergence) ? UNKNOWN_NO_LOCATION_CAUSE : UNKNOWN_CAUSE;
787
942
  }
788
943
 
789
944
  // src/core/report.ts
@@ -792,12 +947,12 @@ function nextId() {
792
947
  counter += 1;
793
948
  return `wh_${Date.now().toString(36)}_${counter}`;
794
949
  }
795
- function truncate(value, max = 300) {
796
- if (value == null) return null;
797
- if (value.length <= max) return value;
798
- const last = value.charCodeAt(max - 1);
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);
799
954
  const end = last >= 55296 && last <= 56319 ? max - 1 : max;
800
- return `${value.slice(0, end)}\u2026`;
955
+ return `${value2.slice(0, end)}\u2026`;
801
956
  }
802
957
  function buildReport(divergence, cause, context = {}) {
803
958
  return {
@@ -857,16 +1012,20 @@ var ReportCollector = class {
857
1012
  getReports() {
858
1013
  return this.reports;
859
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
+ }
860
1022
  get isFull() {
861
1023
  return this.reports.length >= this.maxReports;
862
1024
  }
863
1025
  report(divergence, context = {}) {
864
1026
  if (this.isFull) return null;
865
1027
  if (this.options.ignore?.(divergence)) return null;
866
- const cause = classify(divergence, {
867
- extra: this.options.extra,
868
- threshold: this.options.threshold
869
- });
1028
+ const cause = this.classify(divergence);
870
1029
  const report = buildReport(divergence, cause, context);
871
1030
  const signature = signatureOf(report);
872
1031
  if (this.seen.has(signature)) return null;
@@ -916,11 +1075,19 @@ function extractComponentFromMessage(message) {
916
1075
  }
917
1076
  return names.length ? names[names.length - 1] : void 0;
918
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
+ }
919
1086
  var ATTR_RE = /^([\w:-]+)=(?:"([\s\S]*)"|\{([\s\S]*)\})$/;
920
1087
  function parseModernDiff(message) {
921
1088
  const plus = [];
922
1089
  const minus = [];
923
- for (const raw of message.split("\n")) {
1090
+ for (const raw of diffLines(message)) {
924
1091
  const line = raw.trim();
925
1092
  const p = /^\+\s+(.+)$/.exec(line);
926
1093
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1052,7 +1219,7 @@ function parseAllHydrationDivergences(message) {
1052
1219
  };
1053
1220
  const plus = [];
1054
1221
  const minus = [];
1055
- for (const raw of message.split("\n")) {
1222
+ for (const raw of diffLines(message)) {
1056
1223
  const line = raw.trim();
1057
1224
  const p = /^\+\s+(.+)$/.exec(line);
1058
1225
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1135,8 +1302,13 @@ function serverTreeFor(root, html) {
1135
1302
  serverTrees.set(root, { html, tree });
1136
1303
  return tree;
1137
1304
  }
1138
- function reportFromMessage(message, collector, context = {}) {
1139
- const divergences = parseAllHydrationDivergences(message);
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
+ });
1140
1312
  const component = extractComponentFromMessage(message) ?? context.component;
1141
1313
  let reported = 0;
1142
1314
  for (const divergence of divergences) {
@@ -1151,6 +1323,6 @@ function reportFromMessage(message, collector, context = {}) {
1151
1323
  return reported;
1152
1324
  }
1153
1325
 
1154
- export { BUILT_IN_RULES, CONFIDENCE_THRESHOLD, ReportCollector, UNKNOWN_CAUSE, buildReport, classify, diffSnapshotAgainstDom, diffTrees, formatConsoleArgs, inspectRoot, isDev, isHydrationMessage, parseHydrationMessage, parseServerHtml, reportFromMessage, signatureOf };
1155
- //# sourceMappingURL=chunk-AS5DZZHI.js.map
1156
- //# sourceMappingURL=chunk-AS5DZZHI.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