@pyreon/core 0.18.0 → 0.19.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/index.js +103 -9
- package/lib/types/index.d.ts +65 -23
- package/package.json +2 -2
- package/src/compat-shared.ts +80 -0
- package/src/defer.ts +44 -6
- package/src/index.ts +2 -1
- package/src/lifecycle.ts +4 -2
- package/src/telemetry.ts +37 -0
- package/src/tests/compat-shared.test.ts +99 -0
- package/src/tests/telemetry.test.ts +94 -0
|
@@ -5386,7 +5386,7 @@ var drawChart = (function (exports) {
|
|
|
5386
5386
|
</script>
|
|
5387
5387
|
<script>
|
|
5388
5388
|
/*<!--*/
|
|
5389
|
-
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"
|
|
5389
|
+
const data = {"version":2,"tree":{"name":"root","children":[{"name":"index.js","children":[{"name":"src","children":[{"uid":"d1de8643-1","name":"lifecycle.ts"},{"uid":"d1de8643-3","name":"component.ts"},{"uid":"d1de8643-5","name":"compat-marker.ts"},{"uid":"d1de8643-7","name":"compat-shared.ts"},{"uid":"d1de8643-9","name":"context.ts"},{"uid":"d1de8643-11","name":"h.ts"},{"uid":"d1de8643-13","name":"dynamic.ts"},{"uid":"d1de8643-15","name":"telemetry.ts"},{"uid":"d1de8643-17","name":"error-boundary.ts"},{"uid":"d1de8643-19","name":"for.ts"},{"uid":"d1de8643-21","name":"ref.ts"},{"uid":"d1de8643-23","name":"defer.ts"},{"uid":"d1de8643-25","name":"lazy.ts"},{"uid":"d1de8643-27","name":"map-array.ts"},{"uid":"d1de8643-29","name":"portal.ts"},{"uid":"d1de8643-31","name":"props.ts"},{"uid":"d1de8643-33","name":"show.ts"},{"uid":"d1de8643-35","name":"style.ts"},{"uid":"d1de8643-37","name":"suspense.ts"},{"uid":"d1de8643-39","name":"index.ts"}]}]}],"isRoot":true},"nodeParts":{"d1de8643-1":{"renderedLength":3090,"gzipLength":1316,"brotliLength":0,"metaUid":"d1de8643-0"},"d1de8643-3":{"renderedLength":1471,"gzipLength":693,"brotliLength":0,"metaUid":"d1de8643-2"},"d1de8643-5":{"renderedLength":3173,"gzipLength":1409,"brotliLength":0,"metaUid":"d1de8643-4"},"d1de8643-7":{"renderedLength":2346,"gzipLength":1033,"brotliLength":0,"metaUid":"d1de8643-6"},"d1de8643-9":{"renderedLength":3600,"gzipLength":1542,"brotliLength":0,"metaUid":"d1de8643-8"},"d1de8643-11":{"renderedLength":1813,"gzipLength":957,"brotliLength":0,"metaUid":"d1de8643-10"},"d1de8643-13":{"renderedLength":490,"gzipLength":292,"brotliLength":0,"metaUid":"d1de8643-12"},"d1de8643-15":{"renderedLength":1990,"gzipLength":950,"brotliLength":0,"metaUid":"d1de8643-14"},"d1de8643-17":{"renderedLength":1659,"gzipLength":843,"brotliLength":0,"metaUid":"d1de8643-16"},"d1de8643-19":{"renderedLength":700,"gzipLength":478,"brotliLength":0,"metaUid":"d1de8643-18"},"d1de8643-21":{"renderedLength":86,"gzipLength":98,"brotliLength":0,"metaUid":"d1de8643-20"},"d1de8643-23":{"renderedLength":4387,"gzipLength":1891,"brotliLength":0,"metaUid":"d1de8643-22"},"d1de8643-25":{"renderedLength":461,"gzipLength":273,"brotliLength":0,"metaUid":"d1de8643-24"},"d1de8643-27":{"renderedLength":1018,"gzipLength":571,"brotliLength":0,"metaUid":"d1de8643-26"},"d1de8643-29":{"renderedLength":818,"gzipLength":491,"brotliLength":0,"metaUid":"d1de8643-28"},"d1de8643-31":{"renderedLength":6310,"gzipLength":2344,"brotliLength":0,"metaUid":"d1de8643-30"},"d1de8643-33":{"renderedLength":2022,"gzipLength":854,"brotliLength":0,"metaUid":"d1de8643-32"},"d1de8643-35":{"renderedLength":1858,"gzipLength":825,"brotliLength":0,"metaUid":"d1de8643-34"},"d1de8643-37":{"renderedLength":1104,"gzipLength":614,"brotliLength":0,"metaUid":"d1de8643-36"},"d1de8643-39":{"renderedLength":0,"gzipLength":0,"brotliLength":0,"metaUid":"d1de8643-38"}},"nodeMetas":{"d1de8643-0":{"id":"/src/lifecycle.ts","moduleParts":{"index.js":"d1de8643-1"},"imported":[],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-2"},{"uid":"d1de8643-8"},{"uid":"d1de8643-16"},{"uid":"d1de8643-22"}]},"d1de8643-2":{"id":"/src/component.ts","moduleParts":{"index.js":"d1de8643-3"},"imported":[{"uid":"d1de8643-0"}],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-16"}]},"d1de8643-4":{"id":"/src/compat-marker.ts","moduleParts":{"index.js":"d1de8643-5"},"imported":[],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-16"}]},"d1de8643-6":{"id":"/src/compat-shared.ts","moduleParts":{"index.js":"d1de8643-7"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-8":{"id":"/src/context.ts","moduleParts":{"index.js":"d1de8643-9"},"imported":[{"uid":"d1de8643-40"},{"uid":"d1de8643-0"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-10":{"id":"/src/h.ts","moduleParts":{"index.js":"d1de8643-11"},"imported":[],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-12"},{"uid":"d1de8643-22"},{"uid":"d1de8643-24"},{"uid":"d1de8643-36"}]},"d1de8643-12":{"id":"/src/dynamic.ts","moduleParts":{"index.js":"d1de8643-13"},"imported":[{"uid":"d1de8643-10"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-14":{"id":"/src/telemetry.ts","moduleParts":{"index.js":"d1de8643-15"},"imported":[{"uid":"d1de8643-40"}],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-16"}]},"d1de8643-16":{"id":"/src/error-boundary.ts","moduleParts":{"index.js":"d1de8643-17"},"imported":[{"uid":"d1de8643-40"},{"uid":"d1de8643-4"},{"uid":"d1de8643-2"},{"uid":"d1de8643-0"},{"uid":"d1de8643-14"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-18":{"id":"/src/for.ts","moduleParts":{"index.js":"d1de8643-19"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-20":{"id":"/src/ref.ts","moduleParts":{"index.js":"d1de8643-21"},"imported":[],"importedBy":[{"uid":"d1de8643-38"},{"uid":"d1de8643-22"}]},"d1de8643-22":{"id":"/src/defer.ts","moduleParts":{"index.js":"d1de8643-23"},"imported":[{"uid":"d1de8643-40"},{"uid":"d1de8643-10"},{"uid":"d1de8643-0"},{"uid":"d1de8643-20"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-24":{"id":"/src/lazy.ts","moduleParts":{"index.js":"d1de8643-25"},"imported":[{"uid":"d1de8643-40"},{"uid":"d1de8643-10"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-26":{"id":"/src/map-array.ts","moduleParts":{"index.js":"d1de8643-27"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-28":{"id":"/src/portal.ts","moduleParts":{"index.js":"d1de8643-29"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-30":{"id":"/src/props.ts","moduleParts":{"index.js":"d1de8643-31"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-32":{"id":"/src/show.ts","moduleParts":{"index.js":"d1de8643-33"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-34":{"id":"/src/style.ts","moduleParts":{"index.js":"d1de8643-35"},"imported":[],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-36":{"id":"/src/suspense.ts","moduleParts":{"index.js":"d1de8643-37"},"imported":[{"uid":"d1de8643-10"}],"importedBy":[{"uid":"d1de8643-38"}]},"d1de8643-38":{"id":"/src/index.ts","moduleParts":{"index.js":"d1de8643-39"},"imported":[{"uid":"d1de8643-2"},{"uid":"d1de8643-4"},{"uid":"d1de8643-6"},{"uid":"d1de8643-8"},{"uid":"d1de8643-12"},{"uid":"d1de8643-16"},{"uid":"d1de8643-18"},{"uid":"d1de8643-10"},{"uid":"d1de8643-22"},{"uid":"d1de8643-24"},{"uid":"d1de8643-0"},{"uid":"d1de8643-26"},{"uid":"d1de8643-28"},{"uid":"d1de8643-30"},{"uid":"d1de8643-20"},{"uid":"d1de8643-32"},{"uid":"d1de8643-34"},{"uid":"d1de8643-36"},{"uid":"d1de8643-14"}],"importedBy":[],"isEntry":true},"d1de8643-40":{"id":"@pyreon/reactivity","moduleParts":{},"imported":[],"importedBy":[{"uid":"d1de8643-8"},{"uid":"d1de8643-16"},{"uid":"d1de8643-22"},{"uid":"d1de8643-24"},{"uid":"d1de8643-14"}]}},"env":{"rollup":"4.23.0"},"options":{"gzip":true,"brotli":false,"sourcemap":false}};
|
|
5390
5390
|
|
|
5391
5391
|
const run = () => {
|
|
5392
5392
|
const width = window.innerWidth;
|
package/lib/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { effect, setSnapshotCapture, signal } from "@pyreon/reactivity";
|
|
1
|
+
import { effect, getReactiveTrace, setSnapshotCapture, signal } from "@pyreon/reactivity";
|
|
2
2
|
|
|
3
3
|
//#region src/lifecycle.ts
|
|
4
|
-
const __DEV__$
|
|
4
|
+
const __DEV__$5 = process.env.NODE_ENV !== "production";
|
|
5
5
|
let _current = null;
|
|
6
6
|
function setCurrentHooks(hooks) {
|
|
7
7
|
_current = hooks;
|
|
@@ -40,10 +40,10 @@ function captureCallSite() {
|
|
|
40
40
|
return "";
|
|
41
41
|
}
|
|
42
42
|
function warnOutsideSetup(hookName) {
|
|
43
|
-
if (__DEV__$
|
|
43
|
+
if (__DEV__$5 && !_current) {
|
|
44
44
|
const callSite = captureCallSite();
|
|
45
|
-
const
|
|
46
|
-
console.warn(`[Pyreon] ${hookName}() called outside component setup. Lifecycle hooks must be called synchronously during a component's setup function.` +
|
|
45
|
+
const callSiteSuffix = callSite ? `\n Called from: ${callSite}` : "";
|
|
46
|
+
console.warn(`[Pyreon] ${hookName}() called outside component setup. Lifecycle hooks must be called synchronously during a component's setup function.` + callSiteSuffix + (hookName === "onUnmount" ? "\n Hint: `provide()` internally calls onUnmount(). If you use provide(), ensure it runs during synchronous component setup — not inside effects, callbacks, or after awaits." : ""));
|
|
47
47
|
}
|
|
48
48
|
}
|
|
49
49
|
/**
|
|
@@ -231,6 +231,71 @@ function isNativeCompat(fn) {
|
|
|
231
231
|
return typeof fn === "function" && fn[NATIVE_COMPAT_MARKER] === true;
|
|
232
232
|
}
|
|
233
233
|
|
|
234
|
+
//#endregion
|
|
235
|
+
//#region src/compat-shared.ts
|
|
236
|
+
/**
|
|
237
|
+
* Code shared by the framework-compat JSX runtimes
|
|
238
|
+
* (`@pyreon/react-compat`, `@pyreon/preact-compat`).
|
|
239
|
+
*
|
|
240
|
+
* These helpers were previously copy-pasted byte-for-byte into both
|
|
241
|
+
* packages. `@pyreon/core` is the correct single home — it's already a
|
|
242
|
+
* dependency of every compat package and already hosts the sibling
|
|
243
|
+
* cross-compat module `compat-marker.ts` (`nativeCompat` / `isNativeCompat`).
|
|
244
|
+
*/
|
|
245
|
+
/**
|
|
246
|
+
* Shallow props comparison used by compat `memo()` / `useState` bailout.
|
|
247
|
+
* Same-length key sets with `Object.is`-equal values → equal.
|
|
248
|
+
*/
|
|
249
|
+
function shallowEqualProps(a, b) {
|
|
250
|
+
const keysA = Object.keys(a);
|
|
251
|
+
const keysB = Object.keys(b);
|
|
252
|
+
if (keysA.length !== keysB.length) return false;
|
|
253
|
+
for (const k of keysA) if (!Object.is(a[k], b[k])) return false;
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Map React/Preact-style DOM attributes to standard HTML attributes,
|
|
258
|
+
* mutating `props` in place. No-op when `type` is not a host string
|
|
259
|
+
* (component vnodes keep their props untouched).
|
|
260
|
+
*
|
|
261
|
+
* The React and Preact variants were identical apart from React also
|
|
262
|
+
* stripping `suppressContentEditableWarning`. Both keys are React/Preact
|
|
263
|
+
* authoring-only and never valid DOM attributes, so always stripping
|
|
264
|
+
* both is behavior-preserving for Preact (the key is never set there;
|
|
265
|
+
* `delete` of an absent key is a no-op) and removes the only divergence.
|
|
266
|
+
*/
|
|
267
|
+
function mapCompatDomProps(props, type) {
|
|
268
|
+
if (typeof type !== "string") return;
|
|
269
|
+
if (props.className !== void 0) {
|
|
270
|
+
props.class = props.className;
|
|
271
|
+
delete props.className;
|
|
272
|
+
}
|
|
273
|
+
if (props.htmlFor !== void 0) {
|
|
274
|
+
props.for = props.htmlFor;
|
|
275
|
+
delete props.htmlFor;
|
|
276
|
+
}
|
|
277
|
+
if ((type === "input" || type === "textarea" || type === "select") && props.onChange !== void 0) {
|
|
278
|
+
if (props.onInput === void 0) props.onInput = props.onChange;
|
|
279
|
+
delete props.onChange;
|
|
280
|
+
}
|
|
281
|
+
if (props.autoFocus !== void 0) {
|
|
282
|
+
props.autofocus = props.autoFocus;
|
|
283
|
+
delete props.autoFocus;
|
|
284
|
+
}
|
|
285
|
+
if (type === "input" || type === "textarea") {
|
|
286
|
+
if (props.defaultValue !== void 0 && props.value === void 0) {
|
|
287
|
+
props.value = props.defaultValue;
|
|
288
|
+
delete props.defaultValue;
|
|
289
|
+
}
|
|
290
|
+
if (props.defaultChecked !== void 0 && props.checked === void 0) {
|
|
291
|
+
props.checked = props.defaultChecked;
|
|
292
|
+
delete props.defaultChecked;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
delete props.suppressHydrationWarning;
|
|
296
|
+
delete props.suppressContentEditableWarning;
|
|
297
|
+
}
|
|
298
|
+
|
|
234
299
|
//#endregion
|
|
235
300
|
//#region src/context.ts
|
|
236
301
|
/**
|
|
@@ -396,10 +461,10 @@ function flattenChildren(children) {
|
|
|
396
461
|
|
|
397
462
|
//#endregion
|
|
398
463
|
//#region src/dynamic.ts
|
|
399
|
-
const __DEV__$
|
|
464
|
+
const __DEV__$4 = process.env.NODE_ENV !== "production";
|
|
400
465
|
function Dynamic(props) {
|
|
401
466
|
const { component, children, ...rest } = props;
|
|
402
|
-
if (__DEV__$
|
|
467
|
+
if (__DEV__$4 && !component) console.warn("[Pyreon] <Dynamic> received a falsy `component` prop. Nothing will be rendered.");
|
|
403
468
|
if (!component) return null;
|
|
404
469
|
if (children === void 0) return h(component, rest);
|
|
405
470
|
if (Array.isArray(children)) return h(component, rest, ...children);
|
|
@@ -408,6 +473,24 @@ function Dynamic(props) {
|
|
|
408
473
|
|
|
409
474
|
//#endregion
|
|
410
475
|
//#region src/telemetry.ts
|
|
476
|
+
/**
|
|
477
|
+
* Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
|
|
478
|
+
*
|
|
479
|
+
* Captures errors from ALL lifecycle phases including reactive effects.
|
|
480
|
+
* `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
|
|
481
|
+
* globalThis sink (no upward import — reactivity doesn't depend on core).
|
|
482
|
+
*
|
|
483
|
+
* @example
|
|
484
|
+
* import { registerErrorHandler } from "@pyreon/core"
|
|
485
|
+
* import * as Sentry from "@sentry/browser"
|
|
486
|
+
*
|
|
487
|
+
* registerErrorHandler(ctx => {
|
|
488
|
+
* Sentry.captureException(ctx.error, {
|
|
489
|
+
* extra: { component: ctx.component, phase: ctx.phase },
|
|
490
|
+
* })
|
|
491
|
+
* })
|
|
492
|
+
*/
|
|
493
|
+
const __DEV__$3 = process.env.NODE_ENV !== "production";
|
|
411
494
|
let _handlers = [];
|
|
412
495
|
/**
|
|
413
496
|
* Register a global error handler. Called whenever a component throws in any
|
|
@@ -431,6 +514,10 @@ function registerErrorHandler(handler) {
|
|
|
431
514
|
* Existing console.error calls are preserved; this is additive.
|
|
432
515
|
*/
|
|
433
516
|
function reportError(ctx) {
|
|
517
|
+
if (__DEV__$3 && ctx.reactiveTrace === void 0) try {
|
|
518
|
+
const trace = getReactiveTrace();
|
|
519
|
+
if (trace.length > 0) ctx.reactiveTrace = trace;
|
|
520
|
+
} catch {}
|
|
434
521
|
for (const h of _handlers) try {
|
|
435
522
|
h(ctx);
|
|
436
523
|
} catch {}
|
|
@@ -617,6 +704,11 @@ function Defer(props) {
|
|
|
617
704
|
const startLoad = () => {
|
|
618
705
|
if (loadStarted) return;
|
|
619
706
|
loadStarted = true;
|
|
707
|
+
if (!props.chunk) {
|
|
708
|
+
const err = /* @__PURE__ */ new Error("[Pyreon] <Defer> has no `chunk` prop. Either pass `chunk={() => import(\"...\")}` (explicit form), or use the inline form `<Defer when={...}><Component /></Defer>` with `@pyreon/vite-plugin` enabled — the compiler rewrites inline JSX to an explicit chunk-prop call.");
|
|
709
|
+
Failed.set(err);
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
620
712
|
props.chunk().then((mod) => {
|
|
621
713
|
const Comp = typeof mod === "function" ? mod : mod.default;
|
|
622
714
|
if (__DEV__$1 && typeof Comp !== "function") {
|
|
@@ -639,7 +731,9 @@ function Defer(props) {
|
|
|
639
731
|
if (err) throw err;
|
|
640
732
|
const Comp = Loaded();
|
|
641
733
|
if (!Comp) return props.fallback ?? null;
|
|
642
|
-
|
|
734
|
+
const ch = props.children;
|
|
735
|
+
if (typeof ch === "function") return ch(Comp);
|
|
736
|
+
return h(Comp, {});
|
|
643
737
|
};
|
|
644
738
|
if ("on" in props && props.on === "visible") {
|
|
645
739
|
const containerRef = createRef();
|
|
@@ -1104,5 +1198,5 @@ function Suspense(props) {
|
|
|
1104
1198
|
}
|
|
1105
1199
|
|
|
1106
1200
|
//#endregion
|
|
1107
|
-
export { CSS_UNITLESS, Defer, Dynamic, EMPTY_PROPS, ErrorBoundary, For, ForSymbol, Fragment, Match, MatchSymbol, NATIVE_COMPAT_MARKER, Portal, PortalSymbol, REACTIVE_PROP, Show, Suspense, Switch, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, splitProps, toKebabCase, useContext, withContext };
|
|
1201
|
+
export { CSS_UNITLESS, Defer, Dynamic, EMPTY_PROPS, ErrorBoundary, For, ForSymbol, Fragment, Match, MatchSymbol, NATIVE_COMPAT_MARKER, Portal, PortalSymbol, REACTIVE_PROP, Show, Suspense, Switch, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1108
1202
|
//# sourceMappingURL=index.js.map
|
package/lib/types/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { ReactiveTraceEntry } from "@pyreon/reactivity";
|
|
2
|
+
|
|
1
3
|
//#region src/types.d.ts
|
|
2
4
|
type VNodeChildAtom = VNode | string | number | boolean | null | undefined;
|
|
3
5
|
/** Reactive accessor — TS checks this arm FIRST so `{() => cond && <X />}` resolves correctly */
|
|
@@ -176,6 +178,34 @@ declare function nativeCompat<T>(fn: T): T;
|
|
|
176
178
|
*/
|
|
177
179
|
declare function isNativeCompat(fn: unknown): boolean;
|
|
178
180
|
//#endregion
|
|
181
|
+
//#region src/compat-shared.d.ts
|
|
182
|
+
/**
|
|
183
|
+
* Code shared by the framework-compat JSX runtimes
|
|
184
|
+
* (`@pyreon/react-compat`, `@pyreon/preact-compat`).
|
|
185
|
+
*
|
|
186
|
+
* These helpers were previously copy-pasted byte-for-byte into both
|
|
187
|
+
* packages. `@pyreon/core` is the correct single home — it's already a
|
|
188
|
+
* dependency of every compat package and already hosts the sibling
|
|
189
|
+
* cross-compat module `compat-marker.ts` (`nativeCompat` / `isNativeCompat`).
|
|
190
|
+
*/
|
|
191
|
+
/**
|
|
192
|
+
* Shallow props comparison used by compat `memo()` / `useState` bailout.
|
|
193
|
+
* Same-length key sets with `Object.is`-equal values → equal.
|
|
194
|
+
*/
|
|
195
|
+
declare function shallowEqualProps<P extends Record<string, unknown>>(a: P, b: P): boolean;
|
|
196
|
+
/**
|
|
197
|
+
* Map React/Preact-style DOM attributes to standard HTML attributes,
|
|
198
|
+
* mutating `props` in place. No-op when `type` is not a host string
|
|
199
|
+
* (component vnodes keep their props untouched).
|
|
200
|
+
*
|
|
201
|
+
* The React and Preact variants were identical apart from React also
|
|
202
|
+
* stripping `suppressContentEditableWarning`. Both keys are React/Preact
|
|
203
|
+
* authoring-only and never valid DOM attributes, so always stripping
|
|
204
|
+
* both is behavior-preserving for Preact (the key is never set there;
|
|
205
|
+
* `delete` of an absent key is a no-op) and removes the only divergence.
|
|
206
|
+
*/
|
|
207
|
+
declare function mapCompatDomProps(props: Record<string, unknown>, type: unknown): void;
|
|
208
|
+
//#endregion
|
|
179
209
|
//#region src/context.d.ts
|
|
180
210
|
/**
|
|
181
211
|
* Provide / inject — like React context or Vue provide/inject.
|
|
@@ -1047,14 +1077,27 @@ type DeferProps<P extends Props> = DeferTrigger & {
|
|
|
1047
1077
|
* Dynamic import to lazy-load. The literal `import('./X')` is what
|
|
1048
1078
|
* Rolldown / Vite see when emitting chunks — using a variable here
|
|
1049
1079
|
* defeats code splitting.
|
|
1080
|
+
*
|
|
1081
|
+
* Typed as optional ONLY because the compiler-driven inline form
|
|
1082
|
+
* (`<Defer when={x}><Modal /></Defer>`) doesn't include a `chunk`
|
|
1083
|
+
* prop at source level — `@pyreon/compiler`'s `transformDeferInline`
|
|
1084
|
+
* synthesizes it before runtime. Authors using the explicit form
|
|
1085
|
+
* must pass `chunk` — runtime throws a clear dev-mode error when
|
|
1086
|
+
* the trigger fires and `chunk` is missing.
|
|
1050
1087
|
*/
|
|
1051
|
-
chunk
|
|
1088
|
+
chunk?: () => Promise<ChunkResult<P>>;
|
|
1052
1089
|
/**
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
1090
|
+
* Children accept TWO shapes:
|
|
1091
|
+
* 1. Render-prop `(Component) => VNodeChild` — the explicit form.
|
|
1092
|
+
* Receives the loaded component, lets the author pass props.
|
|
1093
|
+
* 2. Inline JSX (`<Defer when={x}><Modal /></Defer>`) — the compiler-
|
|
1094
|
+
* driven form. The compiler extracts the subtree into a chunk
|
|
1095
|
+
* and rewrites this to the render-prop form before runtime.
|
|
1096
|
+
*
|
|
1097
|
+
* Type widening is necessary because TypeScript checks the raw source
|
|
1098
|
+
* BEFORE the compiler pass runs — both shapes must typecheck.
|
|
1056
1099
|
*/
|
|
1057
|
-
children?: (Component: ComponentFn<P>) => VNodeChild; /** Shown while the chunk is loading. Default: `null`. */
|
|
1100
|
+
children?: ((Component: ComponentFn<P>) => VNodeChild) | VNodeChild; /** Shown while the chunk is loading. Default: `null`. */
|
|
1058
1101
|
fallback?: VNodeChild;
|
|
1059
1102
|
/**
|
|
1060
1103
|
* IntersectionObserver `rootMargin` for `on="visible"` mode. Default
|
|
@@ -1327,23 +1370,6 @@ declare function Switch(props: SwitchProps): VNode | null;
|
|
|
1327
1370
|
declare const MatchSymbol: unique symbol;
|
|
1328
1371
|
//#endregion
|
|
1329
1372
|
//#region src/telemetry.d.ts
|
|
1330
|
-
/**
|
|
1331
|
-
* Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
|
|
1332
|
-
*
|
|
1333
|
-
* Captures errors from ALL lifecycle phases including reactive effects.
|
|
1334
|
-
* `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
|
|
1335
|
-
* globalThis sink (no upward import — reactivity doesn't depend on core).
|
|
1336
|
-
*
|
|
1337
|
-
* @example
|
|
1338
|
-
* import { registerErrorHandler } from "@pyreon/core"
|
|
1339
|
-
* import * as Sentry from "@sentry/browser"
|
|
1340
|
-
*
|
|
1341
|
-
* registerErrorHandler(ctx => {
|
|
1342
|
-
* Sentry.captureException(ctx.error, {
|
|
1343
|
-
* extra: { component: ctx.component, phase: ctx.phase },
|
|
1344
|
-
* })
|
|
1345
|
-
* })
|
|
1346
|
-
*/
|
|
1347
1373
|
interface ErrorContext {
|
|
1348
1374
|
/** Component function name, "Anonymous", or "Effect" for reactive effects */
|
|
1349
1375
|
component: string;
|
|
@@ -1355,6 +1381,22 @@ interface ErrorContext {
|
|
|
1355
1381
|
timestamp: number;
|
|
1356
1382
|
/** Component props at the time of the error */
|
|
1357
1383
|
props?: Record<string, unknown>;
|
|
1384
|
+
/**
|
|
1385
|
+
* The last N signal writes (chronological, oldest → newest) leading
|
|
1386
|
+
* up to the error — the causal sequence of reactive state changes,
|
|
1387
|
+
* not a point-in-time snapshot. Each entry is `{ name, prev, next,
|
|
1388
|
+
* timestamp }` with `prev` / `next` as bounded string previews.
|
|
1389
|
+
*
|
|
1390
|
+
* Populated automatically in development from `@pyreon/reactivity`'s
|
|
1391
|
+
* dev-only ring buffer. **`undefined` in production** — the recorder
|
|
1392
|
+
* feeding the buffer tree-shakes out of prod bundles, so the cost is
|
|
1393
|
+
* zero and the field is simply absent.
|
|
1394
|
+
*
|
|
1395
|
+
* For a signal framework this answers the first question a crash
|
|
1396
|
+
* raises — "what reactive state changed in the run-up?" — that the
|
|
1397
|
+
* thrown value + stack alone can't.
|
|
1398
|
+
*/
|
|
1399
|
+
reactiveTrace?: ReactiveTraceEntry[];
|
|
1358
1400
|
}
|
|
1359
1401
|
type ErrorHandler = (ctx: ErrorContext) => void;
|
|
1360
1402
|
/**
|
|
@@ -1374,5 +1416,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
|
|
|
1374
1416
|
*/
|
|
1375
1417
|
declare function reportError(ctx: ErrorContext): void;
|
|
1376
1418
|
//#endregion
|
|
1377
|
-
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, splitProps, toKebabCase, useContext, withContext };
|
|
1419
|
+
export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, Defer, type DeferProps, Dynamic, type DynamicProps, EMPTY_PROPS, ErrorBoundary, type ErrorContext, type ErrorHandler, type ExtractProps, For, type ForProps, ForSymbol, type FormAttributes, Fragment, type HigherOrderComponent, type ImgAttributes, type InputAttributes, type LazyComponent, type LifecycleHooks, Match, type MatchProps, MatchSymbol, NATIVE_COMPAT_MARKER, type NativeItem, Portal, type PortalProps, PortalSymbol, type Props, type PyreonHTMLAttributes, REACTIVE_PROP, type ReactiveContext, type ReactiveTraceEntry, type Ref, type RefCallback, type RefProp, type SelectAttributes, Show, type ShowProps, type StyleValue, Suspense, type SvgAttributes, Switch, type SwitchProps, type TargetedEvent, type TextareaAttributes, type VNode, type VNodeChild, type VNodeChildAccessor, type VNodeChildAtom, _rp, _wrapSpread, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, isNativeCompat, lazy, makeReactiveProps, mapArray, mapCompatDomProps, mergeProps, nativeCompat, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, shallowEqualProps, splitProps, toKebabCase, useContext, withContext };
|
|
1378
1420
|
//# sourceMappingURL=index2.d.ts.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pyreon/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Core component model and lifecycle for Pyreon",
|
|
5
5
|
"homepage": "https://github.com/pyreon/pyreon/tree/main/packages/core#readme",
|
|
6
6
|
"bugs": {
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"prepublishOnly": "bun run build"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@pyreon/reactivity": "^0.
|
|
56
|
+
"@pyreon/reactivity": "^0.19.0"
|
|
57
57
|
},
|
|
58
58
|
"devDependencies": {
|
|
59
59
|
"@pyreon/manifest": "0.13.1"
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Code shared by the framework-compat JSX runtimes
|
|
3
|
+
* (`@pyreon/react-compat`, `@pyreon/preact-compat`).
|
|
4
|
+
*
|
|
5
|
+
* These helpers were previously copy-pasted byte-for-byte into both
|
|
6
|
+
* packages. `@pyreon/core` is the correct single home — it's already a
|
|
7
|
+
* dependency of every compat package and already hosts the sibling
|
|
8
|
+
* cross-compat module `compat-marker.ts` (`nativeCompat` / `isNativeCompat`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shallow props comparison used by compat `memo()` / `useState` bailout.
|
|
13
|
+
* Same-length key sets with `Object.is`-equal values → equal.
|
|
14
|
+
*/
|
|
15
|
+
export function shallowEqualProps<P extends Record<string, unknown>>(a: P, b: P): boolean {
|
|
16
|
+
const keysA = Object.keys(a)
|
|
17
|
+
const keysB = Object.keys(b)
|
|
18
|
+
if (keysA.length !== keysB.length) return false
|
|
19
|
+
for (const k of keysA) {
|
|
20
|
+
if (!Object.is(a[k], b[k])) return false
|
|
21
|
+
}
|
|
22
|
+
return true
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Map React/Preact-style DOM attributes to standard HTML attributes,
|
|
27
|
+
* mutating `props` in place. No-op when `type` is not a host string
|
|
28
|
+
* (component vnodes keep their props untouched).
|
|
29
|
+
*
|
|
30
|
+
* The React and Preact variants were identical apart from React also
|
|
31
|
+
* stripping `suppressContentEditableWarning`. Both keys are React/Preact
|
|
32
|
+
* authoring-only and never valid DOM attributes, so always stripping
|
|
33
|
+
* both is behavior-preserving for Preact (the key is never set there;
|
|
34
|
+
* `delete` of an absent key is a no-op) and removes the only divergence.
|
|
35
|
+
*/
|
|
36
|
+
export function mapCompatDomProps(props: Record<string, unknown>, type: unknown): void {
|
|
37
|
+
if (typeof type !== 'string') return
|
|
38
|
+
|
|
39
|
+
if (props.className !== undefined) {
|
|
40
|
+
props.class = props.className
|
|
41
|
+
delete props.className
|
|
42
|
+
}
|
|
43
|
+
if (props.htmlFor !== undefined) {
|
|
44
|
+
props.for = props.htmlFor
|
|
45
|
+
delete props.htmlFor
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// React/Preact onChange fires on every keystroke for form elements (like onInput)
|
|
49
|
+
if (
|
|
50
|
+
(type === 'input' || type === 'textarea' || type === 'select') &&
|
|
51
|
+
props.onChange !== undefined
|
|
52
|
+
) {
|
|
53
|
+
if (props.onInput === undefined) {
|
|
54
|
+
props.onInput = props.onChange
|
|
55
|
+
}
|
|
56
|
+
delete props.onChange
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// autoFocus → autofocus
|
|
60
|
+
if (props.autoFocus !== undefined) {
|
|
61
|
+
props.autofocus = props.autoFocus
|
|
62
|
+
delete props.autoFocus
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// defaultValue / defaultChecked → value / checked when no controlled value
|
|
66
|
+
if (type === 'input' || type === 'textarea') {
|
|
67
|
+
if (props.defaultValue !== undefined && props.value === undefined) {
|
|
68
|
+
props.value = props.defaultValue
|
|
69
|
+
delete props.defaultValue
|
|
70
|
+
}
|
|
71
|
+
if (props.defaultChecked !== undefined && props.checked === undefined) {
|
|
72
|
+
props.checked = props.defaultChecked
|
|
73
|
+
delete props.defaultChecked
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Strip authoring-only props that have no DOM equivalent
|
|
78
|
+
delete props.suppressHydrationWarning
|
|
79
|
+
delete props.suppressContentEditableWarning
|
|
80
|
+
}
|
package/src/defer.ts
CHANGED
|
@@ -88,14 +88,27 @@ export type DeferProps<P extends Props> = DeferTrigger & {
|
|
|
88
88
|
* Dynamic import to lazy-load. The literal `import('./X')` is what
|
|
89
89
|
* Rolldown / Vite see when emitting chunks — using a variable here
|
|
90
90
|
* defeats code splitting.
|
|
91
|
+
*
|
|
92
|
+
* Typed as optional ONLY because the compiler-driven inline form
|
|
93
|
+
* (`<Defer when={x}><Modal /></Defer>`) doesn't include a `chunk`
|
|
94
|
+
* prop at source level — `@pyreon/compiler`'s `transformDeferInline`
|
|
95
|
+
* synthesizes it before runtime. Authors using the explicit form
|
|
96
|
+
* must pass `chunk` — runtime throws a clear dev-mode error when
|
|
97
|
+
* the trigger fires and `chunk` is missing.
|
|
91
98
|
*/
|
|
92
|
-
chunk
|
|
99
|
+
chunk?: () => Promise<ChunkResult<P>>
|
|
93
100
|
/**
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
101
|
+
* Children accept TWO shapes:
|
|
102
|
+
* 1. Render-prop `(Component) => VNodeChild` — the explicit form.
|
|
103
|
+
* Receives the loaded component, lets the author pass props.
|
|
104
|
+
* 2. Inline JSX (`<Defer when={x}><Modal /></Defer>`) — the compiler-
|
|
105
|
+
* driven form. The compiler extracts the subtree into a chunk
|
|
106
|
+
* and rewrites this to the render-prop form before runtime.
|
|
107
|
+
*
|
|
108
|
+
* Type widening is necessary because TypeScript checks the raw source
|
|
109
|
+
* BEFORE the compiler pass runs — both shapes must typecheck.
|
|
97
110
|
*/
|
|
98
|
-
children?: (Component: ComponentFn<P>) => VNodeChild
|
|
111
|
+
children?: ((Component: ComponentFn<P>) => VNodeChild) | VNodeChild
|
|
99
112
|
/** Shown while the chunk is loading. Default: `null`. */
|
|
100
113
|
fallback?: VNodeChild
|
|
101
114
|
/**
|
|
@@ -146,6 +159,20 @@ export function Defer<P extends Props>(props: DeferProps<P>): VNode {
|
|
|
146
159
|
const startLoad = (): void => {
|
|
147
160
|
if (loadStarted) return
|
|
148
161
|
loadStarted = true
|
|
162
|
+
if (!props.chunk) {
|
|
163
|
+
// Missing chunk = either the user is hand-writing the inline form
|
|
164
|
+
// without the compiler pass running, or they wrote the explicit
|
|
165
|
+
// form and forgot to pass chunk. Either way, error early with an
|
|
166
|
+
// actionable message instead of crashing later inside the `.then`.
|
|
167
|
+
const err = new Error(
|
|
168
|
+
'[Pyreon] <Defer> has no `chunk` prop. Either pass `chunk={() => import("...")}` ' +
|
|
169
|
+
'(explicit form), or use the inline form `<Defer when={...}><Component /></Defer>` ' +
|
|
170
|
+
'with `@pyreon/vite-plugin` enabled — the compiler rewrites inline JSX to ' +
|
|
171
|
+
'an explicit chunk-prop call.',
|
|
172
|
+
)
|
|
173
|
+
Failed.set(err)
|
|
174
|
+
return
|
|
175
|
+
}
|
|
149
176
|
props
|
|
150
177
|
.chunk()
|
|
151
178
|
.then((mod) => {
|
|
@@ -199,7 +226,18 @@ export function Defer<P extends Props>(props: DeferProps<P>): VNode {
|
|
|
199
226
|
if (err) throw err
|
|
200
227
|
const Comp = Loaded()
|
|
201
228
|
if (!Comp) return props.fallback ?? null
|
|
202
|
-
|
|
229
|
+
// children is widened to `VNodeChild | render-prop` so the compiler-
|
|
230
|
+
// driven inline form (where author writes `<Defer ...><Modal /></Defer>`)
|
|
231
|
+
// typechecks at source level. At RUNTIME children is always either
|
|
232
|
+
// undefined OR the render-prop — the compiler rewrites the inline
|
|
233
|
+
// form's JSX children to a render-prop before this code runs.
|
|
234
|
+
// A non-function children at runtime means the user is invoking the
|
|
235
|
+
// inline form without the compiler pass (e.g. running tests through
|
|
236
|
+
// a bundler that doesn't include `@pyreon/vite-plugin`) — in that
|
|
237
|
+
// case we render `<Comp />` with no props as a best-effort fallback.
|
|
238
|
+
const ch = props.children
|
|
239
|
+
if (typeof ch === 'function') return ch(Comp)
|
|
240
|
+
return h(Comp as ComponentFn, {})
|
|
203
241
|
}
|
|
204
242
|
|
|
205
243
|
if ('on' in props && props.on === 'visible') {
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
export { defineComponent, dispatchToErrorBoundary, propagateError, runWithHooks } from './component'
|
|
4
4
|
export { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from './compat-marker'
|
|
5
|
+
export { mapCompatDomProps, shallowEqualProps } from './compat-shared'
|
|
5
6
|
export type { Context, ContextSnapshot, ReactiveContext } from './context'
|
|
6
7
|
export {
|
|
7
8
|
captureContextStack,
|
|
@@ -59,7 +60,7 @@ export type { ClassValue } from './style'
|
|
|
59
60
|
export { CSS_UNITLESS, cx, normalizeStyleValue, toKebabCase } from './style'
|
|
60
61
|
export type { LazyComponent } from './suspense'
|
|
61
62
|
export { Suspense } from './suspense'
|
|
62
|
-
export type { ErrorContext, ErrorHandler } from './telemetry'
|
|
63
|
+
export type { ErrorContext, ErrorHandler, ReactiveTraceEntry } from './telemetry'
|
|
63
64
|
export { registerErrorHandler, reportError } from './telemetry'
|
|
64
65
|
export type {
|
|
65
66
|
CleanupFn,
|
package/src/lifecycle.ts
CHANGED
|
@@ -59,12 +59,14 @@ function captureCallSite(): string {
|
|
|
59
59
|
function warnOutsideSetup(hookName: string): void {
|
|
60
60
|
if (__DEV__ && !_current) {
|
|
61
61
|
const callSite = captureCallSite()
|
|
62
|
-
|
|
62
|
+
// Local name must NOT shadow the `location` browser global (poor
|
|
63
|
+
// hygiene + trips SSR static analysis into a false positive).
|
|
64
|
+
const callSiteSuffix = callSite ? `\n Called from: ${callSite}` : ''
|
|
63
65
|
// oxlint-disable-next-line no-console
|
|
64
66
|
console.warn(
|
|
65
67
|
`[Pyreon] ${hookName}() called outside component setup. ` +
|
|
66
68
|
"Lifecycle hooks must be called synchronously during a component's setup function." +
|
|
67
|
-
|
|
69
|
+
callSiteSuffix +
|
|
68
70
|
(hookName === 'onUnmount'
|
|
69
71
|
? '\n Hint: `provide()` internally calls onUnmount(). If you use provide(), ensure it runs during synchronous component setup — not inside effects, callbacks, or after awaits.'
|
|
70
72
|
: ''),
|
package/src/telemetry.ts
CHANGED
|
@@ -16,6 +16,13 @@
|
|
|
16
16
|
* })
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
+
import { getReactiveTrace, type ReactiveTraceEntry } from '@pyreon/reactivity'
|
|
20
|
+
|
|
21
|
+
// Bundler-agnostic dev gate (see pyreon/no-process-dev-gate).
|
|
22
|
+
const __DEV__ = process.env.NODE_ENV !== 'production'
|
|
23
|
+
|
|
24
|
+
export type { ReactiveTraceEntry }
|
|
25
|
+
|
|
19
26
|
export interface ErrorContext {
|
|
20
27
|
/** Component function name, "Anonymous", or "Effect" for reactive effects */
|
|
21
28
|
component: string
|
|
@@ -27,6 +34,22 @@ export interface ErrorContext {
|
|
|
27
34
|
timestamp: number
|
|
28
35
|
/** Component props at the time of the error */
|
|
29
36
|
props?: Record<string, unknown>
|
|
37
|
+
/**
|
|
38
|
+
* The last N signal writes (chronological, oldest → newest) leading
|
|
39
|
+
* up to the error — the causal sequence of reactive state changes,
|
|
40
|
+
* not a point-in-time snapshot. Each entry is `{ name, prev, next,
|
|
41
|
+
* timestamp }` with `prev` / `next` as bounded string previews.
|
|
42
|
+
*
|
|
43
|
+
* Populated automatically in development from `@pyreon/reactivity`'s
|
|
44
|
+
* dev-only ring buffer. **`undefined` in production** — the recorder
|
|
45
|
+
* feeding the buffer tree-shakes out of prod bundles, so the cost is
|
|
46
|
+
* zero and the field is simply absent.
|
|
47
|
+
*
|
|
48
|
+
* For a signal framework this answers the first question a crash
|
|
49
|
+
* raises — "what reactive state changed in the run-up?" — that the
|
|
50
|
+
* thrown value + stack alone can't.
|
|
51
|
+
*/
|
|
52
|
+
reactiveTrace?: ReactiveTraceEntry[]
|
|
30
53
|
}
|
|
31
54
|
|
|
32
55
|
export type ErrorHandler = (ctx: ErrorContext) => void
|
|
@@ -56,6 +79,20 @@ export function registerErrorHandler(handler: ErrorHandler): () => void {
|
|
|
56
79
|
* Existing console.error calls are preserved; this is additive.
|
|
57
80
|
*/
|
|
58
81
|
export function reportError(ctx: ErrorContext): void {
|
|
82
|
+
// Enrich with the recent-signal-write trace so every handler (Sentry,
|
|
83
|
+
// Datadog, console) gets the causal reactive sequence for free. Only
|
|
84
|
+
// when the caller didn't already supply one, and only in dev — the
|
|
85
|
+
// gate lets the `getReactiveTrace` call (and the buffer behind it)
|
|
86
|
+
// tree-shake out of production. A throwing/empty trace must never
|
|
87
|
+
// block error reporting, so it's best-effort.
|
|
88
|
+
if (__DEV__ && ctx.reactiveTrace === undefined) {
|
|
89
|
+
try {
|
|
90
|
+
const trace = getReactiveTrace()
|
|
91
|
+
if (trace.length > 0) ctx.reactiveTrace = trace
|
|
92
|
+
} catch {
|
|
93
|
+
// Trace capture is diagnostic — never let it swallow the real error.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
59
96
|
for (const h of _handlers) {
|
|
60
97
|
try {
|
|
61
98
|
h(ctx)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { mapCompatDomProps, shallowEqualProps } from '../compat-shared'
|
|
3
|
+
|
|
4
|
+
describe('shallowEqualProps', () => {
|
|
5
|
+
it('equal for same-key same-value objects', () => {
|
|
6
|
+
expect(shallowEqualProps({ a: 1, b: 'x' }, { a: 1, b: 'x' })).toBe(true)
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('not equal when a value differs', () => {
|
|
10
|
+
expect(shallowEqualProps({ a: 1 }, { a: 2 })).toBe(false)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('not equal when key counts differ', () => {
|
|
14
|
+
expect(shallowEqualProps({ a: 1 }, { a: 1, b: 2 })).toBe(false)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('uses Object.is semantics (NaN equal, ±0 distinct)', () => {
|
|
18
|
+
expect(shallowEqualProps({ n: NaN }, { n: NaN })).toBe(true)
|
|
19
|
+
expect(shallowEqualProps({ z: 0 }, { z: -0 })).toBe(false)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('empty objects are equal', () => {
|
|
23
|
+
expect(shallowEqualProps({}, {})).toBe(true)
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('mapCompatDomProps', () => {
|
|
28
|
+
it('no-op for component (non-string) type', () => {
|
|
29
|
+
const Comp = () => null
|
|
30
|
+
const p: Record<string, unknown> = { className: 'x', htmlFor: 'y' }
|
|
31
|
+
mapCompatDomProps(p, Comp)
|
|
32
|
+
expect(p).toEqual({ className: 'x', htmlFor: 'y' })
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('className → class, htmlFor → for', () => {
|
|
36
|
+
const p: Record<string, unknown> = { className: 'btn', htmlFor: 'email' }
|
|
37
|
+
mapCompatDomProps(p, 'label')
|
|
38
|
+
expect(p).toEqual({ class: 'btn', for: 'email' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('onChange → onInput on input/textarea/select', () => {
|
|
42
|
+
for (const tag of ['input', 'textarea', 'select']) {
|
|
43
|
+
const fn = () => {}
|
|
44
|
+
const p: Record<string, unknown> = { onChange: fn }
|
|
45
|
+
mapCompatDomProps(p, tag)
|
|
46
|
+
expect(p).toEqual({ onInput: fn })
|
|
47
|
+
}
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('onChange does not clobber an explicit onInput', () => {
|
|
51
|
+
const onChange = () => {}
|
|
52
|
+
const onInput = () => {}
|
|
53
|
+
const p: Record<string, unknown> = { onChange, onInput }
|
|
54
|
+
mapCompatDomProps(p, 'input')
|
|
55
|
+
expect(p).toEqual({ onInput })
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('onChange left alone on non-form elements', () => {
|
|
59
|
+
const onChange = () => {}
|
|
60
|
+
const p: Record<string, unknown> = { onChange }
|
|
61
|
+
mapCompatDomProps(p, 'div')
|
|
62
|
+
expect(p).toEqual({ onChange })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('autoFocus → autofocus', () => {
|
|
66
|
+
const p: Record<string, unknown> = { autoFocus: true }
|
|
67
|
+
mapCompatDomProps(p, 'input')
|
|
68
|
+
expect(p).toEqual({ autofocus: true })
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('defaultValue/defaultChecked → value/checked only when uncontrolled', () => {
|
|
72
|
+
const a: Record<string, unknown> = { defaultValue: 'd', defaultChecked: true }
|
|
73
|
+
mapCompatDomProps(a, 'input')
|
|
74
|
+
expect(a).toEqual({ value: 'd', checked: true })
|
|
75
|
+
|
|
76
|
+
const b: Record<string, unknown> = {
|
|
77
|
+
defaultValue: 'd',
|
|
78
|
+
value: 'controlled',
|
|
79
|
+
defaultChecked: true,
|
|
80
|
+
checked: false,
|
|
81
|
+
}
|
|
82
|
+
mapCompatDomProps(b, 'input')
|
|
83
|
+
expect(b).toEqual({
|
|
84
|
+
defaultValue: 'd',
|
|
85
|
+
value: 'controlled',
|
|
86
|
+
defaultChecked: true,
|
|
87
|
+
checked: false,
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('strips authoring-only props with no DOM equivalent', () => {
|
|
92
|
+
const p: Record<string, unknown> = {
|
|
93
|
+
suppressHydrationWarning: true,
|
|
94
|
+
suppressContentEditableWarning: true,
|
|
95
|
+
}
|
|
96
|
+
mapCompatDomProps(p, 'div')
|
|
97
|
+
expect(p).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clearReactiveTrace, signal } from '@pyreon/reactivity'
|
|
1
2
|
import type { ErrorContext } from '../telemetry'
|
|
2
3
|
import { registerErrorHandler, reportError } from '../telemetry'
|
|
3
4
|
|
|
@@ -201,3 +202,96 @@ describe('registerErrorHandler — reactivity bridge (regression)', () => {
|
|
|
201
202
|
unsub2()
|
|
202
203
|
})
|
|
203
204
|
})
|
|
205
|
+
|
|
206
|
+
describe('reportError — reactiveTrace enrichment', () => {
|
|
207
|
+
beforeEach(() => clearReactiveTrace())
|
|
208
|
+
|
|
209
|
+
test('attaches recent signal writes to the error context (dev)', () => {
|
|
210
|
+
const s = signal(0, { name: 'enrichTest' })
|
|
211
|
+
s.set(1)
|
|
212
|
+
s.set(2)
|
|
213
|
+
|
|
214
|
+
let captured: ErrorContext | undefined
|
|
215
|
+
const unsub = registerErrorHandler((ctx) => {
|
|
216
|
+
captured = ctx
|
|
217
|
+
})
|
|
218
|
+
reportError({
|
|
219
|
+
component: 'C',
|
|
220
|
+
phase: 'render',
|
|
221
|
+
error: new Error('boom'),
|
|
222
|
+
timestamp: Date.now(),
|
|
223
|
+
})
|
|
224
|
+
unsub()
|
|
225
|
+
|
|
226
|
+
expect(captured?.reactiveTrace).toBeDefined()
|
|
227
|
+
expect(captured!.reactiveTrace).toHaveLength(2)
|
|
228
|
+
expect(captured!.reactiveTrace![0]).toMatchObject({
|
|
229
|
+
name: 'enrichTest',
|
|
230
|
+
prev: '0',
|
|
231
|
+
next: '1',
|
|
232
|
+
})
|
|
233
|
+
expect(captured!.reactiveTrace![1]).toMatchObject({ prev: '1', next: '2' })
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
test('does not overwrite a caller-supplied reactiveTrace', () => {
|
|
237
|
+
const s = signal(0, { name: 'x' })
|
|
238
|
+
s.set(99)
|
|
239
|
+
|
|
240
|
+
let captured: ErrorContext | undefined
|
|
241
|
+
const unsub = registerErrorHandler((ctx) => {
|
|
242
|
+
captured = ctx
|
|
243
|
+
})
|
|
244
|
+
const supplied = [{ name: 'manual', prev: 'a', next: 'b', timestamp: 1 }]
|
|
245
|
+
reportError({
|
|
246
|
+
component: 'C',
|
|
247
|
+
phase: 'effect',
|
|
248
|
+
error: new Error('boom'),
|
|
249
|
+
timestamp: Date.now(),
|
|
250
|
+
reactiveTrace: supplied,
|
|
251
|
+
})
|
|
252
|
+
unsub()
|
|
253
|
+
|
|
254
|
+
expect(captured!.reactiveTrace).toBe(supplied)
|
|
255
|
+
})
|
|
256
|
+
|
|
257
|
+
test('no trace field when there were no signal writes', () => {
|
|
258
|
+
let captured: ErrorContext | undefined
|
|
259
|
+
const unsub = registerErrorHandler((ctx) => {
|
|
260
|
+
captured = ctx
|
|
261
|
+
})
|
|
262
|
+
reportError({
|
|
263
|
+
component: 'C',
|
|
264
|
+
phase: 'mount',
|
|
265
|
+
error: new Error('boom'),
|
|
266
|
+
timestamp: Date.now(),
|
|
267
|
+
})
|
|
268
|
+
unsub()
|
|
269
|
+
|
|
270
|
+
// Empty buffer → field stays undefined (don't attach a noisy []).
|
|
271
|
+
expect(captured?.reactiveTrace).toBeUndefined()
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('the effect-error bridge path is also enriched', () => {
|
|
275
|
+
const s = signal('idle', { name: 'phase' })
|
|
276
|
+
s.set('running')
|
|
277
|
+
|
|
278
|
+
let captured: ErrorContext | undefined
|
|
279
|
+
const unsub = registerErrorHandler((ctx) => {
|
|
280
|
+
captured = ctx
|
|
281
|
+
})
|
|
282
|
+
// Drive the reactivity → core bridge the same way an effect throw does.
|
|
283
|
+
const bridge = (
|
|
284
|
+
globalThis as { __pyreon_report_error__?: (e: unknown, p: 'effect') => void }
|
|
285
|
+
).__pyreon_report_error__
|
|
286
|
+
bridge?.(new Error('effect boom'), 'effect')
|
|
287
|
+
unsub()
|
|
288
|
+
|
|
289
|
+
expect(captured?.component).toBe('Effect')
|
|
290
|
+
expect(captured?.reactiveTrace).toBeDefined()
|
|
291
|
+
expect(captured!.reactiveTrace![0]).toMatchObject({
|
|
292
|
+
name: 'phase',
|
|
293
|
+
prev: '"idle"',
|
|
294
|
+
next: '"running"',
|
|
295
|
+
})
|
|
296
|
+
})
|
|
297
|
+
})
|