@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.
- package/lib/analysis/index.js.html +1 -1
- package/lib/analysis/keep-alive-entry.js.html +1 -1
- package/lib/analysis/transition-entry.js.html +1 -1
- package/lib/index.js +150 -62
- package/lib/keep-alive-entry.js +81 -44
- package/lib/transition-entry.js +3 -2
- package/lib/types/index.d.ts +54 -5
- package/package.json +7 -6
- package/src/delegate.ts +16 -0
- package/src/hydrate.ts +9 -2
- package/src/hydration-debug.ts +99 -14
- package/src/index.ts +11 -3
- package/src/keep-alive.ts +15 -4
- package/src/mount.ts +1 -2
- package/src/nodes.ts +87 -41
- package/src/props.ts +11 -2
- package/src/template.ts +48 -2
- package/src/tests/dev-gate-pattern.test.ts +17 -11
- package/src/tests/dev-gate-treeshake.test.ts +20 -26
- package/src/tests/fanout-repro.test.tsx +219 -0
- package/src/tests/hydration-integration.test.tsx +166 -1
- package/src/tests/mount.test.ts +92 -1
- package/src/tests/native-markers.test.ts +19 -0
- package/src/tests/runtime-dom.browser.test.ts +58 -6
- package/src/tests/show-context.test.ts +93 -0
- package/src/tests/template.test.ts +71 -1
- package/src/tests/transition.test.ts +5 -1
- package/src/transition-group.ts +22 -7
- package/src/transition.ts +11 -3
- package/lib/index.js.map +0 -1
- package/lib/keep-alive-entry.js.map +0 -1
- package/lib/transition-entry.js.map +0 -1
- package/lib/types/index.d.ts.map +0 -1
- package/lib/types/keep-alive-entry.d.ts.map +0 -1
- package/lib/types/transition-entry.d.ts.map +0 -1
package/lib/keep-alive-entry.js
CHANGED
|
@@ -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 =
|
|
37
|
-
const _countSink$
|
|
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)
|
|
79
|
-
|
|
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$
|
|
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
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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$
|
|
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)
|
|
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)
|
|
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
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
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)
|
|
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 =
|
|
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__ =
|
|
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 };
|
package/lib/transition-entry.js
CHANGED
|
@@ -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__ =
|
|
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 };
|
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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/runtime-dom",
|
|
3
|
-
"version": "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.
|
|
57
|
-
"@pyreon/reactivity": "^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.
|
|
62
|
+
"@pyreon/compiler": "^0.16.0",
|
|
62
63
|
"@pyreon/manifest": "0.13.1",
|
|
63
|
-
"@pyreon/runtime-server": "^0.
|
|
64
|
-
"@pyreon/test-utils": "^0.13.
|
|
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
|
|
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 {
|
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
|
}
|
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 {
|
|
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
|
-
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
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 }
|