@pyreon/runtime-dom 0.13.1 → 0.15.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.
@@ -0,0 +1,168 @@
1
+ import { Fragment, createRef, h, nativeCompat, onUnmount } from "@pyreon/core";
2
+ import { effect, runUntracked, signal } from "@pyreon/reactivity";
3
+
4
+ //#region src/transition.ts
5
+ const __DEV__ = process.env.NODE_ENV !== "production";
6
+ /**
7
+ * Transition — adds CSS enter/leave animation classes to a single child element,
8
+ * controlled by the reactive `show` prop.
9
+ *
10
+ * Class lifecycle:
11
+ * Enter: {name}-enter-from → (next frame) → {name}-enter-active + {name}-enter-to → cleanup
12
+ * Leave: {name}-leave-from → (next frame) → {name}-leave-active + {name}-leave-to → unmount
13
+ *
14
+ * The child element stays in the DOM during the leave animation and is removed only
15
+ * after the CSS transition / animation completes.
16
+ *
17
+ * @example
18
+ * const visible = signal(false)
19
+ *
20
+ * h(Transition, { name: "fade", show: () => visible() },
21
+ * h("div", { class: "modal" }, "content")
22
+ * )
23
+ *
24
+ * // CSS:
25
+ * // .fade-enter-from, .fade-leave-to { opacity: 0; }
26
+ * // .fade-enter-active, .fade-leave-active { transition: opacity 300ms ease; }
27
+ */
28
+ function Transition(props) {
29
+ const n = props.name ?? "pyreon";
30
+ const cls = {
31
+ ef: props.enterFrom ?? `${n}-enter-from`,
32
+ ea: props.enterActive ?? `${n}-enter-active`,
33
+ et: props.enterTo ?? `${n}-enter-to`,
34
+ lf: props.leaveFrom ?? `${n}-leave-from`,
35
+ la: props.leaveActive ?? `${n}-leave-active`,
36
+ lt: props.leaveTo ?? `${n}-leave-to`
37
+ };
38
+ const ref = createRef();
39
+ const isMounted = signal(runUntracked(props.show));
40
+ let pendingEnterCancel = null;
41
+ let pendingLeaveCancel = null;
42
+ let initialized = false;
43
+ const applyEnter = (el) => {
44
+ pendingLeaveCancel?.();
45
+ pendingLeaveCancel = null;
46
+ pendingEnterCancel?.();
47
+ pendingEnterCancel = null;
48
+ props.onBeforeEnter?.(el);
49
+ el.classList.remove(cls.lf, cls.la, cls.lt);
50
+ el.classList.add(cls.ef, cls.ea);
51
+ requestAnimationFrame(() => {
52
+ el.classList.remove(cls.ef);
53
+ el.classList.add(cls.et);
54
+ let safetyTimer = null;
55
+ const done = () => {
56
+ el.removeEventListener("transitionend", done);
57
+ el.removeEventListener("animationend", done);
58
+ if (safetyTimer !== null) {
59
+ clearTimeout(safetyTimer);
60
+ safetyTimer = null;
61
+ }
62
+ pendingEnterCancel = null;
63
+ el.classList.remove(cls.ea, cls.et);
64
+ props.onAfterEnter?.(el);
65
+ };
66
+ pendingEnterCancel = () => {
67
+ el.removeEventListener("transitionend", done);
68
+ el.removeEventListener("animationend", done);
69
+ if (safetyTimer !== null) {
70
+ clearTimeout(safetyTimer);
71
+ safetyTimer = null;
72
+ }
73
+ el.classList.remove(cls.ef, cls.ea, cls.et);
74
+ };
75
+ el.addEventListener("transitionend", done, { once: true });
76
+ el.addEventListener("animationend", done, { once: true });
77
+ safetyTimer = setTimeout(done, 5e3);
78
+ });
79
+ };
80
+ const applyLeave = (el) => {
81
+ pendingEnterCancel?.();
82
+ pendingEnterCancel = null;
83
+ props.onBeforeLeave?.(el);
84
+ el.classList.remove(cls.ef, cls.ea, cls.et);
85
+ el.classList.add(cls.lf, cls.la);
86
+ requestAnimationFrame(() => {
87
+ el.classList.remove(cls.lf);
88
+ el.classList.add(cls.lt);
89
+ let safetyTimer = null;
90
+ const done = () => {
91
+ el.removeEventListener("transitionend", done);
92
+ el.removeEventListener("animationend", done);
93
+ if (safetyTimer !== null) {
94
+ clearTimeout(safetyTimer);
95
+ safetyTimer = null;
96
+ }
97
+ el.classList.remove(cls.la, cls.lt);
98
+ pendingLeaveCancel = null;
99
+ isMounted.set(false);
100
+ props.onAfterLeave?.(el);
101
+ };
102
+ pendingLeaveCancel = () => {
103
+ el.removeEventListener("transitionend", done);
104
+ el.removeEventListener("animationend", done);
105
+ if (safetyTimer !== null) {
106
+ clearTimeout(safetyTimer);
107
+ safetyTimer = null;
108
+ }
109
+ el.classList.remove(cls.lf, cls.la, cls.lt);
110
+ };
111
+ el.addEventListener("transitionend", done, { once: true });
112
+ el.addEventListener("animationend", done, { once: true });
113
+ safetyTimer = setTimeout(done, 5e3);
114
+ });
115
+ };
116
+ const handleVisibilityChange = (visible) => {
117
+ if (visible) {
118
+ if (!isMounted.peek()) isMounted.set(true);
119
+ queueMicrotask(() => applyEnter(ref.current));
120
+ return;
121
+ }
122
+ if (!isMounted.peek()) return;
123
+ const el = ref.current;
124
+ if (!el) {
125
+ isMounted.set(false);
126
+ return;
127
+ }
128
+ applyLeave(el);
129
+ };
130
+ effect(() => {
131
+ const visible = props.show();
132
+ if (!initialized) {
133
+ initialized = true;
134
+ if (visible && props.appear) queueMicrotask(() => applyEnter(ref.current));
135
+ return;
136
+ }
137
+ handleVisibilityChange(visible);
138
+ });
139
+ onUnmount(() => {
140
+ pendingEnterCancel?.();
141
+ pendingEnterCancel = null;
142
+ pendingLeaveCancel?.();
143
+ pendingLeaveCancel = null;
144
+ });
145
+ const rawChild = props.children;
146
+ const emptyFragment = h(Fragment, null);
147
+ return (() => {
148
+ if (!isMounted()) return emptyFragment;
149
+ if (!rawChild || typeof rawChild !== "object" || Array.isArray(rawChild)) return rawChild ?? null;
150
+ const vnode = rawChild;
151
+ if (typeof vnode.type !== "string") {
152
+ if (__DEV__) console.warn("[Pyreon] Transition child is a component. Wrap it in a DOM element for enter/leave animations to work.");
153
+ return vnode;
154
+ }
155
+ return {
156
+ ...vnode,
157
+ props: {
158
+ ...vnode.props,
159
+ ref
160
+ }
161
+ };
162
+ });
163
+ }
164
+ nativeCompat(Transition);
165
+
166
+ //#endregion
167
+ export { Transition };
168
+ //# sourceMappingURL=transition-entry.js.map
@@ -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
@@ -0,0 +1,41 @@
1
+ import { Props, VNodeChild } from "@pyreon/core";
2
+
3
+ //#region src/keep-alive.d.ts
4
+ interface KeepAliveProps extends Props {
5
+ /**
6
+ * Accessor that returns true when this slot's children should be visible.
7
+ * When false, children are CSS-hidden but remain mounted — effects and
8
+ * signals stay alive.
9
+ * Defaults to true (always visible / always mounted).
10
+ */
11
+ active?: () => boolean;
12
+ children?: VNodeChild;
13
+ }
14
+ /**
15
+ * KeepAlive — mounts its children once and keeps them alive even when hidden.
16
+ *
17
+ * Unlike conditional rendering (which destroys and recreates component state),
18
+ * KeepAlive CSS-hides the children while preserving all reactive state,
19
+ * scroll position, form values, and in-flight async operations.
20
+ *
21
+ * Children are mounted imperatively on first activation and are never unmounted
22
+ * while the KeepAlive itself is mounted.
23
+ *
24
+ * Multi-slot pattern (one KeepAlive per route):
25
+ * @example
26
+ * h(Fragment, null, [
27
+ * h(KeepAlive, { active: () => route() === "/a" }, h(RouteA, null)),
28
+ * h(KeepAlive, { active: () => route() === "/b" }, h(RouteB, null)),
29
+ * ])
30
+ *
31
+ * With JSX:
32
+ * @example
33
+ * <>
34
+ * <KeepAlive active={() => route() === "/a"}><RouteA /></KeepAlive>
35
+ * <KeepAlive active={() => route() === "/b"}><RouteB /></KeepAlive>
36
+ * </>
37
+ */
38
+ declare function KeepAlive(props: KeepAliveProps): VNodeChild;
39
+ //#endregion
40
+ export { KeepAlive, KeepAliveProps };
41
+ //# sourceMappingURL=keep-alive-entry2.d.ts.map
@@ -0,0 +1,59 @@
1
+ import { VNodeChild } from "@pyreon/core";
2
+
3
+ //#region src/transition.d.ts
4
+ interface TransitionProps {
5
+ /**
6
+ * CSS class name prefix.
7
+ * "fade" → fade-enter-from, fade-enter-active, fade-enter-to, fade-leave-from, …
8
+ * Default: "pyreon"
9
+ */
10
+ name?: string;
11
+ /** Reactive boolean controlling whether the child is shown. */
12
+ show: () => boolean;
13
+ /**
14
+ * If true, runs the enter transition on the initial mount (instead of
15
+ * appearing immediately). Default: false.
16
+ */
17
+ appear?: boolean;
18
+ enterFrom?: string;
19
+ enterActive?: string;
20
+ enterTo?: string;
21
+ leaveFrom?: string;
22
+ leaveActive?: string;
23
+ leaveTo?: string;
24
+ onBeforeEnter?: (el: HTMLElement) => void;
25
+ onAfterEnter?: (el: HTMLElement) => void;
26
+ onBeforeLeave?: (el: HTMLElement) => void;
27
+ onAfterLeave?: (el: HTMLElement) => void;
28
+ /**
29
+ * The single child element to animate.
30
+ * Must be a direct DOM element VNode (not a component) for class injection to work.
31
+ */
32
+ children?: VNodeChild;
33
+ }
34
+ /**
35
+ * Transition — adds CSS enter/leave animation classes to a single child element,
36
+ * controlled by the reactive `show` prop.
37
+ *
38
+ * Class lifecycle:
39
+ * Enter: {name}-enter-from → (next frame) → {name}-enter-active + {name}-enter-to → cleanup
40
+ * Leave: {name}-leave-from → (next frame) → {name}-leave-active + {name}-leave-to → unmount
41
+ *
42
+ * The child element stays in the DOM during the leave animation and is removed only
43
+ * after the CSS transition / animation completes.
44
+ *
45
+ * @example
46
+ * const visible = signal(false)
47
+ *
48
+ * h(Transition, { name: "fade", show: () => visible() },
49
+ * h("div", { class: "modal" }, "content")
50
+ * )
51
+ *
52
+ * // CSS:
53
+ * // .fade-enter-from, .fade-leave-to { opacity: 0; }
54
+ * // .fade-enter-active, .fade-leave-active { transition: opacity 300ms ease; }
55
+ */
56
+ declare function Transition(props: TransitionProps): VNodeChild;
57
+ //#endregion
58
+ export { Transition, TransitionProps };
59
+ //# sourceMappingURL=transition-entry2.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/runtime-dom",
3
- "version": "0.13.1",
3
+ "version": "0.15.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"
@@ -28,6 +29,16 @@
28
29
  "bun": "./src/index.ts",
