@pyreon/core 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.
Files changed (40) hide show
  1. package/lib/analysis/index.js.html +1 -1
  2. package/lib/analysis/jsx-dev-runtime.js.html +1 -1
  3. package/lib/analysis/jsx-runtime.js.html +1 -1
  4. package/lib/index.js +144 -17
  5. package/lib/jsx-dev-runtime.js +23 -2
  6. package/lib/jsx-runtime.js +23 -2
  7. package/lib/types/index.d.ts +169 -15
  8. package/lib/types/jsx-dev-runtime.d.ts +19 -4
  9. package/lib/types/jsx-runtime.d.ts +19 -4
  10. package/package.json +3 -2
  11. package/src/compat-marker.ts +79 -0
  12. package/src/context.ts +38 -7
  13. package/src/dynamic.ts +16 -5
  14. package/src/error-boundary.ts +15 -2
  15. package/src/for.ts +13 -1
  16. package/src/h.ts +16 -2
  17. package/src/index.ts +1 -0
  18. package/src/jsx-runtime.ts +20 -2
  19. package/src/lifecycle.ts +1 -2
  20. package/src/manifest.ts +55 -7
  21. package/src/show.ts +19 -6
  22. package/src/suspense.ts +1 -2
  23. package/src/telemetry.ts +30 -2
  24. package/src/tests/compat-marker.test.ts +96 -0
  25. package/src/tests/core.test.ts +1 -1
  26. package/src/tests/dynamic.test.ts +33 -1
  27. package/src/tests/extract-props-overloads.types.test.ts +135 -0
  28. package/src/tests/for.test.ts +23 -0
  29. package/src/tests/h.test.ts +21 -0
  30. package/src/tests/manifest-snapshot.test.ts +6 -1
  31. package/src/tests/native-marker-error-boundary.test.ts +12 -0
  32. package/src/tests/show.test.ts +76 -0
  33. package/src/tests/telemetry.test.ts +61 -0
  34. package/src/types.ts +45 -2
  35. package/lib/index.js.map +0 -1
  36. package/lib/jsx-dev-runtime.js.map +0 -1
  37. package/lib/jsx-runtime.js.map +0 -1
  38. package/lib/types/index.d.ts.map +0 -1
  39. package/lib/types/jsx-dev-runtime.d.ts.map +0 -1
  40. package/lib/types/jsx-runtime.d.ts.map +0 -1
@@ -16,8 +16,41 @@ type Props = Record<string, unknown>;
16
16
  * It returns any renderable content and may call lifecycle hooks during setup.
17
17
  */
18
18
  type ComponentFn<P extends Props = Props> = (props: P) => VNodeChild;
19
- /** Extract the props type from a component function, or pass through if already a props type. */
20
- type ExtractProps<T> = T extends ComponentFn<infer P> ? P : T;
19
+ /**
20
+ * Extract the props type from a component function, or pass through if already
21
+ * a props type. **Multi-overload aware** — matches up to 4 call signatures and
22
+ * produces the UNION of their first-argument types. A single-overload function
23
+ * still works (the union of 4 copies of the same props type dedupes back to
24
+ * the single shape).
25
+ *
26
+ * **Why this shape**. `T extends (props: infer P) => any ? P : never` only
27
+ * captures the LAST overload of a multi-overload function — TS's overload-
28
+ * resolution-against-conditional-types semantics. Multi-overload primitives
29
+ * (Iterator, List, Element, etc.) need the union of every overload's props
30
+ * to survive HOC wrapping (`rocketstyle()`, `attrs()`) without silently
31
+ * downgrading the public prop surface to the loosest overload. Mirrors
32
+ * vitus-labs PR #222.
33
+ *
34
+ * @example
35
+ * function Iterator<T extends SimpleValue>(p: { data: T[]; valueName?: string }): VNodeChild
36
+ * function Iterator<T extends ObjectValue>(p: { data: T[]; component: ComponentFn<T> }): VNodeChild
37
+ * type Props = ExtractProps<typeof Iterator>
38
+ * // → { data: SimpleValue[]; valueName?: string }
39
+ * // | { data: ObjectValue[]; component: ComponentFn<ObjectValue> }
40
+ */
41
+ type ExtractProps<T> = T extends {
42
+ (props: infer P1, ...args: any): any;
43
+ (props: infer P2, ...args: any): any;
44
+ (props: infer P3, ...args: any): any;
45
+ (props: infer P4, ...args: any): any;
46
+ } ? P1 | P2 | P3 | P4 : T extends {
47
+ (props: infer P1, ...args: any): any;
48
+ (props: infer P2, ...args: any): any;
49
+ (props: infer P3, ...args: any): any;
50
+ } ? P1 | P2 | P3 : T extends {
51
+ (props: infer P1, ...args: any): any;
52
+ (props: infer P2, ...args: any): any;
53
+ } ? P1 | P2 : T extends ComponentFn<infer P> ? P : T;
21
54
  /** A higher-order component that wraps a component, optionally transforming its props. */
