@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.
- package/README.md +23 -0
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +5406 -0
- package/lib/analysis/transition-entry.js.html +5406 -0
- package/lib/index.js +156 -57
- package/lib/keep-alive-entry.js +1342 -0
- package/lib/transition-entry.js +168 -0
- package/lib/types/index.d.ts +54 -5
- package/lib/types/keep-alive-entry.d.ts +41 -0
- package/lib/types/transition-entry.d.ts +59 -0
- package/package.json +17 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +23 -14
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +30 -6
- package/src/keep-alive-entry.ts +3 -0
- package/src/keep-alive.ts +5 -1
- package/src/mount.ts +160 -56
- package/src/nodes.ts +62 -13
- package/src/props.ts +1 -2
- package/src/template.ts +57 -2
- package/src/tests/coverage-gaps.test.ts +709 -0
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/lis-prepend.browser.test.ts +99 -0
- package/src/tests/mount.test.ts +91 -0
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +121 -7
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +135 -1
- package/src/transition-entry.ts +7 -0
- package/src/transition-group.ts +6 -1
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
|
@@ -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
|
package/lib/types/index.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
91
|
-
* Can be toggled manually for testing or verbose production debugging.
|
|
90
|
+
* Two complementary surfaces:
|
|
92
91
|
*
|
|
93
|
-
*
|
|
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.
|
|
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.
|
|
47
|
-
"@pyreon/reactivity": "^0.
|
|
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.
|
|
62
|
+
"@pyreon/compiler": "^0.15.0",
|
|
52
63
|
"@pyreon/manifest": "0.13.1",
|
|
53
|
-
"@pyreon/runtime-server": "^0.
|
|
54
|
-
"@pyreon/test-utils": "^0.13.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
package/src/hydration-debug.ts
CHANGED
|
@@ -1,18 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Hydration mismatch warnings.
|
|
2
|
+
* Hydration mismatch warnings + telemetry hook.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
* Can be toggled manually for testing or verbose production debugging.
|
|
4
|
+
* Two complementary surfaces:
|
|
6
5
|
*
|
|
7
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
96
|
+
type: HydrationMismatchType,
|
|
97
|
+
expected: unknown,
|
|
98
|
+
actual: unknown,
|
|
99
|
+
path: string,
|
|
39
100
|
): void {
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
}
|