why-hydration 0.1.4 → 0.1.5

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,62 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 7b16e20: Fix the inspector lifecycle, bound detection cost, and close detection gaps.
8
+
9
+ **Lifecycle**
10
+
11
+ - `createHydrationInspector()` is no longer dead after a React Strict Mode
12
+ remount. Its `<Provider>` only ever stopped the controller, so Strict Mode's
13
+ setup → cleanup → setup left the documented Vite/CRA/Remix integration
14
+ silently doing nothing for the rest of the session.
15
+ - The dev check no longer requires a `process` global. Vite, Rollup and esbuild
16
+ substitute `process.env.NODE_ENV` without defining `process` itself, which
17
+ compiled the old `typeof process !== 'undefined'` guard down to `false` and
18
+ disabled the inspector entirely in those bundlers.
19
+ - Scheduling races an animation frame against a timer, so a page that is hidden
20
+ at load — where the browser suspends `requestAnimationFrame` outright — is
21
+ still inspected.
22
+ - The console capture hands `console.error` back correctly when the same
23
+ listener subscribes twice, and its message cap now drops past-cap messages
24
+ instead of recording-but-forwarding them (which broke dedup exactly when the
25
+ cap was meant to engage).
26
+
27
+ **Cost**
28
+
29
+ - A burst of React warnings arriving in one frame triggers one diff pass rather
30
+ than one per warning.
31
+ - Captured server markup is parsed once per root and reused across the settling
32
+ window instead of being re-parsed on every pass.
33
+ - Retained warning text is bounded, so an app erroring in a render loop cannot
34
+ grow the per-pass cost without limit.
35
+
36
+ **Detection**
37
+
38
+ - `date-time` no longer fires on ordinary values. `Date.parse` accepts almost
39
+ anything — `100` is the year 100, `server-0` is the year 2000 — so prices,
40
+ counts and ids were being diagnosed as a clock or timezone drift. A value must
41
+ now look like a date by shape.
42
+ - Arabic formatting mismatches are detected when both sides use the same digit
43
+ script: grouping/decimal separators, field order and times in Arabic-Indic and
44
+ Persian digits previously fell through to `unknown`.
45
+ - Values differing only by invisible bidirectional control marks (LRM/RLM/ALM,
46
+ isolates) are detected and named. Different ICU versions emit different marks
47
+ for the same `Intl` call, so Node and the browser routinely produce strings
48
+ that look identical and are not.
49
+ - Report values are no longer truncated mid surrogate pair.
50
+
51
+ **Overlay**
52
+
53
+ - A panel re-mounted after being dismissed no longer reports a stale count or
54
+ promise scrolling it cannot do.
55
+
56
+ Note for anyone consuming `report.cause.category` in an `onReport` sink: the
57
+ `date-time` and `locale-format` fixes change which category some mismatches
58
+ resolve to.
59
+
3
60
  ## 0.1.4
4
61
 
5
62
  - **License: restored MIT.** Re-added `LICENSE` (MIT) and set
@@ -93,7 +150,7 @@ Router, React 18 & 19). **0.1.0 is broken in Next.js — use 0.1.1 or later.**
93
150
  the browser-normalized live DOM (`#hex` → `rgb()`, spacing) no longer produce
94
151
  a false `unknown` mismatch.
