@pyreon/core 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.
Files changed (44) hide show
  1. package/README.md +10 -1
  2. package/lib/analysis/index.js.html +1 -1
  3. package/lib/analysis/jsx-dev-runtime.js.html +1 -1
  4. package/lib/analysis/jsx-runtime.js.html +1 -1
  5. package/lib/index.js +168 -34
  6. package/lib/jsx-dev-runtime.js +7 -0
  7. package/lib/jsx-runtime.js +7 -0
  8. package/lib/types/index.d.ts +112 -15
  9. package/lib/types/jsx-dev-runtime.d.ts +6 -3
  10. package/lib/types/jsx-runtime.d.ts +6 -3
  11. package/package.json +3 -2
  12. package/src/compat-marker.ts +79 -0
  13. package/src/component.ts +2 -1
  14. package/src/context.ts +38 -7
  15. package/src/dynamic.ts +16 -5
  16. package/src/error-boundary.ts +15 -2
  17. package/src/index.ts +2 -0
  18. package/src/jsx-runtime.ts +20 -2
  19. package/src/lifecycle.ts +17 -6
  20. package/src/manifest.ts +43 -3
  21. package/src/props.ts +19 -6
  22. package/src/show.ts +19 -6
  23. package/src/suspense.ts +1 -2
  24. package/src/telemetry.ts +30 -2
  25. package/src/tests/compat-marker.test.ts +96 -0
  26. package/src/tests/component.test.ts +5 -5
  27. package/src/tests/context.test.ts +3 -3
  28. package/src/tests/core.test.ts +11 -11
  29. package/src/tests/dynamic.test.ts +33 -1
  30. package/src/tests/error-boundary.test.ts +1 -1
  31. package/src/tests/lifecycle.test.ts +18 -18
  32. package/src/tests/manifest-snapshot.test.ts +6 -1
  33. package/src/tests/native-marker-error-boundary.test.ts +12 -0
  34. package/src/tests/reactive-context.test.ts +3 -3
  35. package/src/tests/reactive-props.test.ts +87 -0
  36. package/src/tests/show.test.ts +76 -0
  37. package/src/tests/telemetry.test.ts +61 -0
  38. package/src/types.ts +9 -5
  39. package/lib/index.js.map +0 -1
  40. package/lib/jsx-dev-runtime.js.map +0 -1
  41. package/lib/jsx-runtime.js.map +0 -1
  42. package/lib/types/index.d.ts.map +0 -1
  43. package/lib/types/jsx-dev-runtime.d.ts.map +0 -1
  44. package/lib/types/jsx-runtime.d.ts.map +0 -1
@@ -1,6 +1,8 @@
1
1
  //#region src/types.d.ts
2
2
  type VNodeChildAtom = VNode | string | number | boolean | null | undefined;
