why-hydration 0.1.5 → 0.3.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.
@@ -10,6 +10,230 @@ function readNodeEnv() {
10
10
  }
11
11
  var isDev = readNodeEnv() !== "production";
12
12
 
13
+ // src/core/classify/detectors.ts
14
+ var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
15
+ var REACT_ID_RE = /^:[rR][0-9a-z]*:$/;
16
+ var HEX_TOKEN_RE = /^[0-9a-f]{16,}$/i;
17
+ var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
18
+ var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
19
+ var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
20
+ var LATIN_DIGITS = /[0-9]/;
21
+ var BIDI_CONTROLS = /[\u200e\u200f\u061c\u2066-\u2069]/g;
22
+ function stripBidiControls(value2) {
23
+ return value2.replace(BIDI_CONTROLS, "");
24
+ }
25
+ function differsOnlyByBidiControls(a, b) {
26
+ const sa = stripBidiControls(a);
27
+ const sb = stripBidiControls(b);
28
+ return a !== b && sa === sb && sa.trim() !== "";
29
+ }
30
+ function normalizeNumerals(value2) {
31
+ return stripBidiControls(value2).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
32
+ if (ch === "\u066B") return ".";
33
+ if (ch === "\u066C") return ",";
34
+ const code2 = ch.charCodeAt(0);
35
+ const zero = code2 >= 1776 ? 1776 : 1632;
36
+ return String(code2 - zero);
37
+ });
38
+ }
39
+ function isRandomLike(value2) {
40
+ const v = value2.trim();
41
+ if (!v) return false;
42
+ if (UUID_RE.test(v)) return true;
43
+ if (REACT_ID_RE.test(v)) return true;
44
+ if (HEX_TOKEN_RE.test(v)) return true;
45
+ if (RANDOM_DECIMAL_RE.test(v)) return true;
46
+ if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
47
+ return false;
48
+ }
49
+ function looksLikeTime(value2) {
50
+ return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
51
+ normalizeNumerals(value2).trim()
52
+ );
53
+ }
54
+ var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
55
+ var NUMERIC_DATE = /^\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4}$/;
56
+ var TIME_OF_DAY = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?$/;
57
+ var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
58
+ function looksLikeDate(v) {
59
+ return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
60
+ }
61
+ function toTimestamp(value2) {
62
+ const v = value2.trim();
63
+ if (!v) return null;
64
+ if (/^\d{10,13}$/.test(v)) {
65
+ const n = Number(v);
66
+ return v.length === 10 ? n * 1e3 : n;
67
+ }
68
+ if (!looksLikeDate(v)) return null;
69
+ const parsed = Date.parse(v);
70
+ if (!Number.isNaN(parsed)) return parsed;
71
+ if (looksLikeTime(v)) {
72
+ const anchored = Date.parse(`1970-01-01 ${v}`);
73
+ if (!Number.isNaN(anchored)) return anchored;
74
+ }
75
+ return null;
76
+ }
77
+ function hasArabicIndicDigits(value2) {
78
+ return ARABIC_INDIC_DIGITS.test(value2);
79
+ }
80
+ function hasLatinDigits(value2) {
81
+ return LATIN_DIGITS.test(value2);
82
+ }
83
+ var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
84
+ function isSameNumberDifferentSeparators(a, b) {
85
+ const at = normalizeNumerals(a).trim();
86
+ const bt = normalizeNumerals(b).trim();
87
+ if (!NUMERIC_LIKE.test(at) || !NUMERIC_LIKE.test(bt)) return false;
88
+ const digitsOnly = (s) => s.replace(/\D/g, "");
89
+ const da = digitsOnly(at);
90
+ const db = digitsOnly(bt);
91
+ if (!da || da !== db) return false;
92
+ const hasSep = (s) => /[.,\s\u00a0\u2009]/.test(s);
93
+ return (hasSep(at) || hasSep(bt)) && a.trim() !== b.trim();
94
+ }
95
+ var CONTENT_ATTRIBUTES = /* @__PURE__ */ new Set([
96
+ "value",
97
+ "placeholder",
98
+ "title",
99
+ "alt",
100
+ "label",
101
+ "aria-label",
102
+ "aria-valuetext",
103
+ "content",
104
+ "datetime"
105
+ ]);
106
+ function isContentAttribute(name) {
107
+ return CONTENT_ATTRIBUTES.has(name.toLowerCase());
108
+ }
109
+ var THIRD_PARTY_MARKERS = /googlefc|adsbygoogle|google_ads|googletag|__tcfapi|onetrust|optanon|cookiebot|usercentrics|iubenda|didomi|quantcast|grammarly|data-gramm|gtm-|_hjsettings|hotjar|fullstory|intercom|drift|zendesk|livechat|tawk|hubspot|turnstile|recaptcha/i;
110
+ function looksLikeThirdPartyNode(html, tagName) {
111
+ const tag = (tagName ?? "").toUpperCase();
112
+ if (tag === "IFRAME" || tag === "EMBED" || tag === "OBJECT") return true;
113
+ const h = (html ?? "").toLowerCase();
114
+ if (!h) return false;
115
+ return THIRD_PARTY_MARKERS.test(h) || h.includes("about:blank");
116
+ }
117
+ var DATE_PARTS_RE = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/;
118
+ function isSameDateDifferentOrder(a, b) {
119
+ const partsA = normalizeNumerals(a).trim().match(DATE_PARTS_RE);
120
+ const partsB = normalizeNumerals(b).trim().match(DATE_PARTS_RE);
121
+ if (!partsA || !partsB) return false;
122
+ const setA = [partsA[1], partsA[2], partsA[3]].sort().join("|");
123
+ const setB = [partsB[1], partsB[2], partsB[3]].sort().join("|");
124
+ return setA === setB && a.trim() !== b.trim();
125
+ }
126
+ var EXTENSION_ATTRIBUTES = /* @__PURE__ */ new Set([
127
+ "cz-shortcut-listen",
128
+ "data-gramm",
129
+ "data-gramm_editor",
130
+ "data-gramm_id",
131
+ "data-gr-c-s-loaded",
132
+ "data-lt-installed",
133
+ "data-new-gr-c-s-check-loaded",
134
+ "data-new-gr-c-s-loaded",
135
+ "spellcheck-extension",
136
+ "bis_register",
137
+ "__processed_by_react_dev_tools"
138
+ ]);
139
+ function isExtensionAttribute(name) {
140
+ const n = name.toLowerCase();
141
+ if (EXTENSION_ATTRIBUTES.has(n)) return true;
142
+ return n.startsWith("data-gr-") || n.startsWith("data-gramm") || n.startsWith("__bis") || n.startsWith("bis_");
143
+ }
144
+ var CLOSES_P = [
145
+ "address",
146
+ "article",
147
+ "aside",
148
+ "blockquote",
149
+ "center",
150
+ "details",
151
+ "dialog",
152
+ "dir",
153
+ "div",
154
+ "dl",
155
+ "fieldset",
156
+ "figcaption",
157
+ "figure",
158
+ "footer",
159
+ "form",
160
+ "h1",
161
+ "h2",
162
+ "h3",
163
+ "h4",
164
+ "h5",
165
+ "h6",
166
+ "header",
167
+ "hgroup",
168
+ "hr",
169
+ "li",
170
+ "dd",
171
+ "dt",
172
+ "listing",
173
+ "main",
174
+ "menu",
175
+ "nav",
176
+ "ol",
177
+ "p",
178
+ "plaintext",
179
+ "pre",
180
+ "search",
181
+ "section",
182
+ "summary",
183
+ "table",
184
+ "ul",
185
+ "xmp"
186
+ ];
187
+ var CLOSES_P_SET = new Set(CLOSES_P.map((t) => t.toUpperCase()));
188
+ var TABLE_CHILDREN = /* @__PURE__ */ new Set([
189
+ "CAPTION",
190
+ "COLGROUP",
191
+ "THEAD",
192
+ "TBODY",
193
+ "TFOOT"
194
+ ]);
195
+ var TABLE_SCAFFOLD = ["script", "template", "style"];
196
+ var TABLE_LEVEL = ["caption", "colgroup", "thead", "tbody", "tfoot"];
197
+ var SECTION_LEVEL = ["tr"];
198
+ var ROW_LEVEL = ["td", "th"];
199
+ var notIn = (tags) => `:not(${[...tags, ...TABLE_SCAFFOLD].join(", ")})`;
200
+ var PARSER_REPAIRS = {
201
+ P: CLOSES_P.join(", "),
202
+ A: "a",
203
+ BUTTON: "button",
204
+ FORM: "form",
205
+ TABLE: [
206
+ `:scope > ${notIn([...TABLE_LEVEL, ...SECTION_LEVEL])}`,
207
+ ...["thead", "tbody", "tfoot"].flatMap((section) => [
208
+ `:scope > ${section} > ${notIn(SECTION_LEVEL)}`,
209
+ `:scope > ${section} > tr > ${notIn(ROW_LEVEL)}`
210
+ ]),
211
+ `:scope > tr > ${notIn(ROW_LEVEL)}`
212
+ ].join(", ")
213
+ };
214
+ function isInvalidNesting(parentTag, childTag) {
215
+ if (!parentTag || !childTag) return false;
216
+ const p = parentTag.toUpperCase();
217
+ const c = childTag.toUpperCase();
218
+ if (p === "P") return CLOSES_P_SET.has(c);
219
+ if (p === "A") return c === "A";
220
+ if (p === "BUTTON") return c === "BUTTON" || c === "A";
221
+ if (p === "FORM") return c === "FORM";
222
+ if (p === "TABLE") return !TABLE_CHILDREN.has(c) && !isScaffold(c);
223
+ if (p === "THEAD" || p === "TBODY" || p === "TFOOT") {
224
+ return c !== "TR" && !isScaffold(c);
225
+ }
226
+ if (p === "TR") return c !== "TD" && c !== "TH" && !isScaffold(c);
227
+ return false;
228
+ }
229
+ function isScaffold(tag) {
230
+ return TABLE_SCAFFOLD.includes(tag.toLowerCase());
231
+ }
232
+ function messageIndicatesInvalidNesting(message) {
233
+ if (!message) return false;
234
+ return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
235
+ }
236
+
13
237
  // src/core/diff.ts