95
152
  - **`typesVersions`** added so subpath types resolve under `moduleResolution:
96
- "node"` (fixes `next build` type errors in apps using classic resolution).
153
+ "node"` (fixes `next build` type errors in apps using classic resolution).
97
154
 
98
155
  Verified live: locale-format, non-deterministic-value, date-time, and
99
156
  browser-only-api classify correctly in App Router and Pages Router; the tool is
package/README.md CHANGED
@@ -2,7 +2,9 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/why-hydration.svg)](https://www.npmjs.com/package/why-hydration)
4
4
  [![npm downloads](https://img.shields.io/npm/dm/why-hydration.svg)](https://www.npmjs.com/package/why-hydration)
5
- [![minzipped size](https://img.shields.io/bundlephobia/minzip/why-hydration.svg)](https://bundlephobia.com/package/why-hydration)
5
+ [![CI](https://github.com/razan-aboushi/why-hydration/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/razan-aboushi/why-hydration/actions/workflows/ci.yml)
6
+ [![prod bundle: 99 B gzipped](https://img.shields.io/badge/prod%20bundle-99%20B%20gzipped-brightgreen)](#production-behavior)
7
+ [![node: >=18](https://img.shields.io/node/v/why-hydration)](#install)
6
8
  [![license: MIT](https://img.shields.io/npm/l/why-hydration.svg)](LICENSE)
7
9
 
8
10
  📦 **npm:** https://www.npmjs.com/package/why-hydration &nbsp;·&nbsp; 🐙 **GitHub:** https://github.com/razan-aboushi/why-hydration &nbsp;·&nbsp; 💼 **Author:** [Razan Aboushi](https://www.linkedin.com/in/razan-aboushi/)
@@ -288,6 +290,14 @@ React/Next.js's own internal markup markers.
288
290
  misattribute every following sibling. This is a bounded check, not a
289
291
  standing `MutationObserver`, so legitimate DOM changes from your app's own
290
292
  state updates after this window are never mistaken for a hydration issue.
293
+
294
+ The passes are also bounded in cost. React logs its warnings in bursts, so
295
+ every signal that lands in the same frame is coalesced into **one** diff
296
+ rather than one diff each, and the captured server markup — re-materialising
297
+ it is a full HTML parse of your server render — is parsed **once per root**
298
+ and reused across every pass. Scheduling races an animation frame against a
299
+ 50 ms timer, so a page that is hidden at load (where the browser suspends
300
+ `requestAnimationFrame` entirely) still gets inspected.
291
301
  4. **Classify.** Each divergence is passed through an ordered list of rules
292
302
  (see [Cause categories](#cause-categories)); the first rule whose
293
303
  confidence clears the threshold wins, otherwise the mismatch is reported as
@@ -321,6 +331,27 @@ production):
321
331
 
322
332
  A page with no mismatches renders **nothing** — no overlay, no console output.
323
333
 
334
+ ### Controlling the overlay
335
+
336
+ | Action | Effect |
337
+ | ------ | ------ |
338
+ | **Dismiss** button, or **Esc** | Removes the panel for the rest of the page load. |
339
+ | **✕** on the hint bar | Closes just the "scroll to see all" hint; the panel stays. |
340
+ | `overlay={false}` | Never mounts it at all — `onReport` and the console output still work. |
341
+ | `overlay={{ position }}` | `bottom-right` (default), `bottom-left`, `top-right`, `top-left`. |
342
+
343
+ The overlay is a *view* over the collected reports, not the collector itself:
344
+ dismissing it does not stop detection, and `onReport` keeps firing. If a
345
+ mismatch is found after you dismissed it — a late signal, or a second hydration
346
+ error — the panel returns showing that mismatch, starting from a clean count
347
+ rather than resuming a stale one.
348
+
349
+ It renders in an isolated Shadow DOM, is never part of your app's tree, and is
350
+ excluded from its own diff, so it can never be mistaken for a mismatch.
351
+ Mismatched values are rendered as **text**, and "Learn more" links are
352
+ restricted to `http(s)` URLs, so nothing in a mismatched value can inject markup
353
+ or script into the panel.
354
+
324
355
  ### In a real app
325
356
 
326
357
  Captured from a production Next.js app: every mismatch on the page collected
@@ -337,21 +368,54 @@ there are more than fit:
337
368
 
338
369
  ## RTL and Arabic support
339
370
 
340
- The overlay works correctly in apps that render right-to-left, e.g. pages with
341
- `<html dir="rtl">` for Arabic, Hebrew, or other RTL locales:
342
-
343
- - The overlay's own layout **always renders left-to-right**. Its content —
344
- file paths, DOM selectors, code values, category names — is English, so
345
- keeping it LTR keeps it readable regardless of the host page's direction.
346
- - This is automatic. The overlay renders inside an isolated Shadow DOM and
347
- explicitly sets its own `direction`, so it does not inherit `dir="rtl"`
348
- from the host page and does not mirror its layout. No configuration is
349
- needed.
350
- - **Detection itself is locale-agnostic.** The [`locale-format`](#cause-locale-format)
351
- category specifically detects Arabic-Indic vs. Latin digit-script mismatches
352
- (`٠١٢` vs `012`), which is a common real-world source of hydration
353
- mismatches in Arabic-first apps that format numbers with `Intl` or
354
- `toLocaleString` without pinning an explicit locale.
371
+ An Arabic app gets the same diagnosis quality as an English one. That covers
372
+ both how the overlay renders and what the engine can actually detect.
373
+
374
+ ### The overlay
375
+
376
+ - The overlay's own layout **always renders left-to-right**, on any page. Its
377
+ content — file paths, DOM selectors, code values, category names — is
378
+ English, so keeping it LTR keeps it readable regardless of the host page's
379
+ direction.
380
+ - This is automatic and needs no configuration. The overlay lives in an
381
+ isolated Shadow DOM, sets `direction: ltr` on both `:host` and the panel, and
382
+ carries a `dir="ltr"` attribute as well — belt and braces, because the CSS
383
+ `all` shorthand deliberately excludes `direction` (per spec), so
384
+ `:host { all: initial }` alone would still let `direction: rtl` leak in and
385
+ flip the server/client diff columns.
386
+ - Values are rendered as **text**, so Arabic, Hebrew and mixed bidi content
387
+ display intact inside the LTR panel without reordering the surrounding
388
+ layout.
389
+
390
+ ### Detection
391
+
392
+ Detection is direction-agnostic: every rule matches on the *shape* of a value,
393
+ not its script. Three Arabic-specific cases are worth calling out, because the
394
+ first is the one most people expect and the other two are the ones that
395
+ actually bite:
396
+
397
+ - **Different digit scripts.** Arabic-Indic `٠١٢` on one side, Latin `012` on
398
+ the other — the classic symptom of `Intl`/`toLocaleString` resolving to a
399
+ different locale on the server than in the browser. Reported as
400
+ [`locale-format`](#cause-locale-format) at 92% confidence.
401
+ - **Same digit script, different formatting.** An Arabic-first app renders
402
+ Arabic-Indic digits on *both* sides, so there is no script difference to key
403
+ off — only a grouping separator (`١٬٤٠٠` vs `١٤٠٠`), a decimal separator
404
+ (`١٢٣٤٫٥٦` vs `١٢٣٤.٥٦`), a field order, or a time. These are folded to Latin
405
+ before the numeric/date shape tests run, so they are classified exactly like
406
+ their English equivalents instead of falling through to `unknown`. Persian /
407
+ Extended Arabic-Indic digits (`۰۱۲`) are handled the same way.
408
+ - **Invisible bidi marks.** `Intl` wraps numbers and date fields in
409
+ bidirectional control characters (LRM, RLM, ALM, isolates) in RTL locales,
410
+ and *which* ones it emits differs between ICU versions — so Node and the
411
+ browser routinely format the same date into strings that are visually
412
+ identical and byte-different. This is the hardest hydration mismatch to debug
413
+ by eye, since the console diff looks like the same text twice. It is detected
414
+ and named explicitly.
415
+
416
+ Both directions are covered by the test suite as a matched pair
417
+ (`test/i18n.test.tsx`), plus overlay directionality in `test/direction.test.ts`,
418
+ so English and Arabic behaviour cannot drift apart.
355
419
 
356
420
  ---
357
421
 
@@ -414,6 +478,13 @@ interface HydrationInspectorHandle {
414
478
  }
415
479
  ```