22
55
  type HigherOrderComponent<HOP extends Props, P extends Props | undefined = undefined> = (Component: ComponentFn<HOP>) => ComponentFn<P extends undefined ? HOP : P>;
23
56
  /**
@@ -75,6 +108,74 @@ declare function propagateError(err: unknown, hooks: LifecycleHooks): boolean;
75
108
  */
76
109
  declare function dispatchToErrorBoundary(err: unknown): boolean;
77
110
  //#endregion
111
+ //#region src/compat-marker.d.ts
112
+ /**
113
+ * Compat-mode native-component marker.
114
+ *
115
+ * Pyreon ships compat layers (`@pyreon/{react,preact,vue,solid}-compat`) that
116
+ * wrap every JSX-called component function to emulate that source framework's
117
+ * render-on-state-change semantics. That wrapping is correct for user code
118
+ * (the whole point of compat mode) but corrupts Pyreon framework components
119
+ * — those manage their own reactivity via `provide()` / signals / lifecycle
120
+ * hooks, and wrapping them runs their setup body inside the compat layer's
121
+ * render context instead of Pyreon's, breaking `provide()` and
122
+ * `onMount()` / `onUnmount()` calls.
123
+ *
124
+ * Framework components opt out of compat wrapping by setting a well-known
125
+ * registry symbol (`Symbol.for('pyreon:native-compat')`) on the function.
126
+ * The compat layer reads that symbol and routes marked components straight
127
+ * through Pyreon's `h()` mount path. The symbol is registry-shared, so no
128
+ * import direction between framework and compat is implied — both sides
129
+ * reference the same global symbol via the helpers exported here.
130
+ *
131
+ * Audience: framework-package authors writing JSX components in `@pyreon/*`
132
+ * packages whose setup body uses `provide()` / lifecycle hooks / signal
133
+ * subscriptions. Wrap exported components with `nativeCompat()`. One line
134
+ * per export site; zero runtime cost beyond a single property write at
135
+ * module load.
136
+ */
137
+ /**
138
+ * The well-known registry symbol that marks a component as a Pyreon native
139
+ * framework component. Compat layers check this symbol to decide whether to
140
+ * skip their `wrapCompatComponent` call.
141
+ *
142
+ * Exported for advanced cases where a caller needs to test the marker
143
+ * directly (most callers should use `isNativeCompat()`).
144
+ */
145
+ declare const NATIVE_COMPAT_MARKER: symbol;
146
+ /**
147
+ * Mark a Pyreon framework component as "self-managing" — compat layers will
148
+ * skip their React/Vue/Solid/Preact-style wrapping and route the component
149
+ * directly through Pyreon's mount path. Use on every `@pyreon/*` JSX
150
+ * component whose setup body uses `provide()`, lifecycle hooks
151
+ * (`onMount` / `onUnmount`), signal-driven reactivity, or any other Pyreon
152
+ * native pattern that depends on the active component-setup frame.
153
+ *
154
+ * Idempotent: re-applying the marker is a no-op. Non-function inputs pass
155
+ * through unchanged so callers don't have to typecheck before wrapping.
156
+ *
157
+ * @example
158
+ * import { nativeCompat, provide } from '@pyreon/core'
159
+ *
160
+ * export const RouterView = nativeCompat(function RouterView(props) {
161
+ * provide(RouterContext, ...)
162
+ * return <div data-pyreon-router-view>{children}</div>
163
+ * })
164
+ */
165
+ declare function nativeCompat<T>(fn: T): T;
166
+ /**
167
+ * Read whether a component has been marked as a Pyreon native framework
168
+ * component. Compat-layer code calls this from its `jsx()` to decide whether
169
+ * to wrap or pass through.
170
+ *
171
+ * @example
172
+ * import { isNativeCompat } from '@pyreon/core'
173
+ *
174
+ * if (isNativeCompat(type)) return h(type, props)
175
+ * return wrapCompatComponent(type)(props)
176
+ */
177
+ declare function isNativeCompat(fn: unknown): boolean;
178
+ //#endregion
78
179
  //#region src/context.d.ts