14
238
  var IGNORED_ATTRIBUTES = /* @__PURE__ */ new Set(["data-reactroot"]);
15
239
  function parseServerHtml(html, rootTagName) {
@@ -36,9 +260,15 @@ function diffSnapshotAgainstDom(serverHtml, clientRoot) {
36
260
  function collectChildren(serverParent, clientParent, parentPath, out, limit) {
37
261
  if (out.length >= limit) return;
38
262
  const serverChildren = meaningfulChildNodes(serverParent);
39
- const clientChildren = meaningfulChildNodes(clientParent);
40
263
  const parentPending = hasPendingSuspense(serverParent);
41
264
  const parentTag = elementTag(clientParent);
265
+ const clientChildren = repairClientChildren(
266
+ meaningfulChildNodes(clientParent),
267
+ parentTag,
268
+ parentPath,
269
+ out,
270
+ limit
271
+ );
42
272
  const pairs = alignChildren(serverChildren, clientChildren);
43
273
  let index = 0;
44
274
  for (let k = 0; k < pairs.length; k++) {
@@ -86,7 +316,10 @@ function collectChildren(serverParent, clientParent, parentPath, out, limit) {
86
316
  parentTagName: parentTag,
87
317
  server: null,
88
318
  client: serialize(clientNode),
89
- element: asElement(clientNode)
319
+ // An added text node has no element of its own, so point at its
320
+ // parent, as text changes do. With `null` the component lookup found
321
+ // nothing and fell back to whichever error was reported last.
322
+ element: asElement(clientNode) ?? asElement(clientParent)
90
323
  });
91
324
  } else if (serverNode && clientNode) {
92
325
  collectNode(serverNode, clientNode, path, parentTag, out, limit);
@@ -132,7 +365,7 @@ function collectNode(serverNode, clientNode, path, parentTag, out, limit) {
132
365
  parentTagName: parentTag,
133
366
  server: serialize(serverEl),
134
367
  client: serialize(clientEl),
135
- element: clientEl
368
+ element: liveElement(clientEl)
136
369
  });
137
370
  return;
138
371
  }
@@ -160,26 +393,26 @@ function diffAttributes(serverEl, clientEl, path, parentTag) {
160
393
  attribute: name,
161
394
  server: serverValue,
162
395
  client: clientValue,
163
- element: clientEl
396
+ element: liveElement(clientEl)
164
397
  };
165
398
  }
