@pyreon/runtime-dom 0.14.0 → 0.16.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.
@@ -1,4 +1,4 @@
1
- import { EMPTY_PROPS, ForSymbol, Fragment, PortalSymbol, captureContextStack, createRef, cx, dispatchToErrorBoundary, h, makeReactiveProps, normalizeStyleValue, onMount, propagateError, reportError, restoreContextStack, runWithHooks, toKebabCase } from "@pyreon/core";
1
+ import { EMPTY_PROPS, ForSymbol, Fragment, PortalSymbol, captureContextStack, createRef, cx, dispatchToErrorBoundary, h, makeReactiveProps, nativeCompat, normalizeStyleValue, onMount, propagateError, reportError, restoreContextStack, runWithHooks, toKebabCase } from "@pyreon/core";
2
2
  import { batch, effect, effectScope, renderEffect, runUntracked, setCurrentScope } from "@pyreon/reactivity";
3
3
 
4
4
  //#region src/devtools.ts
@@ -33,8 +33,8 @@ function unregisterComponent(id) {
33
33
 
34
34
  //#endregion
35
35
  //#region src/nodes.ts
36
- const __DEV__$2 = import.meta.env?.DEV === true;
37
- const _countSink$1 = globalThis;
36
+ const __DEV__$2 = process.env.NODE_ENV !== "production";
37
+ const _countSink$2 = globalThis;
38
38
  /**
39
39
  * Move all nodes strictly between `start` and `end` into a throwaway
40
40
  * DocumentFragment, detaching them from the live DOM in O(n) top-level moves.
@@ -56,6 +56,10 @@ function clearBetween(start, end) {
56
56
  cur = next;
57
57
  }
58
58
  }
59
+ /** Emit `runtime.cleanup` once per registered mount cleanup that actually runs. */
60
+ function _emitCleanup() {
61
+ if (__DEV__$2) _countSink$2.__pyreon_count__?.("runtime.cleanup");
62
+ }
59
63
  /**
60
64
  * Mount a reactive node whose content changes over time.
61
65
  *
@@ -63,24 +67,34 @@ function clearBetween(start, end) {
63
67
  * On each change: old nodes are removed, new ones inserted before the anchor.
64
68
  */
65
69
  function mountReactive(accessor, parent, anchor, mount) {
70
+ if (__DEV__$2) _countSink$2.__pyreon_count__?.("runtime.mountReactive");
66
71
  const marker = document.createComment("pyreon");
67
72
  parent.insertBefore(marker, anchor);
68
73
  const contextSnapshot = captureContextStack();
69
74
  let currentCleanup = () => {};
75
+ let hasCleanup = false;
70
76
  let generation = 0;
71
77
  const e = effect(() => {
72
78
  const myGen = ++generation;
79
+ if (hasCleanup) _emitCleanup();
73
80
  runUntracked(() => currentCleanup());
74
81
  currentCleanup = () => {};
82
+ hasCleanup = false;
75
83
  const value = accessor();
76
84
  if (value != null && value !== false) {
77
85
  const cleanup = runUntracked(() => restoreContextStack(contextSnapshot, () => mount(value, parent, marker)));
78
- if (myGen === generation) currentCleanup = cleanup;
79
- else cleanup();
86
+ if (myGen === generation) {
87
+ currentCleanup = cleanup;
88
+ hasCleanup = true;
89
+ } else {
90
+ _emitCleanup();
91
+ cleanup();
92
+ }
80
93
  }
81
94
  });
82
95
  return () => {
83
96
  e.dispose();
97
+ if (hasCleanup) _emitCleanup();
84
98
  currentCleanup();
85
99
  marker.parentNode?.removeChild(marker);
86
100
  };
@@ -117,7 +131,7 @@ function computeKeyedLis(lis, n, newKeyOrder, curPos) {
117
131
  if (lo > 0) pred[i] = tailIdx[lo - 1];
118
132
  if (lo === lisLen) lisLen++;
119
133
  }
120
- if (__DEV__$2 && ops > 0) _countSink$1.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
134
+ if (__DEV__$2 && ops > 0) _countSink$2.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
121
135
  return lisLen;
122
136
  }
123
137
  function markStayingEntries(lis, lisLen) {
@@ -180,6 +194,7 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
180
194
  const removeStaleEntries = (newKeySet) => {
181
195
  for (const [key, entry] of cache) {
182
196
  if (newKeySet.has(key)) continue;
197
+ _emitCleanup();
183
198
  entry.cleanup();
184
199
  entry.anchor.parentNode?.removeChild(entry.anchor);
185
200
  cache.delete(key);
@@ -204,28 +219,34 @@ function mountKeyedList(accessor, parent, listAnchor, mountVNode) {
204
219
  const e = effect(() => {
205
220
  const newList = accessor();
206
221
  const n = newList.length;
207
- if (n === 0 && cache.size > 0) {
208
- for (const entry of cache.values()) entry.cleanup();
209
- cache.clear();
222
+ runUntracked(() => {
223
+ if (n === 0 && cache.size > 0) {
224
+ for (const entry of cache.values()) {
225
+ _emitCleanup();
226
+ entry.cleanup();
227
+ }
228
+ cache.clear();
229
+ curPos.clear();
230
+ currentKeyOrder = [];
231
+ clearBetween(startMarker, tailMarker);
232
+ return;
233
+ }
234
+ const { newKeyOrder, newKeySet } = collectKeyOrder(newList);
235
+ removeStaleEntries(newKeySet);
236
+ mountNewEntries(newList);
237
+ if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
210
238
  curPos.clear();
211
- currentKeyOrder = [];
212
- clearBetween(startMarker, tailMarker);
213
- return;
214
- }
215
- const { newKeyOrder, newKeySet } = collectKeyOrder(newList);
216
- removeStaleEntries(newKeySet);
217
- mountNewEntries(newList);
218
- if (currentKeyOrder.length > 0 && n > 0) lis = keyedListReorder(lis, n, newKeyOrder, curPos, cache, parent, tailMarker);
219
- curPos.clear();
220
- for (let i = 0; i < newKeyOrder.length; i++) {
221
- const k = newKeyOrder[i];
222
- if (k !== void 0) curPos.set(k, i);
223
- }
224
- currentKeyOrder = newKeyOrder;
239
+ for (let i = 0; i < newKeyOrder.length; i++) {
240
+ const k = newKeyOrder[i];
241
+ if (k !== void 0) curPos.set(k, i);
242
+ }
243
+ currentKeyOrder = newKeyOrder;
244
+ });
225
245
  });
226
246
  return () => {
227
247
  e.dispose();
228
248
  for (const entry of cache.values()) {
249
+ _emitCleanup();
229
250
  entry.cleanup();
230
251
  entry.anchor.parentNode?.removeChild(entry.anchor);
231
252
  }
@@ -284,7 +305,7 @@ function computeForLis(lis, n, newKeys, cache) {
284
305
  tailIdx[lo] = i;
285
306
  if (lo > 0) pred[i] = tailIdx[lo - 1];
286
307
  }
287
- if (__DEV__$2 && ops > 0) _countSink$1.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
308
+ if (__DEV__$2 && ops > 0) _countSink$2.__pyreon_count__?.("runtime.mountFor.lisOps", ops);
288
309
  return lisLen;
289
310
  }
290
311
  function applyForMoves(n, newKeys, stay, cache, liveParent, tailMarker) {
@@ -402,7 +423,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
402
423
  };
403
424
  const handleReplaceAll = (items, n, newKeys, liveParent) => {
404
425
  if (cleanupCount > 0) {
405
- for (const entry of cache.values()) if (entry.cleanup) entry.cleanup();
426
+ for (const entry of cache.values()) if (entry.cleanup) {
427
+ _emitCleanup();
428
+ entry.cleanup();
429
+ }
406
430
  }
407
431
  cache = /* @__PURE__ */ new Map();
408
432
  cleanupCount = 0;
@@ -427,6 +451,7 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
427
451
  for (const [key, entry] of cache) {
428
452
  if (newKeySet.has(key)) continue;
429
453
  if (entry.cleanup) {
454
+ _emitCleanup();
430
455
  entry.cleanup();
431
456
  cleanupCount--;
432
457
  }
@@ -446,7 +471,10 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
446
471
  const handleFastClear = (liveParent) => {
447
472
  if (cache.size === 0) return;
448
473
  if (cleanupCount > 0) {
449
- for (const entry of cache.values()) if (entry.cleanup) entry.cleanup();
474
+ for (const entry of cache.values()) if (entry.cleanup) {
475
+ _emitCleanup();
476
+ entry.cleanup();
477
+ }
450
478
  }
451
479
  const pp = liveParent.parentNode;
452
480
  if (pp && liveParent.firstChild === startMarker && liveParent.lastChild === tailMarker) {
@@ -484,25 +512,30 @@ function mountFor(source, getKey, renderItem, parent, anchor, mountChild) {
484
512
  if (!liveParent) return;
485
513
  const items = source();
486
514
  const n = items.length;
487
- if (n === 0) {
488
- handleFastClear(liveParent);
489
- return;
490
- }
491
- if (currentKeys.length === 0) {
492
- handleFreshRender(items, n, liveParent);
493
- return;
494
- }
495
- const newKeys = collectNewKeys(items, n);
496
- if (!hasAnyKeptKey(n, newKeys)) {
497
- handleReplaceAll(items, n, newKeys, liveParent);
498
- return;
499
- }
500
- handleIncrementalUpdate(items, n, newKeys, liveParent);
515
+ runUntracked(() => {
516
+ if (n === 0) {
517
+ handleFastClear(liveParent);
518
+ return;
519
+ }
520
+ if (currentKeys.length === 0) {
521
+ handleFreshRender(items, n, liveParent);
522
+ return;
523
+ }
524
+ const newKeys = collectNewKeys(items, n);
525
+ if (!hasAnyKeptKey(n, newKeys)) {
526
+ handleReplaceAll(items, n, newKeys, liveParent);
527
+ return;
528
+ }
529
+ handleIncrementalUpdate(items, n, newKeys, liveParent);
530
+ });
501
531
  });
502
532
  return () => {
503
533
  e.dispose();
504
534
  for (const entry of cache.values()) {
505
- if (cleanupCount > 0 && entry.cleanup) entry.cleanup();
535
+ if (cleanupCount > 0 && entry.cleanup) {
536
+ _emitCleanup();
537
+ entry.cleanup();
538
+ }
506
539
  entry.anchor.parentNode?.removeChild(entry.anchor);
507
540
  }
508
541
  cache = /* @__PURE__ */ new Map();
@@ -605,7 +638,8 @@ function delegatedPropName(eventName) {
605
638
 
606
639
  //#endregion
607
640
  //#region src/props.ts
608
- const __DEV__$1 = import.meta.env?.DEV === true;
641
+ const __DEV__$1 = process.env.NODE_ENV !== "production";
642
+ const _countSink$1 = globalThis;
609
643
  let _customSanitizer = null;
610
644
  const SAFE_TAGS = new Set([
611
645
  "a",
@@ -750,6 +784,7 @@ function applyProps(el, props) {
750
784
  * Bind an event handler (onClick → "click") with batching + delegation support.
751
785
  */
752
786
  function applyEventProp(el, key, value) {
787
+ if (__DEV__$1) _countSink$1.__pyreon_count__?.("runtime.applyEvent");
753
788
  if (typeof value !== "function") {
754
789
  if (__DEV__$1 && value != null) console.warn(`[Pyreon] Event handler "${key}" received a non-function value (${typeof value}). Expected a function. Did you mean ${key}={() => ...}?`);
755
790
  return null;
@@ -796,6 +831,7 @@ function applyStaticProp(el, key, value) {
796
831
  setStaticProp(el, key, value);
797
832
  }
798
833
  function applyProp(el, key, value) {
834
+ if (__DEV__$1) _countSink$1.__pyreon_count__?.("runtime.applyProp");
799
835
  if (EVENT_RE.test(key)) return applyEventProp(el, key, value);
800
836
  if (typeof value === "function") return renderEffect(() => applyStaticProp(el, key, value()));
801
837
  applyStaticProp(el, key, value);
@@ -886,7 +922,7 @@ function setStaticProp(el, key, value) {
886
922
 
887
923
  //#endregion
888
924
  //#region src/mount.ts
889
- const __DEV__ = import.meta.env?.DEV === true;
925
+ const __DEV__ = process.env.NODE_ENV !== "production";
890
926
  const _countSink = globalThis;
891
927
  const noop = () => {};
892
928
  let _elementDepth = 0;
@@ -1320,7 +1356,7 @@ function KeepAlive(props) {
1320
1356
  const e = effect(() => {
1321
1357
  const isActive = props.active?.() ?? true;
1322
1358
  if (!childMounted) {
1323
- childCleanup = mountChild(props.children ?? null, container, null);
1359
+ childCleanup = runUntracked(() => mountChild(props.children ?? null, container, null));
1324
1360
  childMounted = true;
1325
1361
  }
1326
1362
  container.style.display = isActive ? "" : "none";
@@ -1335,6 +1371,7 @@ function KeepAlive(props) {
1335
1371
  style: "display: contents"
1336
1372
  });
1337
1373
  }
1374
+ nativeCompat(KeepAlive);
1338
1375
 
1339
1376
  //#endregion
1340
1377
  export { KeepAlive };
@@ -1,8 +1,8 @@
1
- import { Fragment, createRef, h, onUnmount } from "@pyreon/core";
1
+ import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
2
2
  import { effect, runUntracked, signal } from "@pyreon/reactivity";
3
3
 
4
4
  //#region src/transition.ts
5
- const __DEV__ = import.meta.env?.DEV === true;
5
+ const __DEV__ = process.env.NODE_ENV !== "production";
6
6
  /**
7
7
  * Transition — adds CSS enter/leave animation classes to a single child element,
8
8
  * controlled by the reactive `show` prop.
@@ -161,6 +161,7 @@ function Transition(props) {
161
161
  };
162
162
  });
163
163
  }
164
+ nativeCompat(Transition);
164
165
 
165
166
  //#endregion
166
167
  export { Transition };
@@ -85,17 +85,66 @@ declare function hydrateRoot(container: Element, vnode: VNodeChild): () => void;
85
85
  //#endregion
86
86
  //#region src/hydration-debug.d.ts
87
87
  /**
88
- * Hydration mismatch warnings.
88
+ * Hydration mismatch warnings + telemetry hook.
89
89
  *
90
- * Enabled automatically in development (NODE_ENV !== "production").
91
- * Can be toggled manually for testing or verbose production debugging.
90
+ * Two complementary surfaces:
92
91
  *
93
- * @example
92
+ * 1. **Dev-mode console.warn** — enabled automatically when
93
+ * `NODE_ENV !== "production"` (and silent otherwise, matching React /
94
+ * Vue / Solid). Toggle manually with `enableHydrationWarnings()` /
95
+ * `disableHydrationWarnings()` if you need verbose production debugging.
96
+ *
97
+ * 2. **Telemetry callback** — register a handler with
98
+ * `onHydrationMismatch(handler)` to forward every mismatch into your
99
+ * error-tracking pipeline (Sentry, Datadog, etc.). Fires on EVERY
100
+ * mismatch, in development AND production, regardless of the warn
101
+ * toggle. Returns an unregister function.
102
+ *
103
+ * The dev warn and the telemetry callback are independent: a production
104
+ * deployment can install Sentry forwarding via `onHydrationMismatch`
105
+ * WITHOUT enabling the noisy console output.
106
+ *
107
+ * @example — dev console
94
108
  * import { enableHydrationWarnings } from "@pyreon/runtime-dom"
95
109
  * enableHydrationWarnings()
110
+ *
111
+ * @example — production telemetry
112
+ * import { onHydrationMismatch } from "@pyreon/runtime-dom"
113
+ * import * as Sentry from "@sentry/browser"
114
+ *
115
+ * onHydrationMismatch(ctx => {
116
+ * Sentry.captureMessage(`Hydration mismatch (${ctx.type})`, {
117
+ * extra: { expected: ctx.expected, actual: ctx.actual, path: ctx.path },
118
+ * level: 'warning',
119
+ * })
120
+ * })
96
121
  */
97
122
  declare function enableHydrationWarnings(): void;
98
123
  declare function disableHydrationWarnings(): void;
124
+ type HydrationMismatchType = 'tag' | 'text' | 'missing';
125
+ interface HydrationMismatchContext {
126
+ /** Kind of mismatch */
127
+ type: HydrationMismatchType;
128
+ /** What the VNode expected */
129
+ expected: unknown;
130
+ /** What the DOM had */
131
+ actual: unknown;
132
+ /** Human-readable path in the tree, e.g. "root > div > span" */
133
+ path: string;
134
+ /** Unix timestamp (ms) */
135
+ timestamp: number;
136
+ }
137
+ type HydrationMismatchHandler = (ctx: HydrationMismatchContext) => void;
138
+ /**
139
+ * Register a hydration mismatch handler. Called on every mismatch in BOTH
140
+ * development and production, independent of the dev-mode warn toggle.
141
+ *
142
+ * Mirrors `@pyreon/core`'s `registerErrorHandler` pattern — multiple
143
+ * handlers can be registered; each is called in registration order;
144
+ * handler errors are swallowed so they don't propagate into the
145
+ * framework. Returns an unregister function.
146
+ */
147
+ declare function onHydrationMismatch(handler: HydrationMismatchHandler): () => void;
99
148
  //#endregion
100
149
  //#region src/keep-alive.d.ts
101
150
  interface KeepAliveProps extends Props {
@@ -410,5 +459,5 @@ declare function mount(root: VNodeChild, container: Element): () => void;
410
459
  /** Alias for `mount` */
411
460
  declare const render: typeof mount;
412
461
  //#endregion
413
- export { DELEGATED_EVENTS, type DevtoolsComponentEntry, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, render, sanitizeHtml, setSanitizer, setupDelegation };
462
+ export { DELEGATED_EVENTS, type DevtoolsComponentEntry, type HydrationMismatchContext, type HydrationMismatchHandler, type HydrationMismatchType, KeepAlive, type KeepAliveProps, type PyreonDevtools, type SanitizeFn, Transition, TransitionGroup, type TransitionGroupProps, type TransitionProps, applyProps as _applyProps, applyProps, _bindDirect, _bindText, _mountSlot, _tpl, applyProp, createTemplate, delegatedPropName, disableHydrationWarnings, enableHydrationWarnings, hydrateRoot, mount, mountChild, onHydrationMismatch, render, sanitizeHtml, setSanitizer, setupDelegation };
414
463
  //# sourceMappingURL=index2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "DOM renderer for Pyreon",
5
5
  "homepage": "https://github.com/pyreon/pyreon/tree/main/packages/runtime-dom#readme",
6
6
  "bugs": {
@@ -14,6 +14,7 @@
14
14
  },
15
15
  "files": [
16
16
  "lib",
17
+ "!lib/**/*.map",
17
18
  "src",
18
19
  "README.md",
19
20
  "LICENSE"
@@ -53,15 +54,15 @@
53
54
  "prepublishOnly": "bun run build"
54
55
  },
55
56
  "dependencies": {
56
- "@pyreon/core": "^0.14.0",
57
- "@pyreon/reactivity": "^0.14.0"
57
+ "@pyreon/core": "^0.16.0",
58
+ "@pyreon/reactivity": "^0.16.0"
58
59
  },
59
60
  "devDependencies": {
60
61
  "@happy-dom/global-registrator": "^20.8.9",
61
- "@pyreon/compiler": "^0.14.0",
62
+ "@pyreon/compiler": "^0.16.0",
62
63
  "@pyreon/manifest": "0.13.1",
63
- "@pyreon/runtime-server": "^0.14.0",
64
- "@pyreon/test-utils": "^0.13.2",
64
+ "@pyreon/runtime-server": "^0.16.0",
65
+ "@pyreon/test-utils": "^0.13.3",
65
66
  "@vitest/browser-playwright": "^4.1.4",
66
67
  "esbuild": "^0.28.0",
67
68
  "happy-dom": "^20.8.3",
package/src/delegate.ts CHANGED
@@ -70,6 +70,22 @@ export function setupDelegation(container: Element): void {
70
70
  while (el && el !== container) {
71
71
  const handler = el[prop]
72
72
  if (typeof handler === 'function') {
73
+ // Per-handler `currentTarget` patch: native event delegation leaves
74
+ // `e.currentTarget` as the container (the listener root). Without
75
+ // this override, `ev.currentTarget.value` in user code reads from
76
+ // the container — silently `undefined` for inputs, the wrong tag
77
+ // type, etc. Pyreon's `TargetedEvent<E>` type *promises* the
78
+ // matched element; this override makes the runtime keep that
79
+ // promise, matching what React, Vue, and Solid all do for
80
+ // delegated events.
81
+ //
82
+ // `currentTarget` is a read-only accessor on native Event types,
83
+ // so direct assignment is silently ignored — `Object.defineProperty`
84
+ // with `configurable: true` is the only portable override.
85
+ Object.defineProperty(e, 'currentTarget', {
86
+ value: el,
87
+ configurable: true,
88
+ })
73
89
  batch(() => handler(e))
74
90
  // Don't break — allow ancestor handlers too (consistent with addEventListener)
75
91
  // But if stopPropagation was called, stop walking
package/src/hydrate.ts CHANGED
@@ -21,6 +21,7 @@ import {
21
21
  dispatchToErrorBoundary,
22
22
  ForSymbol,
23
23
  Fragment,
24
+ makeReactiveProps,
24
25
  PortalSymbol,
25
26
  reportError,
26
27
  runWithHooks,
@@ -349,7 +350,7 @@ function hydrateComponent(
349
350
 
350
351
  // Function.name is always a string per spec; || handles empty string, avoids uncoverable ?? branch
351
352
  const componentName = ((vnode.type as ComponentFn).name || 'Anonymous') as string
352
- const mergedProps =
353
+ const rawProps =
353
354
  (vnode.children ?? []).length > 0 &&
354
355
  (vnode.props as Record<string, unknown>).children === undefined
355
356
  ? {
@@ -359,7 +360,13 @@ function hydrateComponent(
359
360
  ? (vnode.children ?? [])[0]
360
361
  : (vnode.children ?? []),
361
362
  }
362
- : vnode.props
363
+ : (vnode.props as Record<string, unknown>)
364
+ // Convert compiler-emitted `_rp(() => expr)` wrappers into getter properties —
365
+ // mirrors mount.ts so component code can read `props.x` and get the resolved
366
+ // value (not the raw `_rp` function). Without this, hydration set up reactive
367
+ // bindings against the wrong values and any signal-driven re-render would
368
+ // diverge from the SSR HTML.
369
+ const mergedProps = makeReactiveProps(rawProps as Record<string, unknown>)
363
370
 
364
371
  let result: ReturnType<typeof runWithHooks>
365
372
  try {
@@ -1,18 +1,42 @@
1
1
  /**
2
- * Hydration mismatch warnings.
2
+ * Hydration mismatch warnings + telemetry hook.
3
3
  *
4
- * Enabled automatically in development (NODE_ENV !== "production").
5
- * Can be toggled manually for testing or verbose production debugging.
4
+ * Two complementary surfaces:
6
5
  *
7
- * @example
6
+ * 1. **Dev-mode console.warn** — enabled automatically when
7
+ * `NODE_ENV !== "production"` (and silent otherwise, matching React /
8
+ * Vue / Solid). Toggle manually with `enableHydrationWarnings()` /
9
+ * `disableHydrationWarnings()` if you need verbose production debugging.
10
+ *
11
+ * 2. **Telemetry callback** — register a handler with
12
+ * `onHydrationMismatch(handler)` to forward every mismatch into your
13
+ * error-tracking pipeline (Sentry, Datadog, etc.). Fires on EVERY
14
+ * mismatch, in development AND production, regardless of the warn
15
+ * toggle. Returns an unregister function.
16
+ *
17
+ * The dev warn and the telemetry callback are independent: a production
18
+ * deployment can install Sentry forwarding via `onHydrationMismatch`
19
+ * WITHOUT enabling the noisy console output.
20
+ *
21
+ * @example — dev console
8
22
  * import { enableHydrationWarnings } from "@pyreon/runtime-dom"
9
23
  * enableHydrationWarnings()
24
+ *
25
+ * @example — production telemetry
26
+ * import { onHydrationMismatch } from "@pyreon/runtime-dom"
27
+ * import * as Sentry from "@sentry/browser"
28
+ *
29
+ * onHydrationMismatch(ctx => {
30
+ * Sentry.captureMessage(`Hydration mismatch (${ctx.type})`, {
31
+ * extra: { expected: ctx.expected, actual: ctx.actual, path: ctx.path },
32
+ * level: 'warning',
33
+ * })
34
+ * })
10
35
  */
11
36
 
12
37
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
13
38
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
14
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
15
- const __DEV__ = import.meta.env?.DEV === true
39
+ const __DEV__ = process.env.NODE_ENV !== 'production'
16
40
 
17
41
  let _enabled = __DEV__
18
42
 
@@ -24,6 +48,43 @@ export function disableHydrationWarnings(): void {
24
48
  _enabled = false
25
49
  }
26
50
 
51
+ // ─── Telemetry callback ─────────────────────────────────────────────────────
52
+
53
+ export type HydrationMismatchType = 'tag' | 'text' | 'missing'
54
+
55
+ export interface HydrationMismatchContext {
56
+ /** Kind of mismatch */
57
+ type: HydrationMismatchType
58
+ /** What the VNode expected */
59
+ expected: unknown
60
+ /** What the DOM had */
61
+ actual: unknown
62
+ /** Human-readable path in the tree, e.g. "root > div > span" */
63
+ path: string
64
+ /** Unix timestamp (ms) */
65
+ timestamp: number
66
+ }
67
+
68
+ export type HydrationMismatchHandler = (ctx: HydrationMismatchContext) => void
69
+
70
+ let _handlers: HydrationMismatchHandler[] = []
71
+
72
+ /**
73
+ * Register a hydration mismatch handler. Called on every mismatch in BOTH
74
+ * development and production, independent of the dev-mode warn toggle.
75
+ *
76
+ * Mirrors `@pyreon/core`'s `registerErrorHandler` pattern — multiple
77
+ * handlers can be registered; each is called in registration order;
78
+ * handler errors are swallowed so they don't propagate into the
79
+ * framework. Returns an unregister function.
80
+ */
81
+ export function onHydrationMismatch(handler: HydrationMismatchHandler): () => void {
82
+ _handlers.push(handler)
83
+ return () => {
84
+ _handlers = _handlers.filter((h) => h !== handler)
85
+ }
86
+ }
87
+
27
88
  /**
28
89
  * Emit a hydration mismatch warning.
29
90
  * @param type - Kind of mismatch
@@ -32,13 +93,37 @@ export function disableHydrationWarnings(): void {
32
93
  * @param path - Human-readable path in the tree, e.g. "root > div > span"
33
94
  */
34
95
  export function warnHydrationMismatch(
35
- _type: 'tag' | 'text' | 'missing',
36
- _expected: unknown,
37
- _actual: unknown,
38
- _path: string,
96
+ type: HydrationMismatchType,
97
+ expected: unknown,
98
+ actual: unknown,
99
+ path: string,
39
100
  ): void {
40
- if (!_enabled) return
41
- console.warn(
42
- `[Pyreon] Hydration mismatch (${_type}): expected ${String(_expected)}, got ${String(_actual)} at ${_path}`,
43
- )
101
+ // Dev-mode console.warn — gated on _enabled (default __DEV__).
102
+ if (_enabled) {
103
+ // oxlint-disable-next-line no-console
104
+ console.warn(
105
+ `[Pyreon] Hydration mismatch (${type}): expected ${String(expected)}, got ${String(actual)} at ${path}`,
106
+ )
107
+ }
108
+
109
+ // Telemetry callbacks — fire in BOTH dev and prod, independent of the
110
+ // warn toggle. This is the production observability hook (Sentry,
111
+ // Datadog, etc.) that pre-fix was missing entirely.
112
+ if (_handlers.length > 0) {
113
+ const ctx: HydrationMismatchContext = {
114
+ type,
115
+ expected,
116
+ actual,
117
+ path,
118
+ timestamp: Date.now(),
119
+ }
120
+ for (const h of _handlers) {
121
+ try {
122
+ h(ctx)
123
+ } catch {
124
+ // handler errors must never propagate back into the hydration
125
+ // pipeline — a broken Sentry SDK shouldn't crash the app.
126
+ }
127
+ }
128
+ }
44
129
  }
package/src/index.ts CHANGED
@@ -3,7 +3,16 @@
3
3
  export { DELEGATED_EVENTS, delegatedPropName, setupDelegation } from './delegate'
4
4
  export type { DevtoolsComponentEntry, PyreonDevtools } from './devtools'
5
5
  export { hydrateRoot } from './hydrate'
6
- export { disableHydrationWarnings, enableHydrationWarnings } from './hydration-debug'
6
+ export type {
7
+ HydrationMismatchContext,
8
+ HydrationMismatchHandler,
9
+ HydrationMismatchType,
10
+ } from './hydration-debug'
11
+ export {
12
+ disableHydrationWarnings,
13
+ enableHydrationWarnings,
14
+ onHydrationMismatch,
15
+ } from './hydration-debug'
7
16
  export type { KeepAliveProps } from './keep-alive'
8
17
  export { KeepAlive } from './keep-alive'
9
18
  export { mountChild } from './mount'
@@ -28,8 +37,7 @@ import { mountChild } from './mount'
28
37
 
29
38
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
30
39
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
31
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
32
- const __DEV__ = import.meta.env?.DEV === true
40
+ const __DEV__ = process.env.NODE_ENV !== 'production'
33
41
 
34
42
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
35
43
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }
package/src/keep-alive.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Props, VNodeChild } from '@pyreon/core'
2
- import { createRef, h, onMount } from '@pyreon/core'
3
- import { effect } from '@pyreon/reactivity'
2
+ import { createRef, h, nativeCompat, onMount } from '@pyreon/core'
3
+ import { effect, runUntracked } from '@pyreon/reactivity'
4
4
  import { mountChild } from './mount'
5
5
 
6
6
  export interface KeepAliveProps extends Props {
@@ -51,8 +51,15 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
51
51
  const isActive = props.active?.() ?? true
52
52
 
53
53
  if (!childMounted) {
54
- // Mount children into the container div exactly once
55
- childCleanup = mountChild(props.children ?? null, container, null)
54
+ // Mount children UNTRACKED child component setup must not
55
+ // subscribe this effect. Otherwise an unrelated signal flip in
56
+ // the children would re-run KeepAlive's effect, runCleanup()
57
+ // would dispose the children's inner effects (because they were
58
+ // collected as inner effects of this run via _innerEffectCollector),
59
+ // and the `if (!childMounted)` guard would skip re-mount → the
60
+ // children become permanently un-reactive while still rendered.
61
+ // Same shape as the mountFor / mountKeyedList fix in nodes.ts.
62
+ childCleanup = runUntracked(() => mountChild(props.children ?? null, container, null))
56
63
  childMounted = true
57
64
  }
58
65
 
@@ -70,3 +77,7 @@ export function KeepAlive(props: KeepAliveProps): VNodeChild {
70
77
  // (children appear as if directly in the parent flow)
71
78
  return h('div', { ref: containerRef, style: 'display: contents' })
72
79
  }
80
+
81
+ // Mark as native so compat-mode jsx() runtimes skip wrapCompatComponent —
82
+ // KeepAlive uses onMount + effect + mountChild that need Pyreon's setup frame.
83
+ nativeCompat(KeepAlive)
package/src/mount.ts CHANGED
@@ -25,8 +25,7 @@ import { applyProps } from './props'
25
25
 
26
26
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
27
27
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
28
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
29
- const __DEV__ = import.meta.env?.DEV === true
28
+ const __DEV__ = process.env.NODE_ENV !== 'production'
30
29
 
31
30
  // Dev-time counter sink — see packages/internals/perf-harness for contract.
32
31
  const _countSink = globalThis as { __pyreon_count__?: (name: string, n?: number) => void }