416
480
 
481
+ `Provider` owns the inspector's lifetime: it must actually be mounted, and
482
+ unmounting it tears the inspector down (overlay removed, `console.error`
483
+ handed back untouched). Mounting it again restarts detection cleanly rather
484
+ than stacking a second overlay or a second console patch — which is what makes
485
+ it safe under `<React.StrictMode>`, where React deliberately runs every mount
486
+ effect setup → cleanup → setup.
487
+
417
488
  ### `OverlayOptions`
418
489
 
419
490
  ```ts
@@ -508,6 +579,14 @@ Server and client rendered different random-looking values (UUID, token, React
508
579
 
509
580
  Values are dates/times that differ by a small delta — the clock or timezone
510
581
  moved between server and client render.
582
+
583
+ Matched on **shape**, not on whether `Date.parse` happens to accept the value:
584
+ ISO dates, `D/M/YYYY`-style dates, clock times, month names with a number, and
585
+ 10–13 digit epoch timestamps. That distinction matters because `Date.parse` is
586
+ extremely permissive — it reads `100` as the year 100 and `server-0` as the
587
+ year 2000 — so an ordinary price, count, or id would otherwise be diagnosed as
588
+ a clock drift.
589
+
511
590
  **Fix:** render time after mount, or pass one server timestamp down and pin
512
591
  the timezone when formatting.
513
592
  **Reference:** [React — different client/server content](https://react.dev/reference/react-dom/client/hydrateRoot#handling-different-client-and-server-content).
@@ -518,11 +597,21 @@ the timezone when formatting.
518
597
 
519
598
  <img src="docs/screenshots/cause-locale-format.png" alt="locale-format report" width="420">
520
599
 
521
- Same underlying value, different formatting: **Arabic-Indic ٠١٢ vs Latin 012**,
522
- decimal/thousand separators (`1,234.56` vs `1.234,56`), or date field order
523
- (MM/DD vs DD/MM).
600
+ Same underlying value, different formatting. Covers:
601
+
602
+ - **Different digit scripts** — Arabic-Indic `٠١٢` vs Latin `012`, or Persian
603
+ `۰۱۲` vs Latin.
604
+ - **Different separators or field order** — `1,234.56` vs `1.234,56`,
605
+ `١٬٤٠٠` vs `١٤٠٠`, MM/DD vs DD/MM. Detected in Arabic-Indic and Persian
606
+ digits as well as Latin, so an app that renders the same digit script on both
607
+ sides is still diagnosed.
608
+ - **Invisible bidirectional marks** — values that are identical on screen but
609
+ differ by LRM/RLM/ALM or isolate characters, which `Intl` adds around numbers
610
+ and dates in RTL locales and which different ICU versions (Node vs the
611
+ browser) emit differently.
612
+
524
613
  **Fix:** pass an explicit `locale` and timezone to `Intl` on both sides, or
525
- format after mount.
614
+ format after mount. See also [RTL and Arabic support](#rtl-and-arabic-support).
526
615
  **Reference:** [MDN `Intl.NumberFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/NumberFormat).