166
399
  return null;
167
400
  }
168
- function normalizeAttr(name, value) {
169
- if (value == null) return null;
401
+ function normalizeAttr(name, value2) {
402
+ if (value2 == null) return null;
170
403
  if (name === "class") {
171
- return value.trim().split(/\s+/).filter(Boolean).sort().join(" ");
404
+ return value2.trim().split(/\s+/).filter(Boolean).sort().join(" ");
172
405
  }
173
406
  if (name === "style") {
174
- return normalizeStyle(value);
407
+ return normalizeStyle(value2);
175
408
  }
176
- return value;
409
+ return value2;
177
410
  }
178
- function normalizeStyle(value) {
411
+ function normalizeStyle(value2) {
179
412
  if (typeof document !== "undefined") {
180
413
  try {
181
414
  const el = document.createElement("div");
182
- el.style.cssText = value;
415
+ el.style.cssText = value2;
183
416
  const decls = [];
184
417
  for (let i = 0; i < el.style.length; i++) {
185
418
  const prop = el.style.item(i);
@@ -189,7 +422,7 @@ function normalizeStyle(value) {
189
422
  } catch {
190
423
  }
191
424
  }
192
- return value.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
425
+ return value2.split(";").map((s) => s.trim()).filter(Boolean).sort().join(";");
193
426
  }
194
427
  function nodeKey(node) {
195
428
  if (node.nodeType === Node.ELEMENT_NODE) {
@@ -307,7 +540,8 @@ function meaningfulChildNodes(parent) {
307
540
  for (const node of raw) {
308
541
  if (node.nodeType === Node.COMMENT_NODE) {
309
542
  const data = node.nodeValue ?? "";
310
- if (data === "$?" || data === "$" || data === "$!") boundaryStack.push(data);
543
+ if (data === "$?" || data === "$" || data === "$!")
544
+ boundaryStack.push(data);
311
545
  else if (data === "/$") boundaryStack.pop();
312
546
  continue;
313
547
  }
@@ -330,7 +564,94 @@ function serialize(node) {
330
564
  }
331
565
  function asElement(node) {
332
566
  if (!node) return null;
333
- return node.nodeType === Node.ELEMENT_NODE ? node : null;
567
+ return node.nodeType === Node.ELEMENT_NODE ? liveElement(node) : null;
568
+ }
569
+ var LIVE = /* @__PURE__ */ new WeakMap();
570
+ function liveElement(el) {
571
+ return LIVE.get(el) ?? el;
572
+ }
573
+ function repairClientChildren(children, parentTag, parentPath, out, limit) {
574
+ let repaired = null;
575
+ children.forEach((child, i) => {
576
+ const expansion = repairedForm(child, parentTag);
577
+ if (!expansion) {
578
+ repaired?.push(child);
579
+ return;
580
+ }
581
+ repaired ??= children.slice(0, i);
582
+ repaired.push(...expansion.nodes);
583
+ if (out.length < limit) {
584
+ out.push({
585
+ kind: "structure",
586
+ path: childPath(parentPath, child, i),
587
+ tagName: expansion.offender.tagName,
588
+ parentTagName: invalidParent(expansion.offender, child),
589
+ // No values, on purpose: React reports the same nesting in its own
590
+ // warning with none, and matching it lets the two collapse into one.
591
+ server: null,
592
+ client: null,
593
+ element: expansion.offender
594
+ });
595
+ }
596
+ });
597
+ return repaired ?? children;
598
+ }
599
+ function invalidParent(offender, top) {
600
+ for (let el = offender.parentElement; el; el = el.parentElement) {
601
+ if (isInvalidNesting(el.tagName, offender.tagName)) return el.tagName;
602
+ if (el === top) break;
603
+ }
604
+ return top.tagName;
605
+ }
606
+ function repairedForm(node, parentTag) {
607
+ if (node.nodeType !== Node.ELEMENT_NODE) return null;
608
+ const el = node;
609
+ const selector = PARSER_REPAIRS[el.tagName];
610
+ if (!selector) return null;
611
+ let offender = null;
612
+ try {
613
+ offender = el.querySelector(selector);
614
+ } catch {
615
+ return null;
616
+ }
617
+ if (!offender) return null;
618
+ const html = serializeKeepingTextBoundaries(el);
619
+ const container = parseServerHtml(html, parentTag ?? "div");
620
+ if (container.innerHTML === html) return null;
621
+ const nodes = meaningfulChildNodes(container);
622
+ mapToLive(el, nodes);
623
+ return { nodes, offender };
624
+ }
625
+ function serializeKeepingTextBoundaries(el) {
626
+ const copy = el.cloneNode(true);
627
+ const walker = copy.ownerDocument.createTreeWalker(
628
+ copy,
629
+ NodeFilter.SHOW_TEXT
630
+ );
631
+ const texts = [];
632
+ while (walker.nextNode()) texts.push(walker.currentNode);
633
+ for (const t of texts) {
634
+ if (t.previousSibling?.nodeType === Node.TEXT_NODE) {
635
+ t.parentNode.insertBefore(copy.ownerDocument.createComment(" "), t);
636
+ }
637
+ }
638
+ return copy.outerHTML;
639
+ }
640
+ function mapToLive(live, repaired) {
641
+ const liveEls = [live, ...Array.from(live.querySelectorAll("*"))];
642
+ let i = 0;
643
+ for (const root of repaired) {
644
+ if (root.nodeType !== Node.ELEMENT_NODE) continue;
645
+ const el = root;
646
+ for (const r of [el, ...Array.from(el.querySelectorAll("*"))]) {
647
+ let j = i;
648
+ while (j < liveEls.length && liveEls[j].tagName !== r.tagName) j++;
649
+ if (j < liveEls.length) {
650
+ LIVE.set(r, liveEls[j]);
651
+ i = j + 1;
652
+ }
653
+ }
654
+ }
334
655
  }
335
656
  function elementTag(node) {
336
657
  return node.nodeType === Node.ELEMENT_NODE ? node.tagName : void 0;
@@ -351,177 +672,161 @@ function childPath(parentPath, node, index) {
351
672
  return `${parentPath} > #text[${index}]`;
352
673
  }
353
674
 
354
- // src/core/classify/detectors.ts
355
- var UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
356
- var REACT_ID_RE = /^:[rR][0-9a-z]*:$/;
357
- var HEX_TOKEN_RE = /^[0-9a-f]{16,}$/i;
358
- var RANDOM_DECIMAL_RE = /^0?\.\d{6,}$/;
359
- var NANOID_RE = /^[A-Za-z0-9_-]{10,}$/;
360
- var ARABIC_INDIC_DIGITS = /[٠-٩۰-۹]/;
361
- var LATIN_DIGITS = /[0-9]/;
362
- var BIDI_CONTROLS = /[‎‏؜⁦-⁩]/g;
363
- function stripBidiControls(value) {
364
- return value.replace(BIDI_CONTROLS, "");
675
+ // src/core/classify/messages.ts
676
+ var code = (text) => ({ code: text });
677
+ var value = (text) => ({ value: text });
678
+ function param(params, name) {
679
+ const v = params[name];
680
+ return typeof v === "string" ? v : "";
365
681
  }
366
- function differsOnlyByBidiControls(a, b) {
367
- const sa = stripBidiControls(a);
368
- const sb = stripBidiControls(b);
369
- return a !== b && sa === sb && sa.trim() !== "";
682
+ function paramList(params, name) {
683
+ const v = params[name];
684
+ return Array.isArray(v) ? v : [];
370
685
  }
371
- function normalizeNumerals(value) {
372
- return stripBidiControls(value).replace(/[٠-٩۰-۹٫٬]/g, (ch) => {
373
- if (ch === "\u066B") return ".";
374
- if (ch === "\u066C") return ",";
375
- const code = ch.charCodeAt(0);
376
- const zero = code >= 1776 ? 1776 : 1632;
377
- return String(code - zero);
686
+ function valueList(items, separator) {
687
+ const out = [];
688
+ items.forEach((item, i) => {
689
+ if (i > 0) out.push(separator);
690
+ out.push(value(item));
378
691
  });
692
+ return out;
379
693
  }
380
- function isRandomLike(value) {
381
- const v = value.trim();
382
- if (!v) return false;
383
- if (UUID_RE.test(v)) return true;
384
- if (REACT_ID_RE.test(v)) return true;
385
- if (HEX_TOKEN_RE.test(v)) return true;
386
- if (RANDOM_DECIMAL_RE.test(v)) return true;
387
- if (NANOID_RE.test(v) && /[A-Za-z]/.test(v) && /[0-9]/.test(v)) return true;
388
- return false;
389
- }
390
- function looksLikeTime(value) {
391
- return /\b\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp][Mm])?\b/.test(
392
- normalizeNumerals(value).trim()
393
- );
694
+ function renderMessage(template, params = {}) {
695
+ if (typeof template === "function") return template(params);
696
+ const out = [];
697
+ template.split("`").forEach((part, i) => {
698
+ if (i % 2 === 1) {
699
+ out.push(
700
+ code(part.replace(/\{(\w+)\}/g, (_, n) => param(params, n)))
701
+ );
702
+ return;
703
+ }
704
+ let last = 0;
705
+ for (const m of part.matchAll(/\{(\w+)\}/g)) {
706
+ const at = m.index ?? 0;
707
+ if (at > last) out.push(part.slice(last, at));
708
+ out.push(value(param(params, m[1])));
709
+ last = at + m[0].length;
710
+ }
711
+ if (last < part.length) out.push(part.slice(last));
712
+ });
713
+ return out.filter((s) => s !== "");
394
714
  }
395
- var ISO_DATE = /^\d{4}-\d{1,2}(?:-\d{1,2})?(?:[T ]\d{1,2}:\d{2}(?::\d{2})?)?/;
396
- var NUMERIC_DATE = /^\d{1,4}[/.-]\d{1,2}[/.-]\d{1,4}$/;
397
- var TIME_OF_DAY = /^\d{1,2}:\d{2}(?::\d{2})?(?:\s?[AaPp]\.?[Mm]\.?)?$/;
398
- var MONTH_NAME = /\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)[a-z]*\b/i;
399
- function looksLikeDate(v) {
400
- return ISO_DATE.test(v) || NUMERIC_DATE.test(v) || TIME_OF_DAY.test(v) || MONTH_NAME.test(v) && /\d/.test(v);
715
+ function plainText(segments) {
716
+ return segments.map(
717
+ (s) => typeof s === "string" ? s : "code" in s ? `\`${s.code}\`` : s.value
718
+ ).join("");
401
719
  }
402
- function toTimestamp(value) {
403
- const v = value.trim();
404
- if (!v) return null;
405
- if (/^\d{10,13}$/.test(v)) {
406
- const n = Number(v);
407
- return v.length === 10 ? n * 1e3 : n;
720
+ function classDetail(params, words) {
721
+ const added = paramList(params, "added");
722
+ const removed = paramList(params, "removed");
723
+ if (!added.length && !removed.length) return [];
724
+ const out = [words.open];
725
+ if (added.length) {
726
+ out.push(words.added, ...valueList(added, words.listSeparator));
408
727
  }
409
- if (!looksLikeDate(v)) return null;
410
- const parsed = Date.parse(v);
411
- if (!Number.isNaN(parsed)) return parsed;
412
- if (looksLikeTime(v)) {
413
- const anchored = Date.parse(`1970-01-01 ${v}`);
414
- if (!Number.isNaN(anchored)) return anchored;
728
+ if (removed.length) {
729
+ if (added.length) out.push(words.partSeparator);
730
+ out.push(words.removed, ...valueList(removed, words.listSeparator));
415
731
  }
416
- return null;
417
- }
418
- function hasArabicIndicDigits(value) {
419
- return ARABIC_INDIC_DIGITS.test(value);
420
- }
421
- function hasLatinDigits(value) {
422
- return LATIN_DIGITS.test(value);
423
- }
424
- var NUMERIC_LIKE = /^[+-]?[\d.,\s\u00a0\u2009]+$/;
425
- function isSameNumberDifferentSeparators(a, b) {
426
- const at = normalizeNumerals(a).trim();
427
- const bt = normalizeNumerals(b).trim();
428
- if (!NUMERIC_LIKE.test(at) || !NUMERIC_LIKE.test(bt)) return false;
429
- const digitsOnly = (s) => s.replace(/\D/g, "");
430
- const da = digitsOnly(at);
431
- const db = digitsOnly(bt);
432
- if (!da || da !== db) return false;
433
- const hasSep = (s) => /[.,\s\u00a0\u2009]/.test(s);
434
- return (hasSep(at) || hasSep(bt)) && a.trim() !== b.trim();
435
- }
436
- var CONTENT_ATTRIBUTES = /* @__PURE__ */ new Set([
437
- "value",
438
- "placeholder",
439
- "title",
440
- "alt",
441
- "label",
442
- "aria-label",
443
- "aria-valuetext",
444
- "content",
445
- "datetime"
446
- ]);
447
- function isContentAttribute(name) {
448
- return CONTENT_ATTRIBUTES.has(name.toLowerCase());
449
- }
450
- var THIRD_PARTY_MARKERS = /googlefc|adsbygoogle|google_ads|googletag|__tcfapi|onetrust|optanon|cookiebot|usercentrics|iubenda|didomi|quantcast|grammarly|data-gramm|gtm-|_hjsettings|hotjar|fullstory|intercom|drift|zendesk|livechat|tawk|hubspot|turnstile|recaptcha/i;
451
- function looksLikeThirdPartyNode(html, tagName) {
452
- const tag = (tagName ?? "").toUpperCase();
453
- if (tag === "IFRAME" || tag === "EMBED" || tag === "OBJECT") return true;
454
- const h = (html ?? "").toLowerCase();
455
- if (!h) return false;
456
- return THIRD_PARTY_MARKERS.test(h) || h.includes("about:blank");
457
- }
458
- var DATE_PARTS_RE = /^(\d{1,4})[/.-](\d{1,2})[/.-](\d{1,4})$/;
459
- function isSameDateDifferentOrder(a, b) {
460
- const partsA = normalizeNumerals(a).trim().match(DATE_PARTS_RE);
461
- const partsB = normalizeNumerals(b).trim().match(DATE_PARTS_RE);
462
- if (!partsA || !partsB) return false;
463
- const setA = [partsA[1], partsA[2], partsA[3]].sort().join("|");
464
- const setB = [partsB[1], partsB[2], partsB[3]].sort().join("|");
465
- return setA === setB && a.trim() !== b.trim();
466
- }
467
- var EXTENSION_ATTRIBUTES = /* @__PURE__ */ new Set([
468
- "cz-shortcut-listen",
469
- "data-gramm",
470
- "data-gramm_editor",
471
- "data-gramm_id",
472
- "data-gr-c-s-loaded",
473
- "data-lt-installed",
474
- "data-new-gr-c-s-check-loaded",
475
- "data-new-gr-c-s-loaded",
476
- "spellcheck-extension",
477
- "bis_register",
478
- "__processed_by_react_dev_tools"
479
- ]);
480
- function isExtensionAttribute(name) {
481
- const n = name.toLowerCase();
482
- if (EXTENSION_ATTRIBUTES.has(n)) return true;
483
- return n.startsWith("data-gr-") || n.startsWith("data-gramm") || n.startsWith("__bis") || n.startsWith("bis_");
732
+ out.push(words.close);
733
+ return out;
484
734
  }
485
- var BLOCK_TAGS = /* @__PURE__ */ new Set([
486
- "DIV",
487
- "P",
488
- "SECTION",
489
- "ARTICLE",
490
- "UL",
491
- "OL",
492
- "LI",
493
- "TABLE",
494
- "HEADER",
495
- "FOOTER",
496
- "MAIN",
497
- "ASIDE",
498
- "NAV",
499
- "H1",
500
- "H2",
501
- "H3",
502
- "H4",
503
- "H5",
504
- "H6",
505
- "FORM",
506
- "BLOCKQUOTE",
507
- "PRE",
508
- "HR"
509
- ]);
510
- function isInvalidNesting(parentTag, childTag) {
511
- if (!parentTag || !childTag) return false;
512
- const p = parentTag.toUpperCase();
513
- const c = childTag.toUpperCase();
514
- if (p === "P" && BLOCK_TAGS.has(c)) return true;
515
- if (p === "A" && c === "A") return true;
516
- if (p === "BUTTON" && (c === "BUTTON" || c === "A")) return true;
517
- if ((p === "TABLE" || p === "THEAD" || p === "TBODY") && c === "DIV") {
518
- return true;
735
+ var EN_MESSAGES = {
736
+ "non-deterministic-value": {
737
+ 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.",
738
+ 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."
739
+ },
740
+ "date-time": {
741
+ 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.",
742
+ 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."
743
+ },
744
+ "locale-format.bidi": {
745
+ 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.",
746
+ 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."
747
+ },
748
+ "locale-format.script": {
749
+ 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.",
750
+ 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."
751
+ },
752
+ "locale-format.separators": {
753
+ explanation: "The same number was formatted with different grouping/decimal separators between server and client (e.g. 1,234.56 vs 1.234,56).",
754
+ suggestion: "Pass an explicit locale to `Intl.NumberFormat`/`toLocaleString` on both sides so the separators match."
755
+ },
756
+ "locale-format.date-order": {
757
+ explanation: "The same date was rendered in a different field order (MM/DD vs DD/MM) between server and client.",
758
+ suggestion: "Format dates with an explicit locale and timezone via `Intl` on both sides."
759
+ },
760
+ "browser-only-api": {
761
+ 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.",
762
+ 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."
763
+ },
764
+ "viewport-branching": {
765
+ 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.",
766
+ 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."
767
+ },
768
+ "invalid-html-nesting": {
769
+ 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.",
770
+ 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."
771
+ },
772
+ "whitespace-minification": {
773
+ 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.",
774
+ suggestion: "Check your HTML minifier settings (e.g. `conservativeCollapse`) around the app root, or avoid minifying whitespace inside hydrated markup."
775
+ },
776
+ "third-party-dom-mutation.extension-attribute": {
777
+ 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.",
778
+ suggestion: "This is usually harmless. Add `suppressHydrationWarning` to the affected element, or defer third-party script init until after hydration."
779
+ },
780
+ "third-party-dom-mutation.root-attribute": {
781
+ 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.",
782
+ suggestion: "Add `suppressHydrationWarning` to the root element, or defer the third-party script until after hydration."
783
+ },
784
+ "third-party-dom-mutation.injected-node": {
785
+ 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.",
786
+ 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">`).'
787
+ },
788
+ "attribute-mismatch.class": {
789
+ explanation: (p) => [
790
+ "The ",
791
+ code("class"),
792
+ " differs between server and client",
793
+ ...classDetail(p, {
794
+ added: "added on client: ",
795
+ removed: "removed on client: ",
796
+ listSeparator: ", ",
797
+ partSeparator: "; ",
798
+ open: " (",
799
+ close: ")"
800
+ }),
801
+ ". 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."
802
+ ],
803
+ 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."
804
+ },
805
+ "attribute-mismatch.style": {
806
+ 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.",
807
+ suggestion: "Compute the style after mount (`useEffect`) so the first client render matches the server, or move it to a CSS class / media query."
808
+ },
809
+ "attribute-mismatch.generic": {
810
+ 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.",
811
+ suggestion: "Make the attribute deterministic across server and client, or set it after mount so the first client render matches the server HTML."
812
+ },
813
+ unknown: {
814
+ explanation: "A hydration mismatch was detected but could not be matched to a known cause. Inspect the server vs client values above.",
815
+ suggestion: "Compare the server and client values. Common causes are non-deterministic values, dates/locales, and browser-only APIs used during render."
816
+ },
817
+ "unknown.no-location": {
818
+ explanation: "React reported that hydration failed but did not say which node differed, and the DOM diff found no difference to point at.",
819
+ 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."
519
820
  }
520
- return false;
521
- }
522
- function messageIndicatesInvalidNesting(message) {
523
- if (!message) return false;
524
- return /validateDOMNesting/i.test(message) || /cannot (?:be a|contain).*(?:descendant|child)/i.test(message) || /cannot appear as a (?:child|descendant)/i.test(message);
821
+ };
822
+ function describe(id, params = {}, catalog = EN_MESSAGES) {
823
+ const entry = catalog[id];
824
+ return {
825
+ messageId: id,
826
+ ...Object.keys(params).length ? { params } : {},
827
+ explanation: plainText(renderMessage(entry.explanation, params)),
828
+ suggestion: plainText(renderMessage(entry.suggestion, params))
829
+ };
525
830
  }
526
831
 
527
832
  // src/core/classify/rules.ts
@@ -546,8 +851,7 @@ var nonDeterministic = (d) => {
546
851
  return {
547
852
  category: "non-deterministic-value",
548
853
  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.",
854
+ ...describe("non-deterministic-value"),
551
855
  docsUrl: docs("non-deterministic-value")
552
856
  };
553
857
  };
@@ -565,8 +869,7 @@ var dateTime = (d) => {
565
869
  return {
566
870
  category: "date-time",
567
871
  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.",
872
+ ...describe("date-time"),
570
873
  docsUrl: docs("date-time")
571
874
  };
572
875
  };
@@ -578,8 +881,7 @@ var localeFormat = (d) => {
578
881
  return {
579
882
  category: "locale-format",
580
883
  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.",
884
+ ...describe("locale-format.bidi"),
583
885
  docsUrl: docs("locale-format")
584
886
  };
585
887
  }
@@ -588,8 +890,7 @@ var localeFormat = (d) => {
588
890
  return {
589
891
  category: "locale-format",
590
892
  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.",
893
+ ...describe("locale-format.script"),
593
894
  docsUrl: docs("locale-format")
594
895
  };
595
896
  }
@@ -597,8 +898,7 @@ var localeFormat = (d) => {
597
898
  return {
598
899
  category: "locale-format",
599
900
  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.",
901
+ ...describe("locale-format.separators"),
602
902
  docsUrl: docs("locale-format")
603
903
  };
604
904
  }
@@ -606,8 +906,7 @@ var localeFormat = (d) => {
606
906
  return {
607
907
  category: "locale-format",
608
908
  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.",
909
+ ...describe("locale-format.date-order"),
611
910
  docsUrl: docs("locale-format")
612
911
  };
613
912
  }
@@ -621,8 +920,7 @@ var browserOnlyApi = (d) => {
621
920
  return {
622
921
  category: "browser-only-api",
623
922
  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.",
923
+ ...describe("browser-only-api"),
626
924
  docsUrl: docs("browser-only-api")
627
925
  };
628
926
  };
@@ -636,8 +934,7 @@ var viewportBranching = (d) => {
636
934
  return {
637
935
  category: "viewport-branching",
638
936
  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.",
937
+ ...describe("viewport-branching"),
641
938
  docsUrl: docs("viewport-branching")
642
939
  };
643
940
  };
@@ -648,8 +945,7 @@ var invalidNesting = (d) => {
648
945
  return {
649
946
  category: "invalid-html-nesting",
650
947
  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.",
948
+ ...describe("invalid-html-nesting"),
653
949
  docsUrl: docs("invalid-html-nesting")
654
950
  };
655
951
  };
@@ -662,8 +958,7 @@ var whitespaceMinification = (d) => {
662
958
  return {
663
959
  category: "whitespace-minification",
664
960
  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.",
961
+ ...describe("whitespace-minification"),
667
962
  docsUrl: docs("whitespace-minification")
668
963
  };
669
964
  };
@@ -673,8 +968,9 @@ var thirdPartyDomMutation = (d) => {
673
968
  return {
674
969
  category: "third-party-dom-mutation",
675
970
  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.",
971
+ ...describe("third-party-dom-mutation.extension-attribute", {
972
+ attribute: d.attribute
973
+ }),
678
974
  docsUrl: docs("third-party-dom-mutation")
679
975
  };
680
976
  }
@@ -684,8 +980,9 @@ var thirdPartyDomMutation = (d) => {
684
980
  return {
685
981
  category: "third-party-dom-mutation",
686
982
  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.",
983
+ ...describe("third-party-dom-mutation.root-attribute", {
984
+ attribute: d.attribute
985
+ }),
689
986
  docsUrl: docs("third-party-dom-mutation")
690
987
  };
691
988
  }
@@ -695,8 +992,9 @@ var thirdPartyDomMutation = (d) => {
695
992
  return {
696
993
  category: "third-party-dom-mutation",
697
994
  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">`).',
995
+ ...describe("third-party-dom-mutation.injected-node", {
996
+ tag: `<${tag}>`
997
+ }),
700
998
  docsUrl: docs("third-party-dom-mutation")
701
999
  };
702
1000
  }
@@ -712,15 +1010,10 @@ var attributeMismatch = (d) => {
712
1010
  const clientSet = new Set(client.split(/\s+/).filter(Boolean));
713
1011
  const added = [...clientSet].filter((c) => !serverSet.has(c));
714
1012
  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
1013
  return {
720
1014
  category: "attribute-mismatch",
721
1015
  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.",
1016
+ ...describe("attribute-mismatch.class", { added, removed }),
724
1017
  docsUrl: docs("attribute-mismatch")
725
1018
  };
726
1019
  }
@@ -728,16 +1021,18 @@ var attributeMismatch = (d) => {
728
1021
  return {
729
1022
  category: "attribute-mismatch",
730
1023
  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.",
1024
+ ...describe("attribute-mismatch.style"),
733
1025
  docsUrl: docs("attribute-mismatch")
734
1026
  };
735
1027
  }
736
1028
  return {
737
1029
  category: "attribute-mismatch",
738
1030
  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.",
1031
+ ...describe("attribute-mismatch.generic", {
1032
+ attribute: d.attribute,
1033
+ server,
1034
+ client
1035
+ }),
741
1036
  docsUrl: docs("attribute-mismatch")
742
1037
  };
743
1038
  };
@@ -759,13 +1054,21 @@ var BUILT_IN_RULES = [
759
1054
  var UNKNOWN_CAUSE = {
760
1055
  category: "unknown",
761
1056
  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.",
1057
+ ...describe("unknown"),
1058
+ docsUrl: docs("unknown")
1059
+ };
1060
+ var UNKNOWN_NO_LOCATION_CAUSE = {
1061
+ category: "unknown",
1062
+ confidence: 0,
1063
+ ...describe("unknown.no-location"),
764
1064
  docsUrl: docs("unknown")
765
1065
  };
766
1066
 
767
1067
  // src/core/classify/index.ts
768
1068
  var CONFIDENCE_THRESHOLD = 0.5;
1069
+ function isLocationless(d) {
1070
+ return d.server == null && d.client == null && !d.tagName && !d.attribute;
1071
+ }
769
1072
  function classify(divergence, options = {}) {
770
1073
  const threshold = options.threshold ?? CONFIDENCE_THRESHOLD;
771
1074
  const rules = [
@@ -783,7 +1086,7 @@ function classify(divergence, options = {}) {
783
1086
  return result;
784
1087
  }
785
1088
  }
786
- return UNKNOWN_CAUSE;
1089
+ return isLocationless(divergence) ? UNKNOWN_NO_LOCATION_CAUSE : UNKNOWN_CAUSE;
787
1090
  }
788
1091
 
789
1092
  // src/core/report.ts
@@ -792,12 +1095,12 @@ function nextId() {
792
1095
  counter += 1;
793
1096
  return `wh_${Date.now().toString(36)}_${counter}`;
794
1097
  }
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);
1098
+ function truncate(value2, max = 300) {
1099
+ if (value2 == null) return null;
1100
+ if (value2.length <= max) return value2;
1101
+ const last = value2.charCodeAt(max - 1);
799
1102
  const end = last >= 55296 && last <= 56319 ? max - 1 : max;
800
- return `${value.slice(0, end)}\u2026`;
1103
+ return `${value2.slice(0, end)}\u2026`;
801
1104
  }
802
1105
  function buildReport(divergence, cause, context = {}) {
803
1106
  return {
@@ -835,6 +1138,16 @@ var ReportCollector = class {
835
1138
  this.options = options;
836
1139
  this.maxReports = options.maxReports ?? 25;
837
1140
  }
1141
+ /**
1142
+ * Swap the rules, threshold, ignore list and cap for everything reported
1143
+ * from now on. Reports already collected are kept as they are: they were
1144
+ * correct under the options in force when they were made, and re-deciding
1145
+ * them would re-fire `onReport` for mismatches the caller already saw.
1146
+ */
1147
+ configure(options) {
1148
+ this.options = options;
1149
+ this.maxReports = options.maxReports ?? 25;
1150
+ }
838
1151
  /**
839
1152
  * Register a sink. Pass `replay` for sinks that render *state* (the overlay)
840
1153
  * rather than react to *events* (`onReport`, the console): they need the
@@ -857,16 +1170,20 @@ var ReportCollector = class {
857
1170
  getReports() {
858
1171
  return this.reports;
859
1172
  }
1173
+ /** The cause this collector would assign — same rules, same threshold. */
1174
+ classify(divergence) {
1175
+ return classify(divergence, {
1176
+ extra: this.options.extra,
1177
+ threshold: this.options.threshold
1178
+ });
1179
+ }
860
1180
  get isFull() {
861
1181
  return this.reports.length >= this.maxReports;
862
1182
  }
863
1183
  report(divergence, context = {}) {
864
1184
  if (this.isFull) return null;
865
1185
  if (this.options.ignore?.(divergence)) return null;
866
- const cause = classify(divergence, {
867
- extra: this.options.extra,
868
- threshold: this.options.threshold
869
- });
1186
+ const cause = this.classify(divergence);
870
1187
  const report = buildReport(divergence, cause, context);
871
1188
  const signature = signatureOf(report);
872
1189
  if (this.seen.has(signature)) return null;
@@ -916,11 +1233,19 @@ function extractComponentFromMessage(message) {
916
1233
  }
917
1234
  return names.length ? names[names.length - 1] : void 0;
918
1235
  }
1236
+ function diffLines(message) {
1237
+ const lines = message.split("\n");
1238
+ let link = -1;
1239
+ lines.forEach((line, i) => {
1240
+ if (/react\.dev\/link\/hydration-mismatch/.test(line)) link = i;
1241
+ });
1242
+ return link >= 0 ? lines.slice(link + 1) : lines;
1243
+ }
919
1244
  var ATTR_RE = /^([\w:-]+)=(?:"([\s\S]*)"|\{([\s\S]*)\})$/;
920
1245
  function parseModernDiff(message) {
921
1246
  const plus = [];
922
1247
  const minus = [];
923
- for (const raw of message.split("\n")) {
1248
+ for (const raw of diffLines(message)) {
924
1249
  const line = raw.trim();
925
1250
  const p = /^\+\s+(.+)$/.exec(line);
926
1251
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1052,7 +1377,7 @@ function parseAllHydrationDivergences(message) {
1052
1377
  };
1053
1378
  const plus = [];
1054
1379
  const minus = [];
1055
- for (const raw of message.split("\n")) {
1380
+ for (const raw of diffLines(message)) {
1056
1381
  const line = raw.trim();
1057
1382
  const p = /^\+\s+(.+)$/.exec(line);
1058
1383
  const mn = /^-\s+(.+)$/.exec(line);
@@ -1135,8 +1460,13 @@ function serverTreeFor(root, html) {
1135
1460
  serverTrees.set(root, { html, tree });
1136
1461
  return tree;
1137
1462
  }
1138
- function reportFromMessage(message, collector, context = {}) {
1139
- const divergences = parseAllHydrationDivergences(message);
1463
+ function reportFromMessage(message, collector, context = {}, options = {}) {
1464
+ const mode = options.locationless ?? "include";
1465
+ const divergences = parseAllHydrationDivergences(message).filter((d) => {
1466
+ if (mode === "include") return true;
1467
+ const bare = isLocationless(d) && collector.classify(d).messageId === "unknown.no-location";
1468
+ return mode === "skip" ? !bare : bare;
1469
+ });
1140
1470
  const component = extractComponentFromMessage(message) ?? context.component;
1141
1471
  let reported = 0;
1142
1472
  for (const divergence of divergences) {
@@ -1151,6 +1481,6 @@ function reportFromMessage(message, collector, context = {}) {
1151
1481
  return reported;
1152
1482
  }
1153
1483
 
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
1484
+ 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 };
1485
+ //# sourceMappingURL=chunk-KP4S6CRL.js.map
1486
+ //# sourceMappingURL=chunk-KP4S6CRL.js.map