79
180
  /**
80
181
  * Provide / inject — like React context or Vue provide/inject.
@@ -155,7 +256,16 @@ type ContextSnapshot = Map<symbol, unknown>[];
155
256
  declare function captureContextStack(): ContextSnapshot;
156
257
  /**
157
258
  * Execute `fn()` with a previously captured context stack active.
158
- * Restores the original stack after `fn()` completes (even on throw).
259
+ *
260
+ * After `fn()` returns, removes ONLY the snapshot frames this call pushed
261
+ * — anything `fn()` itself pushed (typically provider frames from
262
+ * `provide()` calls during component mount) stays on the stack so
263
+ * subsequent reactive re-runs (e.g. `_bind` text bindings,
264
+ * `renderEffect` callbacks) can still find ancestor providers via
265
+ * `useContext`. Pre-fix this method was `stack.length = savedLength`,
266
+ * which destructively truncated provider frames pushed during mount —
267
+ * silently breaking `useMode()` / `useTheme()` / `useRouter()` etc. on
268
+ * every signal-driven update under a `mountReactive` boundary.
159
269
  */
160
270
  declare function restoreContextStack<T>(snapshot: ContextSnapshot, fn: () => T): T;
161
271
  //#endregion
@@ -205,7 +315,19 @@ declare function ErrorBoundary(props: {
205
315
  */
206
316
  declare const ForSymbol: unique symbol;
207
317
  interface ForProps<T> {
208
- each: () => T[];
318
+ /**
319
+ * The list to iterate. Accepts EITHER a function returning the array
320
+ * (preferred — keeps reactivity intact when the array comes from a
321
+ * signal accessor) OR the array directly (convenient for static lists
322
+ * or already-resolved arrays). The runtime in `runtime-dom/src/mount.ts`
323
+ * normalizes both shapes; this type matches the runtime so users aren't
324
+ * forced to write `each={() => items}` for a plain array.
325
+ *
326
+ * @example
327
+ * <For each={items}>{r => <li>{r.label}</li>}</For> // static
328
+ * <For each={() => store.items()}>{r => <li>...</li>}</For> // reactive
329
+ */
330
+ each: T[] | (() => T[]);
209
331
  /** Keying function — use `by` not `key` (JSX extracts `key` for VNode reconciliation). */
210
332
  by: (item: T) => string | number;
211
333
  children: (item: T) => VNode | NativeItem;
@@ -229,8 +351,22 @@ interface ForProps<T> {
229
351
  declare function For<T>(props: ForProps<T>): VNode;
230
352
  //#endregion
231
353
  //#region src/h.d.ts
232
- /** Marker for fragment nodes — renders children without a wrapper element */
233
- declare const Fragment: unique symbol;
354
+ /**
355
+ * Marker for fragment nodes — renders children without a wrapper element.
356
+ *
357
+ * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
358
+ * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
359
+ * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
360
+ * each bundle's evaluation of a bare `Symbol(...)` would produce a
361
+ * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
362
+ * resolves to jsx-runtime's identity; `runtime-server` checks
363
+ * `vnode.type === Fragment` against the main-entry identity. Mismatch
364
+ * fell through to `renderElement` and crashed SSG with
365
+ * `TypeError: Cannot convert a Symbol value to a string`.
366
+ * `Symbol.for()` keys by string in a global registry shared across all
367
+ * bundle evaluations — same identity everywhere.
368
+ */
369
+ declare const Fragment: symbol;
234
370
  /**
235
371
  * Hyperscript function — the compiled output of JSX.
236
372
  * `<div class="x">hello</div>` → `h("div", { class: "x" }, "hello")`
@@ -365,6 +501,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
365
501
  } | undefined;
366
502
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
367
503
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
504
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
368
505
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
369
506
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
370
507
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -428,7 +565,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
428
565
  defaultChecked?: boolean | undefined;
429
566
  placeholder?: string | (() => string) | undefined;
430
567
  disabled?: boolean | (() => boolean) | undefined;
431
- readOnly?: boolean | undefined;
568
+ readOnly?: boolean | (() => boolean) | undefined;
432
569
  required?: boolean | (() => boolean) | undefined;
433
570
  min?: string | number | undefined;
434
571
  max?: string | number | undefined;
@@ -477,7 +614,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
477
614
  defaultValue?: string | undefined;
478
615
  placeholder?: string | (() => string) | undefined;
479
616
  disabled?: boolean | (() => boolean) | undefined;
480
- readOnly?: boolean | undefined;
617
+ readOnly?: boolean | (() => boolean) | undefined;
481
618
  required?: boolean | (() => boolean) | undefined;
482
619
  rows?: number | undefined;
483
620
  cols?: number | undefined;
@@ -1031,8 +1168,15 @@ declare function createUniqueId(): string;
1031
1168
  //#endregion
1032
1169
  //#region src/show.d.ts
1033
1170
  interface ShowProps extends Props {
1034
- /** Accessor — children render when truthy, fallback when falsy. */
1035
- when: () => unknown;
1171
+ /**
1172
+ * Truthy condition. Accepts a value or an accessor.
1173
+ *
1174
+ * Use an accessor (`() => signal()`) for reactive conditions.
1175
+ * Bare values are accepted for static cases and as a defensive normalization
1176
+ * for cases where the compiler's signal auto-call has already invoked
1177
+ * a signal at the prop site (e.g. `when={mySignal}` becomes `when={mySignal()}`).
1178
+ */
1179
+ when: unknown | (() => unknown);
1036
1180
  fallback?: VNodeChild;
1037
1181
  children?: VNodeChild;
1038
1182
  }
@@ -1051,8 +1195,8 @@ interface ShowProps extends Props {
1051
1195
  */
1052
1196
  declare function Show(props: ShowProps): VNode | null;
1053
1197
  interface MatchProps extends Props {
1054
- /** Accessor this branch renders when truthy. */
1055
- when: () => unknown;
1198
+ /** Truthy condition. Accepts a value or an accessor — see {@link ShowProps.when}. */
1199
+ when: unknown | (() => unknown);
1056
1200
  children?: VNodeChild;
1057
1201
  }
1058
1202
  /**
@@ -1075,6 +1219,10 @@ declare const MatchSymbol: unique symbol;
1075
1219
  /**
1076
1220
  * Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
1077
1221
  *
1222
+ * Captures errors from ALL lifecycle phases including reactive effects.
1223
+ * `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
1224
+ * globalThis sink (no upward import — reactivity doesn't depend on core).
1225
+ *
1078
1226
  * @example
1079
1227
  * import { registerErrorHandler } from "@pyreon/core"
1080
1228
  * import * as Sentry from "@sentry/browser"
@@ -1086,7 +1234,7 @@ declare const MatchSymbol: unique symbol;
1086
1234
  * })
1087
1235
  */
1088
1236
  interface ErrorContext {
1089
- /** Component function name, or "Anonymous" */
1237
+ /** Component function name, "Anonymous", or "Effect" for reactive effects */
1090
1238
  component: string;
1091
1239
  /** Lifecycle phase where the error occurred */
1092
1240
  phase: 'setup' | 'render' | 'mount' | 'unmount' | 'effect';
@@ -1100,7 +1248,13 @@ interface ErrorContext {
1100
1248
  type ErrorHandler = (ctx: ErrorContext) => void;
1101
1249
  /**
1102
1250
  * Register a global error handler. Called whenever a component throws in any
1103
- * lifecycle phase. Returns an unregister function.
1251
+ * lifecycle phase, OR an effect throws in `@pyreon/reactivity`. Returns an
1252
+ * unregister function.
1253
+ *
1254
+ * Also installs a `globalThis.__pyreon_report_error__` bridge so the
1255
+ * reactivity package (which can't depend on core) can forward effect errors
1256
+ * into the same telemetry pipeline. Pre-fix the two surfaces were
1257
+ * disconnected — Sentry/Datadog wiring missed effect-thrown errors.
1104
1258
  */
1105
1259
  declare function registerErrorHandler(handler: ErrorHandler): () => void;
1106
1260
  /**
@@ -1109,5 +1263,5 @@ declare function registerErrorHandler(handler: ErrorHandler): () => void;
1109
1263
  */
1110
1264
  declare function reportError(ctx: ErrorContext): void;
1111
1265
  //#endregion
1112
- 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 VNodeChildAccessor, 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 };
1266
+ 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 };
1113
1267
  //# sourceMappingURL=index2.d.ts.map
@@ -18,8 +18,22 @@ type Props = Record<string, unknown>;
18
18
  type ComponentFn<P extends Props = Props> = (props: P) => VNodeChild;
19
19
  //#endregion
20
20
  //#region src/h.d.ts
21
- /** Marker for fragment nodes — renders children without a wrapper element */
22
- declare const Fragment: unique symbol;
21
+ /**
22
+ * Marker for fragment nodes — renders children without a wrapper element.
23
+ *
24
+ * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
25
+ * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
26
+ * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
27
+ * each bundle's evaluation of a bare `Symbol(...)` would produce a
28
+ * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
29
+ * resolves to jsx-runtime's identity; `runtime-server` checks
30
+ * `vnode.type === Fragment` against the main-entry identity. Mismatch
31
+ * fell through to `renderElement` and crashed SSG with
32
+ * `TypeError: Cannot convert a Symbol value to a string`.
33
+ * `Symbol.for()` keys by string in a global registry shared across all
34
+ * bundle evaluations — same identity everywhere.
35
+ */
36
+ declare const Fragment: symbol;
23
37
  //#endregion
24
38
  //#region src/ref.d.ts
25
39
  /**
@@ -135,6 +149,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
135
149
  } | undefined;
136
150
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
137
151
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
152
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
153
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
139
154
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
140
155
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -198,7 +213,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
198
213
  defaultChecked?: boolean | undefined;
199
214
  placeholder?: string | (() => string) | undefined;
200
215
  disabled?: boolean | (() => boolean) | undefined;
201
- readOnly?: boolean | undefined;
216
+ readOnly?: boolean | (() => boolean) | undefined;
202
217
  required?: boolean | (() => boolean) | undefined;
203
218
  min?: string | number | undefined;
204
219
  max?: string | number | undefined;
@@ -247,7 +262,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
247
262
  defaultValue?: string | undefined;
248
263
  placeholder?: string | (() => string) | undefined;
249
264
  disabled?: boolean | (() => boolean) | undefined;
250
- readOnly?: boolean | undefined;
265
+ readOnly?: boolean | (() => boolean) | undefined;
251
266
  required?: boolean | (() => boolean) | undefined;
252
267
  rows?: number | undefined;
253
268
  cols?: number | undefined;
@@ -18,8 +18,22 @@ type Props = Record<string, unknown>;
18
18
  type ComponentFn<P extends Props = Props> = (props: P) => VNodeChild;
19
19
  //#endregion
20
20
  //#region src/h.d.ts
21
- /** Marker for fragment nodes — renders children without a wrapper element */
22
- declare const Fragment: unique symbol;
21
+ /**
22
+ * Marker for fragment nodes — renders children without a wrapper element.
23
+ *
24
+ * MUST use `Symbol.for(...)` (global registry, keyed by string), NOT
25
+ * `Symbol(...)` (fresh per evaluation). `h.ts` is inlined into BOTH the
26
+ * main `lib/index.js` and the `lib/jsx-runtime.js` published bundles —
27
+ * each bundle's evaluation of a bare `Symbol(...)` would produce a
28
+ * DISTINCT Symbol identity. JSX `<>` compiles to `jsx(Fragment, ...)` and
29
+ * resolves to jsx-runtime's identity; `runtime-server` checks
30
+ * `vnode.type === Fragment` against the main-entry identity. Mismatch
31
+ * fell through to `renderElement` and crashed SSG with
32
+ * `TypeError: Cannot convert a Symbol value to a string`.
33
+ * `Symbol.for()` keys by string in a global registry shared across all
34
+ * bundle evaluations — same identity everywhere.
35
+ */
36
+ declare const Fragment: symbol;
23
37
  //#endregion
24
38
  //#region src/ref.d.ts
25
39
  /**
@@ -135,6 +149,7 @@ interface PyreonHTMLAttributes<E extends Element = HTMLElement> {
135
149
  } | undefined;
136
150
  onClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
137
151
  onDblClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
152
+ onDoubleClick?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
138
153
  onMouseDown?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
139
154
  onMouseUp?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
140
155
  onMouseEnter?: ((e: TargetedEvent<E, MouseEvent>) => void) | undefined;
@@ -198,7 +213,7 @@ interface InputAttributes extends PyreonHTMLAttributes<HTMLInputElement> {
198
213
  defaultChecked?: boolean | undefined;
199
214
  placeholder?: string | (() => string) | undefined;
200
215
  disabled?: boolean | (() => boolean) | undefined;
201
- readOnly?: boolean | undefined;
216
+ readOnly?: boolean | (() => boolean) | undefined;
202
217
  required?: boolean | (() => boolean) | undefined;
203
218
  min?: string | number | undefined;
204
219
  max?: string | number | undefined;
@@ -247,7 +262,7 @@ interface TextareaAttributes extends PyreonHTMLAttributes<HTMLTextAreaElement> {
247
262
  defaultValue?: string | undefined;
248
263
  placeholder?: string | (() => string) | undefined;
249
264
  disabled?: boolean | (() => boolean) | undefined;
250
- readOnly?: boolean | undefined;
265
+ readOnly?: boolean | (() => boolean) | undefined;
251
266
  required?: boolean | (() => boolean) | undefined;
252
267
  rows?: number | undefined;
253
268
  cols?: number | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyreon/core",
3
- "version": "0.14.0",
3
+ "version": "0.16.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.14.0"
56
+ "@pyreon/reactivity": "^0.16.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/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/for.ts CHANGED
@@ -7,7 +7,19 @@ import type { NativeItem, Props, VNode } from './types'
7
7
  export const ForSymbol: unique symbol = Symbol('pyreon.For')
8
8
 
9
9
  export interface ForProps<T> {
10
- each: () => T[]
10
+ /**
11
+ * The list to iterate. Accepts EITHER a function returning the array
12
+ * (preferred — keeps reactivity intact when the array comes from a
13
+ * signal accessor) OR the array directly (convenient for static lists
14
+ * or already-resolved arrays). The runtime in `runtime-dom/src/mount.ts`
15
+ * normalizes both shapes; this type matches the runtime so users aren't
16
+ * forced to write `each={() => items}` for a plain array.
17
+ *
18
+ * @example
19
+ * <For each={items}>{r => <li>{r.label}</li>}</For> // static
20
+ * <For each={() => store.items()}>{r => <li>...</li>}</For> // reactive
21
+ */
22
+ each: T[] | (() => T[])
11
23
  /** Keying function — use `by` not `key` (JSX extracts `key` for VNode reconciliation). */
12
24
  by: (item: T) => string | number
13
25
  children: (item: T) => VNode | NativeItem