3
- type VNodeChild = VNodeChildAtom | VNodeChildAtom[] | (() => VNodeChildAtom | VNodeChildAtom[]);
3
+ /** Reactive accessor TS checks this arm FIRST so `{() => cond && <X />}` resolves correctly */
4
+ type VNodeChildAccessor = () => VNodeChildAtom | VNodeChildAtom[];
5
+ type VNodeChild = VNodeChildAccessor | VNodeChildAtom | VNodeChildAtom[];
4
6
  interface VNode {
5
7
  /** Tag name, component function, or special symbol (Fragment) */
6
8
  type: string | ComponentFn | symbol;
@@ -39,11 +41,11 @@ interface NativeItem {
39
41
  cleanup: (() => void) | null;
40
42
  }
41
43
  interface LifecycleHooks {
42
- mount: (() => CleanupFn | void | undefined)[];
43
- unmount: (() => void)[];
44
- update: (() => void)[];
44
+ mount: (() => CleanupFn | void | undefined)[] | null;
45
+ unmount: (() => void)[] | null;
46
+ update: (() => void)[] | null;
45
47
  /** Error handlers — return true to mark the error as handled (stops propagation). */
46
- error: ((err: unknown) => boolean | undefined)[];
48
+ error: ((err: unknown) => boolean | undefined)[] | null;
47
49
  }
48
50
  //#endregion
49
51
  //#region src/component.d.ts
@@ -73,6 +75,74 @@ declare function propagateError(err: unknown, hooks: LifecycleHooks): boolean;
73
75
  */
74
76
  declare function dispatchToErrorBoundary(err: unknown): boolean;
75
77
  //#endregion
78
+ //#region src/compat-marker.d.ts
79
+ /**
80
+ * Compat-mode native-component marker.
81
+ *
82
+ * Pyreon ships compat layers (`@pyreon/{react,preact,vue,solid}-compat`) that
83
+ * wrap every JSX-called component function to emulate that source framework's
84
+ * render-on-state-change semantics. That wrapping is correct for user code
85
+ * (the whole point of compat mode) but corrupts Pyreon framework components
86
+ * — those manage their own reactivity via `provide()` / signals / lifecycle
87
+ * hooks, and wrapping them runs their setup body inside the compat layer's
88
+ * render context instead of Pyreon's, breaking `provide()` and
89
+ * `onMount()` / `onUnmount()` calls.
90
+ *
91
+ * Framework components opt out of compat wrapping by setting a well-known
92
+ * registry symbol (`Symbol.for('pyreon:native-compat')`) on the function.
93
+ * The compat layer reads that symbol and routes marked components straight
94
+ * through Pyreon's `h()` mount path. The symbol is registry-shared, so no
95
+ * import direction between framework and compat is implied — both sides
96
+ * reference the same global symbol via the helpers exported here.
97
+ *
98
+ * Audience: framework-package authors writing JSX components in `@pyreon/*`
99
+ * packages whose setup body uses `provide()` / lifecycle hooks / signal
100
+ * subscriptions. Wrap exported components with `nativeCompat()`. One line
101
+ * per export site; zero runtime cost beyond a single property write at
102
+ * module load.
103
+ */
104
+ /**
105
+ * The well-known registry symbol that marks a component as a Pyreon native
106
+ * framework component. Compat layers check this symbol to decide whether to
107
+ * skip their `wrapCompatComponent` call.
108
+ *
109
+ * Exported for advanced cases where a caller needs to test the marker
110
+ * directly (most callers should use `isNativeCompat()`).
111
+ */
112
+ declare const NATIVE_COMPAT_MARKER: symbol;
113
+ /**
114
+ * Mark a Pyreon framework component as "self-managing" — compat layers will
115
+ * skip their React/Vue/Solid/Preact-style wrapping and route the component
116
+ * directly through Pyreon's mount path. Use on every `@pyreon/*` JSX
117
+ * component whose setup body uses `provide()`, lifecycle hooks
118
+ * (`onMount` / `onUnmount`), signal-driven reactivity, or any other Pyreon
119
+ * native pattern that depends on the active component-setup frame.
120
+ *
121
+ * Idempotent: re-applying the marker is a no-op. Non-function inputs pass
122
+ * through unchanged so callers don't have to typecheck before wrapping.
123
+ *
124
+ * @example
125
+ * import { nativeCompat, provide } from '@pyreon/core'
126
+ *
127
+ * export const RouterView = nativeCompat(function RouterView(props) {
128
+ * provide(RouterContext, ...)
129
+ * return <div data-pyreon-router-view>{children}</div>
130
+ * })
131
+ */
132
+ declare function nativeCompat<T>(fn: T): T;
133
+ /**
134
+ * Read whether a component has been marked as a Pyreon native framework
135
+ * component. Compat-layer code calls this from its `jsx()` to decide whether
136
+ * to wrap or pass through.
137
+ *
138
+ * @example
139
+ * import { isNativeCompat } from '@pyreon/core'
140
+ *
141
+ * if (isNativeCompat(type)) return h(type, props)
142
+ * return wrapCompatComponent(type)(props)
143
+ */
144
+ declare function isNativeCompat(fn: unknown): boolean;
145
+ //#endregion
76
146
  //#region src/context.d.ts
77
147
  /**
78
148
  * Provide / inject — like React context or Vue provide/inject.
@@ -153,7 +223,16 @@ type ContextSnapshot = Map<symbol, unknown>[];
153
223
  declare function captureContextStack(): ContextSnapshot;
154
224
  /**
155
225
  * Execute `fn()` with a previously captured context stack active.
156
- * Restores the original stack after `fn()` completes (even on throw).
226
+ *
227
+ * After `fn()` returns, removes ONLY the snapshot frames this call pushed
228
+ * — anything `fn()` itself pushed (typically provider frames from
229
+ * `provide()` calls during component mount) stays on the stack so
230
+ * subsequent reactive re-runs (e.g. `_bind` text bindings,
231
+ * `renderEffect` callbacks) can still find ancestor providers via
232
+ * `useContext`. Pre-fix this method was `stack.length = savedLength`,
233
+ * which destructively truncated provider frames pushed during mount —
234
+ * silently breaking `useMode()` / `useTheme()` / `useRouter()` etc. on
235
+ * every signal-driven update under a `mountReactive` boundary.
157
236
  */
158
237
  declare function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T;
159
238
  //#endregion
@@ -363,6 +442,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
363
442
  } | undefined;