527
616
 
528
617
  <a id="cause-third-party-dom-mutation"></a>
@@ -628,11 +717,21 @@ above. If you find a reliable signal for it, add a custom rule via the
628
717
  check, written so bundlers can statically fold it away. In a production
629
718
  build, `<HydrationInspector>` becomes a plain pass-through and
630
719
  `createHydrationInspector()` returns no-op functions.
720
+ - Production is **opt-out**, not opt-in: the internal dev check treats an
721
+ explicit `NODE_ENV === 'production'` as production and everything else,
722
+ including a bundle where `process` was never defined at all, as development.
723
+ That matters because Vite, Rollup and esbuild substitute
724
+ `process.env.NODE_ENV` without shimming `process` itself — requiring
725
+ `process` to exist would silently disable the inspector for all of them. It
726
+ cannot leak dev code into a production bundle, because the entry-point gates
727
+ above have already been folded away by then.
631
728
  - This is enforced, not just claimed: the project's CI pipeline runs a size
632
729
  budget (`npm run size`) against a real production bundle built with webpack
633
730
  and terser — the same toolchain Next.js and CRA use for production — and
634
731
  fails the build if the tree-shaken output for any entry point isn't reduced
635
- to a near-empty stub.
732
+ to a near-empty stub. Verified independently against Rollup, which is what
733
+ Vite uses for production builds: **99 B** under webpack, **166 B** under
734
+ Rollup, versus ~40 KB for the same entry built for development.
636
735
  - `<HydrationSnapshotScript>` also renders `null` outside development, so no
637
736
  snapshot script is emitted into your production HTML.
638
737
 
@@ -662,6 +761,16 @@ above. If you find a reliable signal for it, add a custom rule via the
662
761
  - **Bounded, not continuous.** Detection re-checks the DOM a handful of times
663
762
  in the ~1.5 seconds after hydration and then stops — there is no standing
664
763
  `MutationObserver` watching your app for the rest of its lifetime.