29
30
  "import": "./lib/index.js",
30
31
  "types": "./lib/types/index.d.ts"
32
+ },
33
+ "./transition": {
34
+ "bun": "./src/transition-entry.ts",
35
+ "import": "./lib/transition-entry.js",
36
+ "types": "./lib/types/transition-entry.d.ts"
37
+ },
38
+ "./keep-alive": {
39
+ "bun": "./src/keep-alive-entry.ts",
40
+ "import": "./lib/keep-alive-entry.js",
41
+ "types": "./lib/types/keep-alive-entry.d.ts"
31
42
  }
32
43
  },
33
44
  "publishConfig": {
@@ -43,15 +54,15 @@
43
54
  "prepublishOnly": "bun run build"
44
55
  },
45
56
  "dependencies": {
46
- "@pyreon/core": "^0.13.1",
47
- "@pyreon/reactivity": "^0.13.1"
57
+ "@pyreon/core": "^0.15.0",
58
+ "@pyreon/reactivity": "^0.15.0"
48
59
  },
49
60
  "devDependencies": {
50
61
  "@happy-dom/global-registrator": "^20.8.9",
51
- "@pyreon/compiler": "^0.13.1",
62
+ "@pyreon/compiler": "^0.15.0",
52
63
  "@pyreon/manifest": "0.13.1",
53
- "@pyreon/runtime-server": "^0.13.1",
54
- "@pyreon/test-utils": "^0.13.1",
64
+ "@pyreon/runtime-server": "^0.15.0",
65
+ "@pyreon/test-utils": "^0.13.2",
55
66
  "@vitest/browser-playwright": "^4.1.4",
56
67
  "esbuild": "^0.28.0",
57
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 {
@@ -384,8 +391,8 @@ function hydrateComponent(
384
391
  const { vnode: output, hooks } = result
385
392
 
386
393
  // Register onUpdate hooks with the scope
387
- for (const fn of hooks.update) {
388
- scope.addUpdateHook(fn)
394
+ if (hooks.update) {
395
+ for (const fn of hooks.update) scope.addUpdateHook(fn)
389
396
  }
390
397
 
391
398
  if (output != null) {
@@ -395,22 +402,24 @@ function hydrateComponent(
395
402
  }
396
403
 
397
404
  // Fire onMount hooks; effects created inside are tracked by the scope via runInScope
398
- for (const fn of hooks.mount) {
399
- try {
400
- let c: (() => void) | undefined
401
- scope.runInScope(() => {
402
- c = fn() as (() => void) | undefined
403
- })
404
- if (c) mountCleanups.push(c)
405
- } catch (err) {
406
- reportError({ component: componentName, phase: 'mount', error: err, timestamp: Date.now() })
405
+ if (hooks.mount) {
406
+ for (const fn of hooks.mount) {
407
+ try {
408
+ let c: (() => void) | undefined
409
+ scope.runInScope(() => {
410
+ c = fn() as (() => void) | undefined
411
+ })
412
+ if (c) mountCleanups.push(c)
413
+ } catch (err) {
414
+ reportError({ component: componentName, phase: 'mount', error: err, timestamp: Date.now() })
415
+ }
407
416
  }
408
417
  }
409
418
 
410
419
  const cleanup: Cleanup = () => {
411
420
  scope.stop()
412
421
  subtreeCleanup()
413
- for (const fn of hooks.unmount) fn()
422
+ if (hooks.unmount) for (const fn of hooks.unmount) fn()
414
423
  for (const fn of mountCleanups) fn()
415
424
  }
416
425
 
@@ -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
  }