364
443
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
365
444
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
445
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
366
446
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
367
447
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
368
448
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -426,7 +506,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
426
506
  defaultChecked?: boolean | undefined;
427
507
  placeholder?: string | (() => string) | undefined;
428
508
  disabled?: boolean | (() => boolean) | undefined;
429
- readOnly?: boolean | undefined;
509
+ readOnly?: boolean | (() => boolean) | undefined;
430
510
  required?: boolean | (() => boolean) | undefined;
431
511
  min?: string | number | undefined;
432
512
  max?: string | number | undefined;
@@ -475,7 +555,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
475
555
  defaultValue?: string | undefined;
476
556
  placeholder?: string | (() => string) | undefined;
477
557
  disabled?: boolean | (() => boolean) | undefined;
478
- readOnly?: boolean | undefined;
558
+ readOnly?: boolean | (() => boolean) | undefined;
479
559
  required?: boolean | (() => boolean) | undefined;
480
560
  rows?: number | undefined;
481
561
  cols?: number | undefined;
@@ -1029,8 +1109,15 @@ declare function createUniqueId(): string;
1029
1109
  //#endregion
1030
1110
  //#region src/show.d.ts
1031
1111
  interface ShowProps extends Props {
1032
- /** Accessor — children render when truthy, fallback when falsy. */
1033
- when: () => unknown;
1112
+ /**
1113
+ * Truthy condition. Accepts a value or an accessor.
1114
+ *
1115
+ * Use an accessor (`() => signal()`) for reactive conditions.
1116
+ * Bare values are accepted for static cases and as a defensive normalization
1117
+ * for cases where the compiler's signal auto-call has already invoked
1118
+ * a signal at the prop site (e.g. `when={mySignal}` becomes `when={mySignal()}`).
1119
+ */
1120
+ when: unknown | (() => unknown);
1034
1121
  fallback?: VNodeChild;
1035
1122
  children?: VNodeChild;
1036
1123
  }