764
+ - **Bounded in cost, too.** A burst of React warnings landing in one frame
765
+ triggers one diff pass, not one per warning. The captured server markup is
766
+ parsed once per root and reused across passes rather than re-parsed each
767
+ time. Retained warning text is capped, so an app erroring in a render loop
768
+ cannot grow the per-pass cost without limit, and reports are capped by
769
+ `maxReports` (default 25).
770
+ - **Symmetrical teardown.** Unmounting the inspector restores `console.error`
771
+ to exactly the function it replaced, removes the overlay, and clears every
772
+ pending timer — so a remount (including React Strict Mode's deliberate
773
+ double-mount in dev) leaves one overlay and one console patch, not two.
665
774
 
666
775
  ---
667
776
 
@@ -673,11 +782,25 @@ Remix, or a custom SSR setup. Use `createHydrationInspector` where you own
673
782
  The core engine (`why-hydration`) is framework-agnostic.
674
783
 
675
784
  **Nothing shows up, but I know there's a mismatch.** Confirm `NODE_ENV` isn't
676
- `production`, that `<HydrationInspector>` (or its `Provider`) actually wraps
677
- the part of the tree that mismatches, and that the snapshot script is present
678
- in `<head>` and runs before your app's hydration script. Without the snapshot
679
- script, the tool still reports mismatches it can parse from React's own
680
- console warning, but loses the precise DOM-level diff.
785
+ `production`, that `<HydrationInspector>` (or its `Provider`) is actually
786
+ **mounted** and wraps the part of the tree that mismatches, and that the
787
+ snapshot script is present in `<head>` and runs before your app's hydration
788
+ script. Without the snapshot script, the tool still reports mismatches it can
789
+ parse from React's own console warning, but loses the precise DOM-level diff.
790
+ If you passed `roots`, check the console for a
791
+ `[why-hydration] Skipping root:` warning — a root with no captured server HTML
792
+ can never produce a DOM-level report, and it says so rather than failing
793
+ silently.
794
+
795
+ **Does it work under `<React.StrictMode>`?** Yes. Strict Mode runs every mount
796
+ effect setup → cleanup → setup in development, which tears the inspector down
797
+ and rebuilds it. You get one overlay, one console patch, and each mismatch
798
+ reported once — the warnings React logged during the first pass are replayed
799
+ to the rebuilt inspector rather than lost.
800
+
801
+ **I loaded the page in a background tab and got nothing.** Fixed — browsers
802
+ suspend `requestAnimationFrame` on hidden pages, so scheduling races a frame
803
+ against a 50 ms timer and no longer depends on the page being painted.
681
804
 
682
805
  **The "Learn more →" link 404s.** The links point at this README on GitHub
683
806
  (`github.com/razan-aboushi/why-hydration#cause-…`). If you've forked the
@@ -701,7 +824,15 @@ right after hydration, then stops.
701
824
 
702
825
  **Does it work with `<html dir="rtl">`?** Yes — see