@@ -1049,8 +1136,8 @@ interface ShowProps extends Props {
1049
1136
  */
1050
1137
  declare function Show(props: ShowProps): VNode | null;
1051
1138
  interface MatchProps extends Props {
1052
- /** Accessor this branch renders when truthy. */
1053
- when: () => unknown;
1139
+ /** Truthy condition. Accepts a value or an accessor — see {@link ShowProps.when}. */
1140
+ when: unknown | (() => unknown);
1054
1141
  children?: VNodeChild;
1055
1142
  }
1056
1143
  /**
@@ -1073,6 +1160,10 @@ declare const MatchSymbol: unique symbol;
1073
1160
  /**
1074
1161
  * Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
1075
1162
  *
1163
+ * Captures errors from ALL lifecycle phases including reactive effects.
1164
+ * `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
1165
+ * globalThis sink (no upward import — reactivity doesn't depend on core).
1166
+ *
1076
1167
  * @example
1077
1168
  * import { registerErrorHandler } from "@pyreon/core"
1078
1169
  * import * as Sentry from "@sentry/browser"
@@ -1084,7 +1175,7 @@ declare const MatchSymbol: unique symbol;
1084
1175
  * })
1085
1176
  */
1086
1177
  interface ErrorContext {
1087
- /** Component function name, or "Anonymous" */
1178
+ /** Component function name, "Anonymous", or "Effect" for reactive effects */
1088
1179
  component: string;
1089
1180
  /** Lifecycle phase where the error occurred */
1090
1181
  phase: 'setup' | 'render' | 'mount' | 'unmount' | 'effect';
@@ -1098,7 +1189,13 @@ interface ErrorContext {
1098
1189
  type ErrorHandler = (ctx: ErrorContext) => void;
1099
1190
  /**
1100
1191
  * Register a global error handler. Called whenever a component throws in any
1101
- * lifecycle phase. Returns an unregister function.
1192
+ * lifecycle phase, OR an effect throws in `@pyreon/reactivity`. Returns an
1193
+ * unregister function.
1194
+ *
1195
+ * Also installs a `globalThis.__pyreon_report_error__` bridge so the
1196
+ * reactivity package (which can't depend on core) can forward effect errors
1197
+ * into the same telemetry pipeline. Pre-fix the two surfaces were
1198
+ * disconnected — Sentry/Datadog wiring missed effect-thrown errors.
1102
1199
  */
1103
1200
  declare function registerErrorHandler(handler: ErrorHandler): () => void;
1104
1201
  /**
@@ -1107,5 +1204,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
1107
1204
  */
1108
1205
  declare function reportError(ctx: ErrorContext): void;
1109
1206
  //#endregion
1110
- export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, 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, 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 VNodeChildAtom, _rp, captureContextStack, createContext, createReactiveContext, createRef, createUniqueId, cx, defineComponent, dispatchToErrorBoundary, h, lazy, makeReactiveProps, mapArray, mergeProps, normalizeStyleValue, onErrorCaptured, onMount, onUnmount, onUpdate, popContext, propagateError, provide, pushContext, registerErrorHandler, reportError, restoreContextStack, runWithHooks, setContextStackProvider, splitProps, toKebabCase, useContext, withContext };
1207
+ export { type AnchorAttributes, type ButtonAttributes, type CSSProperties, CSS_UNITLESS, type ClassValue, type CleanupFn, type ComponentFn, type ComponentInstance, type Context, type ContextSnapshot, 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, 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 };
1111
1208
  //# sourceMappingURL=index2.d.ts.map
@@ -1,6 +1,8 @@
1
1
  //#region src/types.d.ts
2
2
  type VNodeChildAtom = VNode | string | number | boolean | null | undefined;
3
- type VNodeChild = VNodeChildAtom | VNodeChildAtom[] | (() => VNodeChildAtom | VNodeChildAtom[]);
3
+ /** Reactive accessor TS checks this arm FIRST so `{() => cond && <X />}` resolves correctly */
4
+ type VNodeChildAccessor = () => VNodeChildAtom | VNodeChildAtom[];
5
+ type VNodeChild = VNodeChildAccessor | VNodeChildAtom | VNodeChildAtom[];
4
6
  interface VNode {
5
7
  /** Tag name, component function, or special symbol (Fragment) */
6
8
  type: string | ComponentFn | symbol;
@@ -133,6 +135,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
133
135
  } | undefined;
134
136
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
135
137
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
136
139
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
137
140
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
141
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -196,7 +199,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
196
199
  defaultChecked?: boolean | undefined;
197
200
  placeholder?: string | (() => string) | undefined;
198
201
  disabled?: boolean | (() => boolean) | undefined;
199
- readOnly?: boolean | undefined;
202
+ readOnly?: boolean | (() => boolean) | undefined;
200
203
  required?: boolean | (() => boolean) | undefined;
201
204
  min?: string | number | undefined;
202
205
  max?: string | number | undefined;
@@ -245,7 +248,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
245
248
  defaultValue?: string | undefined;
246
249
  placeholder?: string | (() => string) | undefined;
247
250
  disabled?: boolean | (() => boolean) | undefined;
248
- readOnly?: boolean | undefined;
251
+ readOnly?: boolean | (() => boolean) | undefined;
249
252
  required?: boolean | (() => boolean) | undefined;
250
253
  rows?: number | undefined;
251
254
  cols?: number | undefined;
@@ -1,6 +1,8 @@
1
1
  //#region src/types.d.ts
2
2
  type VNodeChildAtom = VNode | string | number | boolean | null | undefined;
3
- type VNodeChild = VNodeChildAtom | VNodeChildAtom[] | (() => VNodeChildAtom | VNodeChildAtom[]);
3
+ /** Reactive accessor TS checks this arm FIRST so `{() => cond && <X />}` resolves correctly */
4
+ type VNodeChildAccessor = () => VNodeChildAtom | VNodeChildAtom[];
5
+ type VNodeChild = VNodeChildAccessor | VNodeChildAtom | VNodeChildAtom[];
4
6
  interface VNode {
5
7
  /** Tag name, component function, or special symbol (Fragment) */
6
8
  type: string | ComponentFn | symbol;
@@ -133,6 +135,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
133
135
  } | undefined;
134
136
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
135
137
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
136
139
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
137
140
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
141
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -196,7 +199,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
196
199
  defaultChecked?: boolean | undefined;
197
200
  placeholder?: string | (() => string) | undefined;
198
201
  disabled?: boolean | (() => boolean) | undefined;
199
- readOnly?: boolean | undefined;
202
+ readOnly?: boolean | (() => boolean) | undefined;
200
203
  required?: boolean | (() => boolean) | undefined;
201
204
  min?: string | number | undefined;
202
205
  max?: string | number | undefined;
@@ -245,7 +248,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
245
248
  defaultValue?: string | undefined;
246
249
  placeholder?: string | (() => string) | undefined;
247
250
  disabled?: boolean | (() => boolean) | undefined;
248
- readOnly?: boolean | undefined;
251
+ readOnly?: boolean | (() => boolean) | undefined;
249
252
  required?: boolean | (() => boolean) | undefined;
250
253
  rows?: number | undefined;
251
254
  cols?: number | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/core",
3
- "version": "0.13.1",
3
+ "version": "0.15.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": {
@@ -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"
@@ -52,7 +53,7 @@
52
53
  "prepublishOnly": "bun run build"
53
54
  },
54
55
  "dependencies": {
55
- "@pyreon/reactivity": "^0.13.1"
56
+ "@pyreon/reactivity": "^0.15.0"
56
57
  },
57
58
  "devDependencies": {
58
59
  "@pyreon/manifest": "0.13.1"
@@ -0,0 +1,79 @@
1
+ /**
2
+ * Compat-mode native-component marker.
3
+ *
4
+ * Pyreon ships compat layers (`@pyreon/{react,preact,vue,solid}-compat`) that
5
+ * wrap every JSX-called component function to emulate that source framework's
6
+ * render-on-state-change semantics. That wrapping is correct for user code
7
+ * (the whole point of compat mode) but corrupts Pyreon framework components
8
+ * — those manage their own reactivity via `provide()` / signals / lifecycle
9
+ * hooks, and wrapping them runs their setup body inside the compat layer's
10
+ * render context instead of Pyreon's, breaking `provide()` and
11
+ * `onMount()` / `onUnmount()` calls.
12
+ *
13
+ * Framework components opt out of compat wrapping by setting a well-known
14
+ * registry symbol (`Symbol.for('pyreon:native-compat')`) on the function.
15
+ * The compat layer reads that symbol and routes marked components straight
16
+ * through Pyreon's `h()` mount path. The symbol is registry-shared, so no
17
+ * import direction between framework and compat is implied — both sides
18
+ * reference the same global symbol via the helpers exported here.
19
+ *
20
+ * Audience: framework-package authors writing JSX components in `@pyreon/*`
21
+ * packages whose setup body uses `provide()` / lifecycle hooks / signal
22
+ * subscriptions. Wrap exported components with `nativeCompat()`. One line
23
+ * per export site; zero runtime cost beyond a single property write at
24
+ * module load.
25
+ */
26
+
27
+ /**
28
+ * The well-known registry symbol that marks a component as a Pyreon native
29
+ * framework component. Compat layers check this symbol to decide whether to
30
+ * skip their `wrapCompatComponent` call.
31
+ *
32
+ * Exported for advanced cases where a caller needs to test the marker
33
+ * directly (most callers should use `isNativeCompat()`).
34
+ */
35
+ export const NATIVE_COMPAT_MARKER: symbol = Symbol.for('pyreon:native-compat')
36
+
37
+ /**
38
+ * Mark a Pyreon framework component as "self-managing" — compat layers will
39
+ * skip their React/Vue/Solid/Preact-style wrapping and route the component
40
+ * directly through Pyreon's mount path. Use on every `@pyreon/*` JSX
41
+ * component whose setup body uses `provide()`, lifecycle hooks
42
+ * (`onMount` / `onUnmount`), signal-driven reactivity, or any other Pyreon
43
+ * native pattern that depends on the active component-setup frame.
44
+ *
45
+ * Idempotent: re-applying the marker is a no-op. Non-function inputs pass
46
+ * through unchanged so callers don't have to typecheck before wrapping.
47
+ *
48
+ * @example
49
+ * import { nativeCompat, provide } from '@pyreon/core'
50
+ *
51
+ * export const RouterView = nativeCompat(function RouterView(props) {
52
+ * provide(RouterContext, ...)
53
+ * return <div data-pyreon-router-view>{children}</div>
54
+ * })
55
+ */
56
+ export function nativeCompat<T>(fn: T): T {
57
+ if (typeof fn === 'function') {
58
+ ;(fn as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER] = true
59
+ }
60
+ return fn
61
+ }
62
+
63
+ /**
64
+ * Read whether a component has been marked as a Pyreon native framework
65
+ * component. Compat-layer code calls this from its `jsx()` to decide whether
66
+ * to wrap or pass through.
67
+ *
68
+ * @example
69
+ * import { isNativeCompat } from '@pyreon/core'
70
+ *
71
+ * if (isNativeCompat(type)) return h(type, props)
72
+ * return wrapCompatComponent(type)(props)
73
+ */
74
+ export function isNativeCompat(fn: unknown): boolean {
75
+ return (
76
+ typeof fn === 'function' &&
77
+ (fn as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER] === true
78
+ )
79
+ }
package/src/component.ts CHANGED
@@ -19,7 +19,7 @@ export function runWithHooks<P extends Props>(
19
19
  fn: ComponentFn<P>,
20
20
  props: P,
21
21
  ): { vnode: VNodeChild; hooks: LifecycleHooks } {
22
- const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
22
+ const hooks: LifecycleHooks = { mount: null, unmount: null, update: null, error: null }
23
23
  setCurrentHooks(hooks)
24
24
  let vnode: VNodeChild = null
25
25
  try {
@@ -35,6 +35,7 @@ export function runWithHooks<P extends Props>(
35
35
  * Returns true if any handler marked the error as handled.
36
36
  */
37
37
  export function propagateError(err: unknown, hooks: LifecycleHooks): boolean {
38
+ if (!hooks.error) return false
38
39
  for (const handler of hooks.error) {
39
40
  if (handler(err) === true) return true
40
41
  }
package/src/context.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  * The renderer maintains the context stack as it walks the VNode tree.
6
6
  */
7
7
 
8
+ import { setSnapshotCapture } from '@pyreon/reactivity'
8
9
  import { onUnmount } from './lifecycle'
9
10
 
10
11
  export interface Context<T> {
@@ -66,8 +67,7 @@ function getStack(): Map<symbol, unknown>[] {
66
67
 
67
68
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
68
69
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
69
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
70
- const __DEV__ = import.meta.env?.DEV === true
70
+ const __DEV__ = process.env.NODE_ENV !== 'production'
71
71
 
72
72
  export function pushContext(values: Map<symbol, unknown>) {
73
73
  getStack().push(values)
@@ -149,13 +149,22 @@ export function captureContextStack(): ContextSnapshot {
149
149
 
150
150
  /**
151
151
  * Execute `fn()` with a previously captured context stack active.
152
- * Restores the original stack after `fn()` completes (even on throw).
152
+ *
153
+ * After `fn()` returns, removes ONLY the snapshot frames this call pushed
154
+ * — anything `fn()` itself pushed (typically provider frames from
155
+ * `provide()` calls during component mount) stays on the stack so
156
+ * subsequent reactive re-runs (e.g. `_bind` text bindings,
157
+ * `renderEffect` callbacks) can still find ancestor providers via
158
+ * `useContext`. Pre-fix this method was `stack.length = savedLength`,
159
+ * which destructively truncated provider frames pushed during mount —
160
+ * silently breaking `useMode()` / `useTheme()` / `useRouter()` etc. on
161
+ * every signal-driven update under a `mountReactive` boundary.
153
162
  */
154
163
  export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T {
155
164
  const stack = getStack()
156
- const savedLength = stack.length
165
+ const insertIndex = stack.length
157
166
 
158
- // Push all captured frames onto the current stack
167
+ // Push captured snapshot frames at the END of the current stack.
159
168
  for (const frame of snapshot) {
160
169
  stack.push(frame)
161
170
  }
@@ -163,7 +172,29 @@ export function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T):
163
172
  try {
164
173
  return fn()
165
174
  } finally {
166
- // Remove only the frames we pushed (preserve anything added by fn)
167
- stack.length = savedLength
175
+ // Splice out exactly the snapshot frames we pushed (they sit at
176
+ // [insertIndex, insertIndex + snapshot.length)). Any frames `fn()`
177
+ // pushed AFTER our snapshot (provider frames) get shifted down by
178
+ // `snapshot.length` positions but remain on the stack. Their owning
179
+ // components' `onUnmount(popContext)` handlers will pop them in
180
+ // LIFO order on subtree teardown — splice preserves that ordering
181
+ // because it doesn't touch frames at indices >= insertIndex +
182
+ // snapshot.length until the splice operation itself.
183
+ stack.splice(insertIndex, snapshot.length)
168
184
  }
169
185
  }
186
+
187
+ // ─── Reactivity-layer DI: install context capture/restore for effects ────────
188
+ //
189
+ // `_bind` / `renderEffect` / `effect` (in `@pyreon/reactivity`) capture this
190
+ // snapshot at setup and restore it on every subsequent re-run. Without this,
191
+ // signal-driven re-runs after the synchronous mount see whatever the GLOBAL
192
+ // context stack looks like at that moment — which may be missing provider
193
+ // frames for any number of reasons (sibling subtree mounts/unmounts mutating
194
+ // the stack, async re-render cycles, etc.). Defense-in-depth alongside the
195
+ // `restoreContextStack` splice fix above.
196
+ setSnapshotCapture({
197
+ capture: () => captureContextStack(),
198
+ restore: <T>(snap: unknown, fn: () => T): T =>
199
+ restoreContextStack(snap as ContextSnapshot, fn),
200
+ })
package/src/dynamic.ts CHANGED
@@ -1,21 +1,32 @@
1
1
  import { h } from './h'
2
- import type { ComponentFn, Props, VNode } from './types'
2
+ import type { ComponentFn, Props, VNode, VNodeChild } from './types'
3
3
 
4
4
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
5
5
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
6
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
7
- const __DEV__ = import.meta.env?.DEV === true
6
+ const __DEV__ = process.env.NODE_ENV !== 'production'
8
7
 
9
8
  export interface DynamicProps extends Props {
10
9
  component: ComponentFn | string
11
10
  }
12
11
 
13
12
  export function Dynamic(props: DynamicProps): VNode | null {
14
- const { component, ...rest } = props
13
+ const { component, children, ...rest } = props as DynamicProps & { children?: unknown }
15
14
  if (__DEV__ && !component) {
16
15
  // oxlint-disable-next-line no-console
17
16
  console.warn('[Pyreon] <Dynamic> received a falsy `component` prop. Nothing will be rendered.')
18
17
  }
19
18
  if (!component) return null
20
- return h(component as string | ComponentFn, rest as Props)
19
+ // Children must NOT remain in props. When `component` is a string tag
20
+ // (e.g. <Dynamic component="h3">x</Dynamic>), runtime-dom's prop applier
21
+ // forwards every prop key to setAttribute, so a leaked `children` prop
22
+ // crashes with `setAttribute('children', ...)`. Re-emit them as h() rest
23
+ // args so they land in vnode.children, which is where both string-tag
24
+ // mounts and component-merge expect them.
25
+ if (children === undefined) {
26
+ return h(component as string | ComponentFn, rest as Props)
27
+ }
28
+ if (Array.isArray(children)) {
29
+ return h(component as string | ComponentFn, rest as Props, ...(children as VNodeChild[]))
30
+ }
31
+ return h(component as string | ComponentFn, rest as Props, children as VNodeChild)
21
32
  }
@@ -1,4 +1,5 @@
1
1
  import { signal } from '@pyreon/reactivity'
2
+ import { nativeCompat } from './compat-marker'
2
3
  import { popErrorBoundary, pushErrorBoundary } from './component'
3
4
  import { onUnmount } from './lifecycle'
4
5
  import { reportError } from './telemetry'
@@ -6,8 +7,7 @@ import type { VNodeChild, VNodeChildAtom } from './types'
6
7
 
7
8
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
8
9
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
9
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
10
- const __DEV__ = import.meta.env?.DEV === true
10
+ const __DEV__ = process.env.NODE_ENV !== 'production'
11
11
 
12
12
  /**
13
13
  * ErrorBoundary — catches errors thrown by child components and renders a
@@ -53,6 +53,14 @@ export function ErrorBoundary(props: {
53
53
 
54
54
  const handler = (err: unknown): boolean => {
55
55
  if (error.peek() !== null) return false // already in error state — let outer boundary catch it
56
+ // Synchronous signal write. The handler fires from inside mountComponent's
57
+ // catch, which is itself inside the boundary's own mountReactive effect
58
+ // run (the run that mounted the throwing child). The batch system's
59
+ // two-tier flush handles this correctly: this `error.set(err)` enqueues
60
+ // the boundary's run into the effects queue's nextPass (since the run is
61
+ // currently being visited), and the next pass fires it to swap to the
62
+ // fallback subtree. See packages/core/reactivity/src/batch.ts for the
63
+ // multi-pass effect drain contract.
56
64
  error.set(err)
57
65
  reportError({ component: 'ErrorBoundary', phase: 'render', error: err, timestamp: Date.now() })
58
66
  return true
@@ -69,3 +77,8 @@ export function ErrorBoundary(props: {
69
77
  return (typeof ch === 'function' ? ch() : ch) as VNodeChildAtom
70
78
  }
71
79
  }
80
+
81
+ // Mark as native so compat-mode jsx() runtimes (react/preact/vue/solid-compat)
82
+ // skip wrapCompatComponent — ErrorBoundary uses pushErrorBoundary/onUnmount,
83
+ // which need Pyreon's setup frame (compat wrapping breaks dispatchToErrorBoundary).
84
+ nativeCompat(ErrorBoundary)
package/src/index.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // @pyreon/core — component model, VNode types, lifecycle hooks
2
2
 
3
3
  export { defineComponent, dispatchToErrorBoundary, propagateError, runWithHooks } from './component'
4
+ export { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from './compat-marker'
4
5
  export type { Context, ContextSnapshot, ReactiveContext } from './context'
5
6
  export {
6
7
  captureContextStack,
@@ -61,5 +62,6 @@ export type {
61
62
  Props,
62
63
  VNode,
63
64
  VNodeChild,
65
+ VNodeChildAccessor,
64
66
  VNodeChildAtom,
65
67
  } from './types'
@@ -1,9 +1,17 @@
1
+ /// <reference lib="dom" />
1
2
  /**
2
3
  * JSX automatic runtime.
3
4
  *
4
5
  * When tsconfig has `"jsxImportSource": "@pyreon/core"`, the TS/bundler compiler
5
6
  * rewrites JSX to imports from this file automatically:
6
7
  * <div class="x" /> → jsx("div", { class: "x" })
8
+ *
9
+ * The triple-slash reference above makes this file self-declare its DOM-lib
10
+ * dependency. Without it, any consumer whose tsconfig has `lib: ["ESNext"]`
11
+ * (no DOM) — e.g. backend-only packages like @pyreon/cli — fails to typecheck
12
+ * once `@pyreon/core` becomes resolvable from their dependency graph (e.g. via
13
+ * a transitive devDep), because tsc auto-resolves jsxImportSource and pulls
14
+ * jsx-runtime.ts into the consumer's compilation unit.
7
15
  */
8
16
  import { Fragment, h } from './h'
9
17
  import type { RefProp } from './ref'
@@ -132,6 +140,8 @@ export interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
132
140
  // Events — typed currentTarget via generic E
133
141
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
134
142
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
143
+ // React-compat alias for onDblClick — compiler maps to `dblclick` DOM event.
144
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
135
145
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
136
146
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
137
147
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined
@@ -197,7 +207,11 @@ export interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement>
197
207
  defaultChecked?: boolean | undefined
198
208
  placeholder?: string | (() => string) | undefined
199
209
  disabled?: boolean | (() => boolean) | undefined
200
- readOnly?: boolean | undefined
210
+ // `readOnly` is paired with `disabled` semantically — both accept a
211
+ // reactive callable so consumers can spread `useForm.register()`'s
212
+ // return value (which produces `readOnly: Accessor<boolean>`) directly
213
+ // onto `<input>` / `<textarea>` without losing reactivity.
214
+ readOnly?: boolean | (() => boolean) | undefined
201
215
  required?: boolean | (() => boolean) | undefined
202
216
  min?: string | number | undefined
203
217
  max?: string | number | undefined
@@ -249,7 +263,11 @@ export interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaEle
249
263
  defaultValue?: string | undefined
250
264
  placeholder?: string | (() => string) | undefined
251
265
  disabled?: boolean | (() => boolean) | undefined
252
- readOnly?: boolean | undefined
266
+ // `readOnly` is paired with `disabled` semantically — both accept a
267
+ // reactive callable so consumers can spread `useForm.register()`'s
268
+ // return value (which produces `readOnly: Accessor<boolean>`) directly
269
+ // onto `<input>` / `<textarea>` without losing reactivity.
270
+ readOnly?: boolean | (() => boolean) | undefined
253
271
  required?: boolean | (() => boolean) | undefined
254
272
  rows?: number | undefined
255
273
  cols?: number | undefined