703
826
  [RTL and Arabic support](#rtl-and-arabic-support). The overlay stays
704
- left-to-right on purpose; this is not a bug.
827
+ left-to-right on purpose; this is not a bug. Detection covers Arabic-script
828
+ formatting mismatches on both sides, not just Arabic-vs-Latin.
829
+
830
+ **My Arabic app shows two identical-looking values as a mismatch.** They differ
831
+ by invisible bidirectional control characters — `Intl` adds LRM/RLM/isolate
832
+ marks around numbers and dates in RTL locales, and Node's ICU and the browser's
833
+ ICU do not always agree on which. The report names this explicitly under
834
+ [`locale-format`](#cause-locale-format). Format the value in one place and pass
835
+ the string down, or add `suppressHydrationWarning` if the marks are harmless.
705
836
 
706
837
  ---
707
838
 
@@ -1,12 +1,12 @@
1
- import { isDev, ReportCollector, inspectRoot, reportFromMessage, formatConsoleArgs, isHydrationMessage } from './chunk-WQLUD25W.js';
2
- import { readSnapshot, DEFAULT_SNAPSHOT_SELECTORS } from './chunk-FV3PEJQE.js';
1
+ import { isDev, ReportCollector, inspectRoot, reportFromMessage, formatConsoleArgs, isHydrationMessage } from './chunk-AS5DZZHI.js';
2
+ import { getServerHtmlForRoot, readSnapshot, DEFAULT_SNAPSHOT_SELECTORS } from './chunk-XIM33ZGB.js';
3
3
  import * as React2 from 'react';
4
4
 
5
5
  // src/react/console.ts
6
6
  function installConsoleInterceptor(onMessage) {
7
7
  if (typeof console === "undefined") return () => {
8
8
  };
9
- const original = console.error.bind(console);
9
+ const original = console.error;
10
10
  const patched = (...args) => {
11
11
  try {
12
12
  const message = formatConsoleArgs(args);
@@ -15,7 +15,7 @@ function installConsoleInterceptor(onMessage) {
15
15
  }
16
16
  } catch {
17
17
  }
18
- original(...args);
18
+ original.apply(console, args);
19
19
  };
20
20
  console.error = patched;
21
21
  return () => {
@@ -52,6 +52,32 @@ function createConsoleReporter() {
52
52
  return sink;
53
53
  }
54
54
 
55
+ // src/react/capture.ts
56
+ var MAX_CAPTURED = 50;
57
+ var captured = /* @__PURE__ */ new Set();
58
+ var listeners = /* @__PURE__ */ new Set();
59
+ var uninstall = null;
60
+ function startCapture() {
61
+ if (uninstall || typeof window === "undefined") return;
62
+ uninstall = installConsoleInterceptor((message) => {
63
+ if (captured.has(message) || captured.size >= MAX_CAPTURED) return;
64
+ captured.add(message);
65
+ for (const listener of [...listeners]) listener(message);
66
+ });
67
+ }
68
+ function subscribeCapture(listener) {
69
+ startCapture();
70
+ listeners.add(listener);
71
+ for (const message of [...captured]) listener(message);
72
+ return () => {
73
+ if (!listeners.delete(listener)) return;
74
+ if (listeners.size === 0 && uninstall) {
75
+ uninstall();
76
+ uninstall = null;
77
+ }
78
+ };
79
+ }
80
+
55
81
  // src/react/overlay.ts
56
82
  var CONTAINER_ID = "why-hydration-overlay";
57
83
  var CATEGORY_LABELS = {
@@ -262,6 +288,7 @@ function createOverlay(options = {}) {
262
288
  host.id = CONTAINER_ID;
263
289
  host.setAttribute("data-why-hydration", "overlay");
264
290
  host.setAttribute("aria-hidden", "false");
291
+ host.setAttribute("dir", "ltr");
265
292
  const shadow = host.attachShadow({ mode: "open" });
266
293
  const style = document.createElement("style");
267
294
  style.textContent = STYLES;
@@ -313,6 +340,8 @@ function createOverlay(options = {}) {
313
340
  countEl = null;
314
341
  hintEl = null;
315
342
  hintTextEl = null;
343
+ count = 0;
344
+ hintDismissed = false;
316
345
  }
317
346
  function push(report) {
318
347
  ensureMounted();
@@ -381,6 +410,7 @@ function resolveReactSource(node) {
381
410
  }
382
411
 
383
412
  // src/react/controller.ts
413
+ var INSPECT_FALLBACK_MS = 50;
384
414
  function buildIgnore(ignore) {
385
415
  if (!ignore || ignore.length === 0) return void 0;
386
416
  return (divergence) => {
@@ -404,11 +434,14 @@ var InspectorController = class {
404
434
  this.started = false;
405
435
  this.pendingContext = {};
406
436
  this.messages = /* @__PURE__ */ new Set();
437
+ this.warnedRoots = /* @__PURE__ */ new Set();
407
438
  this.settlingTimers = [];
439
+ this.inspectScheduled = false;
440
+ this.inspectTimer = null;
408
441
  this.onRecoverableError = (error, info) => {
409
442
  if (!isDev) return;
410
443
  const message = error instanceof Error ? error.message : String(error);
411
- this.messages.add(message);
444
+ this.rememberMessage(message);
412
445
  this.mergeContext({
413
446
  componentStack: info?.componentStack,
414
447
  component: firstComponentFromStack(info?.componentStack),
@@ -420,30 +453,31 @@ var InspectorController = class {
420
453
  this.collector = new ReportCollector({
421
454
  maxReports: options.maxReports,
422
455
  extra: options.classify,
423
- ignore: buildIgnore(options.ignore)
456
+ ignore: buildIgnore(options?.ignore)
424
457
  });
425
458
  }
426
459
  start() {
427
460
  if (this.started || !isDev || typeof window === "undefined") return;
428
461
  this.started = true;
429
- this.pendingContext = {};
430
- this.messages.clear();
431
462
  if (this.options.onReport) {
432
- this.collector.addSink(this.options.onReport);
463
+ this.cleanups.push(this.collector.addSink(this.options.onReport));
433
464
  }
434
- this.collector.addSink(createConsoleReporter());
465
+ this.cleanups.push(this.collector.addSink(createConsoleReporter()));
435
466
  if (this.options.overlay !== false) {
436
467
  const overlayOpts = typeof this.options.overlay === "object" ? this.options.overlay : {};
437
468
  const handle = createOverlay(overlayOpts);
438
- this.collector.addSink(handle.push);
439
- this.cleanups.push(() => handle.destroy());
469
+ this.cleanups.push(
470
+ this.collector.addSink(handle.push, { replay: true }),
471
+ () => handle.destroy()
472
+ );
440
473
  }
441
- const restore = installConsoleInterceptor((message) => {
442
- this.messages.add(message);
443
- this.mergeContext({ reactMessage: message });
444
- this.scheduleInspect();
445
- });
446
- this.cleanups.push(restore);
474
+ this.cleanups.push(
475
+ subscribeCapture((message) => {
476
+ this.rememberMessage(message);
477
+ this.mergeContext({ reactMessage: message });
478
+ this.scheduleInspect();
479
+ })
480
+ );
447
481
  this.scheduleSettlingInspections();
448
482
  }
449
483
  // React applies client values to mismatched subtrees via a client re-render a
@@ -458,18 +492,31 @@ var InspectorController = class {
458
492
  this.settlingTimers.push(timer);
459
493
  }
460
494
  }
495
+ rememberMessage(message) {
496
+ if (this.messages.size >= MAX_CAPTURED && !this.messages.has(message)) {
497
+ return;
498
+ }
499
+ this.messages.add(message);
500
+ }
461
501
  mergeContext(ctx) {
462
502
  this.pendingContext = {
463
- componentStack: this.pendingContext.componentStack ?? ctx.componentStack,
464
- component: this.pendingContext.component ?? ctx.component,
465
- location: this.pendingContext.location ?? ctx.location,
466
- reactMessage: this.pendingContext.reactMessage ?? ctx.reactMessage
503
+ componentStack: ctx.componentStack ?? this.pendingContext.componentStack,
504
+ component: ctx.component ?? this.pendingContext.component,
505
+ location: ctx.location ?? this.pendingContext.location,
506
+ reactMessage: ctx.reactMessage ?? this.pendingContext.reactMessage
507
+ };
508
+ }
509
+ domContext() {
510
+ return {
511
+ componentStack: this.pendingContext.componentStack,
512
+ component: this.pendingContext.component,
513
+ location: this.pendingContext.location
467
514
  };
468
515
  }
469
516
  stop() {
470
517
  for (const timer of this.settlingTimers.splice(0)) clearTimeout(timer);
518
+ this.clearScheduledInspect();
471
519
  for (const cleanup of this.cleanups.splice(0)) cleanup();
472
- this.messages.clear();
473
520
  this.started = false;
474
521
  }
475
522
  getReports() {
@@ -483,16 +530,31 @@ var InspectorController = class {
483
530
  if (!this.started || typeof document === "undefined" || this.collector.isFull) {
484
531
  return;
485
532
  }
486
- const selectors = this.options.roots ?? snapshotSelectors();
533
+ const configured = this.options.roots;
534
+ const selectors = configured ?? snapshotSelectors();
535
+ const context = this.domContext();
487
536
  const seen = /* @__PURE__ */ new Set();
488
537
  for (const selector of selectors) {
489
- const root = document.querySelector(selector);
538
+ let root = null;
539
+ try {
540
+ root = document.querySelector(selector);
541
+ } catch {
542
+ this.warnRoot(selector, `"${selector}" is not a valid CSS selector.`);
543
+ continue;
544
+ }
490
545
  if (!root || seen.has(root)) continue;
491
546
  seen.add(root);
547
+ if (configured && getServerHtmlForRoot(root) == null) {
548
+ this.warnRoot(
549
+ selector,
550
+ `no server HTML was captured for "${selector}". Add it to the \`selectors\` prop of <HydrationSnapshotScript> so the server markup for that root is snapshotted.`
551
+ );
552
+ continue;
553
+ }
492
554
  inspectRoot(
493
555
  root,
494
556
  this.collector,
495
- this.pendingContext,
557
+ context,
496
558
  (divergence) => resolveReactSource(divergence.element ?? null)
497
559
  );
498
560
  }
@@ -500,12 +562,27 @@ var InspectorController = class {
500
562
  reportFromMessage(message, this.collector, this.pendingContext);
501
563
  }
502
564
  }
565
+ warnRoot(selector, detail) {
566
+ if (this.warnedRoots.has(selector)) return;
567
+ this.warnedRoots.add(selector);
568
+ console.warn(`[why-hydration] Skipping root: ${detail}`);
569
+ }
503
570
  scheduleInspect() {
504
- const run = () => this.inspectAllRoots();
505
- if (typeof requestAnimationFrame === "function") {
506
- requestAnimationFrame(run);
507
- } else {
508
- Promise.resolve().then(run);
571
+ if (this.inspectScheduled) return;
572
+ this.inspectScheduled = true;
573
+ const run = () => {
574
+ if (!this.inspectScheduled) return;
575
+ this.clearScheduledInspect();
576
+ this.inspectAllRoots();
577
+ };
578
+ if (typeof requestAnimationFrame === "function") requestAnimationFrame(run);
579
+ this.inspectTimer = setTimeout(run, INSPECT_FALLBACK_MS);
580
+ }
581
+ clearScheduledInspect() {
582
+ this.inspectScheduled = false;
583
+ if (this.inspectTimer != null) {
584
+ clearTimeout(this.inspectTimer);
585
+ this.inspectTimer = null;
509
586
  }
510
587
  }
511
588
  };
@@ -521,20 +598,13 @@ function firstComponentFromStack(stack) {
521
598
  }
522
599
  function InspectorImpl(props) {
523
600
  const { children, overlay, onReport, ignore, classify, maxReports } = props;
524
- const controllerRef = React2.useRef(null);
525
- if (controllerRef.current === null) {
526
- controllerRef.current = new InspectorController({
527
- overlay,
528
- onReport,
529
- ignore,
530
- classify,
531
- maxReports
532
- });
533
- controllerRef.current.start();
534
- }
601
+ startCapture();
602
+ const optionsRef = React2.useRef({});
603
+ optionsRef.current = { overlay, onReport, ignore, classify, maxReports };
535
604
  React2.useEffect(() => {
536
- const controller = controllerRef.current;
537
- return () => controller?.stop();
605
+ const controller = new InspectorController(optionsRef.current);
606
+ controller.start();
607
+ return () => controller.stop();
538
608
  }, []);
539
609
  return React2.createElement(React2.Fragment, null, children);
540
610
  }
@@ -555,7 +625,10 @@ function createHydrationInspector(options = {}) {
555
625
  const controller = new InspectorController(options);
556
626
  controller.start();
557
627
  function Provider(props) {
558
- React2.useEffect(() => () => controller.stop(), []);
628
+ React2.useEffect(() => {
629
+ controller.start();
630
+ return () => controller.stop();
631
+ }, []);
559
632
  return React2.createElement(React2.Fragment, null, props.children);
560
633
  }
561
634
  return {
@@ -565,5 +638,5 @@ function createHydrationInspector(options = {}) {
565
638
  }
566
639
 
567
640
  export { HydrationInspector, createHydrationInspector };
568
- //# sourceMappingURL=chunk-3KULWJ7A.js.map
569
- //# sourceMappingURL=chunk-3KULWJ7A.js.map
641
+ //# sourceMappingURL=chunk-3I6F4K4L.js.map
642
+ //# sourceMappingURL=chunk-3I6F4K4L.js.map