@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
package/src/lifecycle.ts CHANGED
@@ -2,8 +2,7 @@ import type { CleanupFn, LifecycleHooks } from './types'
2
2
 
3
3
  // Dev-mode gate: see `pyreon/no-process-dev-gate` lint rule for why this
4
4
  // uses `import.meta.env.DEV` instead of `typeof process !== 'undefined'`.
5
- // @ts-ignore `import.meta.env.DEV` is provided by Vite/Rolldown at build time
6
- const __DEV__ = import.meta.env?.DEV === true
5
+ const __DEV__ = process.env.NODE_ENV !== 'production'
7
6
 
8
7
  // The currently-executing component's hook storage, set by the renderer
9
8
  // before calling the component function, cleared immediately after.
@@ -79,7 +78,10 @@ function warnOutsideSetup(hookName: string): void {
79
78
  */
80
79
  export function onMount(fn: () => CleanupFn | void | undefined) {
81
80
  warnOutsideSetup('onMount')
82
- _current?.mount.push(fn)
81
+ if (_current) {
82
+ if (_current.mount === null) _current.mount = []
83
+ _current.mount.push(fn)
84
+ }
83
85
  }
84
86
 
85
87
  /**
@@ -87,7 +89,10 @@ export function onMount(fn: () => CleanupFn | void | undefined) {
87
89
  */
88
90
  export function onUnmount(fn: () => void) {
89
91
  warnOutsideSetup('onUnmount')
90
- _current?.unmount.push(fn)
92
+ if (_current) {
93
+ if (_current.unmount === null) _current.unmount = []
94
+ _current.unmount.push(fn)
95
+ }
91
96
  }
92
97
 
93
98
  /**
@@ -95,7 +100,10 @@ export function onUnmount(fn: () => void) {
95
100
  */
96
101
  export function onUpdate(fn: () => void) {
97
102
  warnOutsideSetup('onUpdate')
98
- _current?.update.push(fn)
103
+ if (_current) {
104
+ if (_current.update === null) _current.update = []
105
+ _current.update.push(fn)
106
+ }
99
107
  }
100
108
 
101
109
  /**
@@ -113,5 +121,8 @@ export function onUpdate(fn: () => void) {
113
121
  */
114
122
  export function onErrorCaptured(fn: (err: unknown) => boolean | undefined) {
115
123
  warnOutsideSetup('onErrorCaptured')
116
- _current?.error.push(fn)
124
+ if (_current) {
125
+ if (_current.error === null) _current.error = []
126
+ _current.error.push(fn)
127
+ }
117
128
  }
package/src/manifest.ts CHANGED
@@ -249,14 +249,14 @@ const getMode = useContext(ModeCtx) // reactive: returns () => T`,
249
249
  kind: 'component',
250
250
  signature: '<Show when={condition} fallback={alternative}>{children}</Show>',
251
251
  summary:
252
- 'Reactive conditional rendering. Mounts children when `when` is truthy, unmounts and shows `fallback` when falsy. More efficient than ternary for signal-driven conditions because it avoids re-evaluating the entire branch expression on every signal change — `Show` only transitions between mounted/unmounted when the boolean flips.',
252
+ 'Reactive conditional rendering. Mounts children when `when` is truthy, unmounts and shows `fallback` when falsy. More efficient than ternary for signal-driven conditions because it avoids re-evaluating the entire branch expression on every signal change — `Show` only transitions between mounted/unmounted when the boolean flips. `when` accepts BOTH a value (`when={true}`, `when={signal()}`) and an accessor (`when={() => signal()}`) — the framework normalizes via `typeof === "function"`. The accessor form is required for true reactivity (the framework re-evaluates it on signal change); a bare `when={signal}` reference works because the compiler\'s signal auto-call rewrites it to `when={signal()}`.',
253
253
  example: `<Show when={isLoggedIn()} fallback={<LoginForm />}>
254
254
  <Dashboard />
255
255
  </Show>`,
256
256
  mistakes: [
257
257
  '`{cond() ? <A /> : <B />}` — works but less efficient than `<Show>` for signal-driven conditions',
258
258
  '`<Show when={items().length}>` — works (truthy check), but be explicit: `<Show when={items().length > 0}>`',
259
- '`<Show when={user}>` without calling the signal must call: `<Show when={user()}>`',
259
+ '`<Show when={signal}>` (bare reference) — relies on the compiler\'s signal auto-call to rewrite to `when={signal()}`. Works defensively but use `when={() => signal()}` for explicit accessor semantics across the entire reactive lifecycle.',
260
260
  ],
261
261
  seeAlso: ['Switch', 'Match', 'For'],
262
262
  },
@@ -284,7 +284,7 @@ const getMode = useContext(ModeCtx) // reactive: returns () => T`,
284
284
  kind: 'component',
285
285
  signature: '<Match when={condition}>{children}</Match>',
286
286
  summary:
287
- 'A branch inside a `<Switch>`. Renders its children when `when` is truthy and it is the first truthy `<Match>` in the parent `<Switch>`. Must be a direct child of `<Switch>`.',
287
+ 'A branch inside a `<Switch>`. Renders its children when `when` is truthy and it is the first truthy `<Match>` in the parent `<Switch>`. Must be a direct child of `<Switch>`. `when` accepts both a value and an accessor (same normalization as `<Show>`).',
288
288
  example: `<Switch>
289
289
  <Match when={tab() === "home"}><Home /></Match>
290
290
  <Match when={tab() === "settings"}><Settings /></Match>
@@ -481,6 +481,46 @@ return <input ref={inputRef} />`,
481
481
  })`,
482
482
  seeAlso: ['@pyreon/reactivity'],
483
483
  },
484
+ {
485
+ name: 'nativeCompat',
486
+ kind: 'function',
487
+ signature: '<T>(fn: T) => T',
488
+ summary:
489
+ 'Mark a Pyreon framework component as "self-managing" so compat layers (`@pyreon/{react,preact,vue,solid}-compat`) skip their wrapping and route the component through Pyreon\'s mount path. Use on every `@pyreon/*` JSX component whose setup body uses `provide()` / lifecycle hooks / signal subscriptions — wrapping breaks those by running the body inside the compat layer\'s render context instead of Pyreon\'s. Idempotent; non-function inputs pass through unchanged. The marker is a registry symbol (`Symbol.for("pyreon:native-compat")`), so framework and compat sides share it without an import dependency between them.',
490
+ example: `// In a framework package:
491
+ export const RouterView = nativeCompat(function RouterView(props) {
492
+ provide(RouterContext, ...)
493
+ return <div>{children}</div>
494
+ })`,
495
+ seeAlso: ['isNativeCompat', 'NATIVE_COMPAT_MARKER'],
496
+ mistakes: [
497
+ 'Forgetting to mark a new framework JSX export — under compat mode, the component\'s `provide()` / `onMount()` calls fail with "called outside component setup" warnings and the rendered DOM silently breaks.',
498
+ 'Marking user-app components — only `@pyreon/*` framework components that already manage their own reactivity should be marked. User components in compat mode are SUPPOSED to be wrapped (that\'s how they get re-render-on-state-change semantics).',
499
+ ],
500
+ },
501
+ {
502
+ name: 'isNativeCompat',
503
+ kind: 'function',
504
+ signature: '(fn: unknown) => boolean',
505
+ summary:
506
+ 'Compat-layer-side: read whether a function has been marked as a Pyreon native framework component via `nativeCompat()`. Compat `jsx()` calls this to decide whether to skip the React/Vue/Solid/Preact-style wrapping. Always returns `false` for non-function inputs.',
507
+ example: `// In a compat layer's jsx-runtime:
508
+ if (isNativeCompat(type)) return h(type, props)
509
+ return wrapCompatComponent(type)(props)`,
510
+ seeAlso: ['nativeCompat', 'NATIVE_COMPAT_MARKER'],
511
+ },
512
+ {
513
+ name: 'NATIVE_COMPAT_MARKER',
514
+ kind: 'constant',
515
+ signature: 'symbol',
516
+ summary:
517
+ 'The well-known registry symbol (`Symbol.for("pyreon:native-compat")`) used to mark a component as a Pyreon native framework component. Most callers should use `nativeCompat()` / `isNativeCompat()` instead of touching the symbol directly; exported for advanced cases (e.g., a compat layer that wants to inspect the property without going through the helper).',
518
+ example: `import { NATIVE_COMPAT_MARKER } from '@pyreon/core'
519
+
520
+ // Equivalent to nativeCompat(MyComponent):
521
+ ;(MyComponent as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER] = true`,
522
+ seeAlso: ['nativeCompat', 'isNativeCompat'],
523
+ },
484
524
  {
485
525
  name: 'ExtractProps',
486
526
  kind: 'type',
package/src/props.ts CHANGED
@@ -151,25 +151,38 @@ export function _rp<T>(fn: () => T): () => T {
151
151
  export function makeReactiveProps(
152
152
  raw: Record<string, unknown>,
153
153
  ): Record<string, unknown> {
154
- const result: Record<string, unknown> = {}
155
- let hasGetters = false
154
+ // Fast path: scan for any REACTIVE_PROP-branded function first.
155
+ // If none found, return raw immediately — no object allocation, no property copying.
156
+ // This saves ~90 object allocations + ~450 property copies per page load
157
+ // for components with all-static props (buttons, icons, layout, etc.).
158
+ const keys = Object.keys(raw)
159
+ let hasAny = false
160
+ for (let i = 0; i < keys.length; i++) {
161
+ const val = raw[keys[i]!]
162
+ if (typeof val === 'function' && (val as any)[REACTIVE_PROP]) {
163
+ hasAny = true
164
+ break
165
+ }
166
+ }
167
+ if (!hasAny) return raw
156
168
 
157
- for (const key of Object.keys(raw)) {
169
+ // At least one reactive prop exists — build the getter-backed object.
170
+ const result: Record<string, unknown> = {}
171
+ for (let i = 0; i < keys.length; i++) {
172
+ const key = keys[i]!
158
173
  const val = raw[key]
159
-
160
174
  if (typeof val === 'function' && (val as any)[REACTIVE_PROP]) {
161
175
  Object.defineProperty(result, key, {
162
176
  get: val as () => unknown,
163
177
  enumerable: true,
164
178
  configurable: true,
165
179
  })
166
- hasGetters = true
167
180
  } else {
168
181
  result[key] = val
169
182
  }
170
183
  }
171
184
 
172
- return hasGetters ? result : raw
185
+ return result
173
186
  }
174
187
 
175
188
  // ─── Unique ID ───────────────────────────────────────────────────────────────
package/src/show.ts CHANGED
@@ -3,12 +3,25 @@ import type { Props, VNode, VNodeChild, VNodeChildAtom } from './types'
3
3
  // ─── Show ─────────────────────────────────────────────────────────────────────
4
4
 
5
5
  export interface ShowProps extends Props {
6
- /** Accessor — children render when truthy, fallback when falsy. */
7
- when: () => unknown
6
+ /**
7
+ * Truthy condition. Accepts a value or an accessor.
8
+ *
9
+ * Use an accessor (`() => signal()`) for reactive conditions.
10
+ * Bare values are accepted for static cases and as a defensive normalization
11
+ * for cases where the compiler's signal auto-call has already invoked
12
+ * a signal at the prop site (e.g. `when={mySignal}` becomes `when={mySignal()}`).
13
+ */
14
+ when: unknown | (() => unknown)
8
15
  fallback?: VNodeChild
9
16
  children?: VNodeChild
10
17
  }
11
18
 
19
+ // Normalize a value-or-accessor `when` into a single accessor.
20
+ // Same shape used by Match — kept inline (one branch) to stay zero-cost.
21
+ function callWhen(when: unknown): unknown {
22
+ return typeof when === 'function' ? (when as () => unknown)() : when
23
+ }
24
+
12
25
  /**
13
26
  * Conditionally render children based on a reactive condition.
14
27
  *
@@ -25,7 +38,7 @@ export interface ShowProps extends Props {
25
38
  export function Show(props: ShowProps): VNode | null {
26
39
  // Returns a reactive accessor; the renderer unwraps it at mount time.
27
40
  return ((): VNodeChildAtom =>
28
- (props.when()
41
+ (callWhen(props.when)
29
42
  ? (props.children ?? null)
30
43
  : (props.fallback ?? null)) as VNodeChildAtom) as unknown as VNode
31
44
  }
@@ -33,8 +46,8 @@ export function Show(props: ShowProps): VNode | null {
33
46
  // ─── Switch / Match ───────────────────────────────────────────────────────────
34
47
 
35
48
  export interface MatchProps extends Props {
36
- /** Accessor this branch renders when truthy. */
37
- when: () => unknown
49
+ /** Truthy condition. Accepts a value or an accessor — see {@link ShowProps.when}. */
50
+ when: unknown | (() => unknown)
38
51
  children?: VNodeChild
39
52
  }
40
53
 
@@ -97,7 +110,7 @@ export function Switch(props: SwitchProps): VNode | null {
97
110
  for (const branch of branches) {
98
111
  if (!isMatchVNode(branch)) continue
99
112
  const matchProps = branch.props as unknown as MatchProps
100
- if (matchProps.when()) return resolveMatchChildren(branch)
113
+ if (callWhen(matchProps.when)) return resolveMatchChildren(branch)
101
114
  }
102
115
 
103
116
  return (props.fallback ?? null) as VNodeChildAtom
package/src/suspense.ts CHANGED
@@ -3,8 +3,7 @@ import type { 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
  /** Internal marker attached to lazy()-wrapped components */
10
9
  export type LazyComponent<P extends Props = Props> = ((props: P) => VNodeChild) & {
package/src/telemetry.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Error telemetry — hook into Pyreon's error reporting for Sentry, Datadog, etc.
3
3
  *
4
+ * Captures errors from ALL lifecycle phases including reactive effects.
5
+ * `effect()` errors thrown by `@pyreon/reactivity` are bridged through a
6
+ * globalThis sink (no upward import — reactivity doesn't depend on core).
7
+ *
4
8
  * @example
5
9
  * import { registerErrorHandler } from "@pyreon/core"
6
10
  * import * as Sentry from "@sentry/browser"
@@ -13,7 +17,7 @@
13
17
  */
14
18
 
15
19
  export interface ErrorContext {
16
- /** Component function name, or "Anonymous" */
20
+ /** Component function name, "Anonymous", or "Effect" for reactive effects */
17
21
  component: string
18
22
  /** Lifecycle phase where the error occurred */
19
23
  phase: 'setup' | 'render' | 'mount' | 'unmount' | 'effect'
@@ -31,10 +35,17 @@ let _handlers: ErrorHandler[] = []
31
35
 
32
36
  /**
33
37
  * Register a global error handler. Called whenever a component throws in any
34
- * lifecycle phase. Returns an unregister function.
38
+ * lifecycle phase, OR an effect throws in `@pyreon/reactivity`. Returns an
39
+ * unregister function.
40
+ *
41
+ * Also installs a `globalThis.__pyreon_report_error__` bridge so the
42
+ * reactivity package (which can't depend on core) can forward effect errors
43
+ * into the same telemetry pipeline. Pre-fix the two surfaces were
44
+ * disconnected — Sentry/Datadog wiring missed effect-thrown errors.
35
45
  */
36
46
  export function registerErrorHandler(handler: ErrorHandler): () => void {
37
47
  _handlers.push(handler)
48
+ _installReactivityBridge()
38
49
  return () => {
39
50
  _handlers = _handlers.filter((h) => h !== handler)
40
51
  }
@@ -53,3 +64,20 @@ export function reportError(ctx: ErrorContext): void {
53
64
  }
54
65
  }
55
66
  }
67
+
68
+ // ─── Reactivity bridge ──────────────────────────────────────────────────────
69
+ // Installs `globalThis.__pyreon_report_error__` so `@pyreon/reactivity`
70
+ // effect-error path can forward into reportError. Idempotent — multiple
71
+ // `registerErrorHandler` calls install once.
72
+
73
+ interface PyreonErrorBridge {
74
+ __pyreon_report_error__?: (err: unknown, phase: 'effect') => void
75
+ }
76
+ const _bridgeHost = globalThis as PyreonErrorBridge
77
+
78
+ function _installReactivityBridge(): void {
79
+ if (_bridgeHost.__pyreon_report_error__) return
80
+ _bridgeHost.__pyreon_report_error__ = (err, phase) => {
81
+ reportError({ component: 'Effect', phase, error: err, timestamp: Date.now() })
82
+ }
83
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { isNativeCompat, NATIVE_COMPAT_MARKER, nativeCompat } from '../compat-marker'
3
+
4
+ describe('NATIVE_COMPAT_MARKER', () => {
5
+ it('is the same registry symbol regardless of how it is referenced', () => {
6
+ // Symbol.for(...) registry contract — every consumer that uses the same
7
+ // string key (compat layers reading it, framework packages writing it)
8
+ // gets the SAME symbol identity. Changing the string is a breaking
9
+ // change to the marker contract.
10
+ expect(NATIVE_COMPAT_MARKER).toBe(Symbol.for('pyreon:native-compat'))
11
+ })
12
+
13
+ it('is a `symbol`-typed value', () => {
14
+ expect(typeof NATIVE_COMPAT_MARKER).toBe('symbol')
15
+ })
16
+ })
17
+
18
+ describe('nativeCompat', () => {
19
+ it('attaches the marker to a function and returns the same reference', () => {
20
+ function RouterView() {
21
+ return null
22
+ }
23
+ const marked = nativeCompat(RouterView)
24
+ expect(marked).toBe(RouterView)
25
+ expect((RouterView as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBe(true)
26
+ })
27
+
28
+ it('is idempotent — applying twice yields the same property state', () => {
29
+ const Component = () => null
30
+ nativeCompat(Component)
31
+ nativeCompat(Component)
32
+ expect((Component as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBe(true)
33
+ })
34
+
35
+ it('passes non-function values through unchanged', () => {
36
+ // Defensive: callers may pipe variables of unknown shape (e.g. lazy
37
+ // imports that resolve to objects, or null during HMR boundary
38
+ // teardown). The helper must be safe regardless.
39
+ expect(nativeCompat(null as unknown)).toBe(null)
40
+ expect(nativeCompat(undefined as unknown)).toBe(undefined)
41
+ const obj = { foo: 'bar' }
42
+ expect(nativeCompat(obj)).toBe(obj)
43
+ expect((obj as unknown as Record<symbol, boolean>)[NATIVE_COMPAT_MARKER]).toBeUndefined()
44
+ })
45
+
46
+ it('preserves the function signature for typed callers', () => {
47
+ // The generic `T` flows through unchanged so framework component
48
+ // exports keep their typed callable shape after wrapping.
49
+ const Typed = (props: { name: string }): string => `hello ${props.name}`
50
+ const marked: typeof Typed = nativeCompat(Typed)
51
+ expect(marked({ name: 'world' })).toBe('hello world')
52
+ })
53
+ })
54
+
55
+ describe('isNativeCompat', () => {
56
+ it('returns true for a marked function', () => {
57
+ const Comp = nativeCompat(() => null)
58
+ expect(isNativeCompat(Comp)).toBe(true)
59
+ })
60
+
61
+ it('returns false for an unmarked function', () => {
62
+ expect(isNativeCompat(() => null)).toBe(false)
63
+ })
64
+
65
+ it('returns false for non-function inputs', () => {
66
+ expect(isNativeCompat(null)).toBe(false)
67
+ expect(isNativeCompat(undefined)).toBe(false)
68
+ expect(isNativeCompat('string')).toBe(false)
69
+ expect(isNativeCompat(42)).toBe(false)
70
+ expect(isNativeCompat({ [NATIVE_COMPAT_MARKER]: true })).toBe(false)
71
+ })
72
+
73
+ it('returns false when the marker is set to a non-true value', () => {
74
+ // Defensive against accidental shape mismatch — only `=== true` qualifies.
75
+ function Comp() {
76
+ return null
77
+ }
78
+ ;(Comp as unknown as Record<symbol, unknown>)[NATIVE_COMPAT_MARKER] = 1
79
+ expect(isNativeCompat(Comp)).toBe(false)
80
+ ;(Comp as unknown as Record<symbol, unknown>)[NATIVE_COMPAT_MARKER] = 'yes'
81
+ expect(isNativeCompat(Comp)).toBe(false)
82
+ })
83
+
84
+ it('reads the same registry symbol that nativeCompat writes', () => {
85
+ // Cross-side contract: a function marked here is detectable by
86
+ // someone who looked up the symbol via `Symbol.for('pyreon:native-compat')`
87
+ // independently — without importing NATIVE_COMPAT_MARKER from this module.
88
+ const Comp = nativeCompat(function Comp() {
89
+ return null
90
+ })
91
+ const externallyDiscoveredSymbol = Symbol.for('pyreon:native-compat')
92
+ expect(
93
+ (Comp as unknown as Record<symbol, boolean>)[externallyDiscoveredSymbol],
94
+ ).toBe(true)
95
+ })
96
+ })
@@ -105,12 +105,12 @@ describe('runWithHooks', () => {
105
105
  expect(hooks.unmount).toHaveLength(2)
106
106
  })
107
107
 
108
- test('empty hooks when component registers none', () => {
108
+ test('null hooks when component registers none (lazy allocation)', () => {
109
109
  const { hooks } = runWithHooks(() => h('div', null), {})
110
- expect(hooks.mount).toHaveLength(0)
111
- expect(hooks.unmount).toHaveLength(0)
112
- expect(hooks.update).toHaveLength(0)
113
- expect(hooks.error).toHaveLength(0)
110
+ expect(hooks.mount).toBeNull()
111
+ expect(hooks.unmount).toBeNull()
112
+ expect(hooks.update).toBeNull()
113
+ expect(hooks.error).toBeNull()
114
114
  })
115
115
  })
116
116
 
@@ -196,9 +196,9 @@ describe('provide', () => {
196
196
  // Context should still be available after runWithHooks
197
197
  expect(useContext(ctx)).toBe('provided-value')
198
198
  // unmount hooks should include the popContext cleanup
199
- expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
199
+ expect(hooks.unmount!.length).toBeGreaterThanOrEqual(1)
200
200
  // Running unmount cleans up
201
- for (const fn of hooks.unmount) fn()
201
+ for (const fn of hooks.unmount!) fn()
202
202
  expect(useContext(ctx)).toBe('default')
203
203
  })
204
204
 
@@ -216,7 +216,7 @@ describe('provide', () => {
216
216
  expect(useContext(ctxA)).toBe('A-value')
217
217
  expect(useContext(ctxB)).toBe('B-value')
218
218
  // Clean up
219
- for (const fn of hooks.unmount) fn()
219
+ for (const fn of hooks.unmount!) fn()
220
220
  expect(useContext(ctxA)).toBe('a')
221
221
  expect(useContext(ctxB)).toBe('b')
222
222
  })
@@ -172,14 +172,14 @@ describe('runWithHooks()', () => {
172
172
 
173
173
  const { vnode, hooks } = runWithHooks(Comp, {})
174
174
  expect(vnode).not.toBeNull()
175
- expect(hooks.mount).toHaveLength(1)
176
- expect(hooks.mount[0]).toBe(mountFn)
177
- expect(hooks.unmount).toHaveLength(1)
178
- expect(hooks.unmount[0]).toBe(unmountFn)
179
- expect(hooks.update).toHaveLength(1)
180
- expect(hooks.update[0]).toBe(updateFn)
181
- expect(hooks.error).toHaveLength(1)
182
- expect(hooks.error[0]).toBe(errorFn)
175
+ expect(hooks.mount!).toHaveLength(1)
176
+ expect(hooks.mount![0]).toBe(mountFn)
177
+ expect(hooks.unmount!).toHaveLength(1)
178
+ expect(hooks.unmount![0]).toBe(unmountFn)
179
+ expect(hooks.update!).toHaveLength(1)
180
+ expect(hooks.update![0]).toBe(updateFn)
181
+ expect(hooks.error!).toHaveLength(1)
182
+ expect(hooks.error![0]).toBe(errorFn)
183
183
  })
184
184
 
185
185
  test('returns null vnode when component returns null', () => {
@@ -208,7 +208,7 @@ describe('runWithHooks()', () => {
208
208
  }
209
209
 
210
210
  const { hooks } = runWithHooks(Comp, {})
211
- expect(hooks.mount).toHaveLength(3)
211
+ expect(hooks.mount!).toHaveLength(3)
212
212
  })
213
213
 
214
214
  test('passes props to component function', () => {
@@ -350,7 +350,7 @@ describe('createContext / useContext', () => {
350
350
  // Context is available after provide()
351
351
  expect(useContext(ctx)).toBe('provided')
352
352
  // Running unmount hooks should pop the context
353
- for (const fn of hooks.unmount) fn()
353
+ for (const fn of hooks.unmount!) fn()
354
354
  expect(useContext(ctx)).toBe('default')
355
355
  })
356
356
  })
@@ -663,7 +663,7 @@ describe('ErrorBoundary', () => {
663
663
  }, {})
664
664
  expect(vnode).not.toBeNull()
665
665
  // Should have registered onUnmount for cleanup
666
- expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
666
+ expect(hooks.unmount!.length).toBeGreaterThanOrEqual(1)
667
667
  })
668
668
 
669
669
  test('renders children when no error', () => {
@@ -1,6 +1,6 @@
1
1
  import { Dynamic } from '../dynamic'
2
2
  import { h } from '../h'
3
- import type { ComponentFn, VNode } from '../types'
3
+ import type { ComponentFn, VNode, VNodeChild } from '../types'
4
4
 
5
5
  describe('Dynamic', () => {
6
6
  test('renders component function', () => {
@@ -52,4 +52,36 @@ describe('Dynamic', () => {
52
52
  expect(result).not.toBeNull()
53
53
  expect((result as VNode).type).toBe('br')
54
54
  })
55
+
56
+ test('does not leak children as a prop on string-tag mount', () => {
57
+ // Regression: for string `component`, runtime-dom forwards every prop
58
+ // key to setAttribute. If `children` stayed in props it crashed at
59
+ // mount with `setAttribute('children', ...)`. The fix re-emits them
60
+ // as h() rest args, landing them in vnode.children.
61
+ const result = Dynamic({ component: 'h3', children: 'hello' })
62
+ expect((result as VNode).type).toBe('h3')
63
+ expect((result as VNode).props.children).toBeUndefined()
64
+ expect((result as VNode).children).toEqual(['hello'])
65
+ })
66
+
67
+ test('flattens array children to vnode.children', () => {
68
+ const a = h('span', null, 'a')
69
+ const b = h('span', null, 'b')
70
+ const result = Dynamic({ component: 'div', children: [a, b] })
71
+ expect((result as VNode).props.children).toBeUndefined()
72
+ expect((result as VNode).children).toHaveLength(2)
73
+ })
74
+
75
+ test('component children still reach props.children at mount', () => {
76
+ // For component (not string), the merge happens at mount via
77
+ // mergeChildrenIntoProps — verified end-to-end by mount tests in
78
+ // runtime-dom. Here we just confirm the vnode shape is correct so the
79
+ // merge will fire (children must be on vnode.children, not props).
80
+ const Comp: ComponentFn = (props) =>
81
+ h('div', null, (props as { children?: VNodeChild }).children ?? null)
82
+ const result = Dynamic({ component: Comp, children: 'hi' })
83
+ expect((result as VNode).type).toBe(Comp)
84
+ expect((result as VNode).props.children).toBeUndefined()
85
+ expect((result as VNode).children).toEqual(['hi'])
86
+ })
55
87
  })
@@ -76,7 +76,7 @@ describe('ErrorBoundary', () => {
76
76
  })
77
77
  return null
78
78
  }, {})
79
- expect(hooks.unmount.length).toBeGreaterThanOrEqual(1)
79
+ expect(hooks.unmount!.length).toBeGreaterThanOrEqual(1)
80
80
  })
81
81
 
82
82
  test('warns when fallback is not a function', () => {
@@ -42,8 +42,8 @@ describe('onMount', () => {
42
42
  setCurrentHooks(hooks)
43
43
  const fn = () => undefined
44
44
  onMount(fn)
45
- expect(hooks.mount).toHaveLength(1)
46
- expect(hooks.mount[0]).toBe(fn)
45
+ expect(hooks.mount!).toHaveLength(1)
46
+ expect(hooks.mount![0]).toBe(fn)
47
47
  })
48
48
 
49
49
  test('multiple onMount calls accumulate', () => {
@@ -52,7 +52,7 @@ describe('onMount', () => {
52
52
  onMount(() => undefined)
53
53
  onMount(() => undefined)
54
54
  onMount(() => undefined)
55
- expect(hooks.mount).toHaveLength(3)
55
+ expect(hooks.mount!).toHaveLength(3)
56
56
  })
57
57
 
58
58
  test('warns when called outside component setup', () => {
@@ -73,16 +73,16 @@ describe('onMount', () => {
73
73
  setCurrentHooks(hooks)
74
74
  const cleanup = () => {}
75
75
  onMount(() => cleanup)
76
- expect(hooks.mount).toHaveLength(1)
77
- expect(hooks.mount[0]!()).toBe(cleanup)
76
+ expect(hooks.mount!).toHaveLength(1)
77
+ expect(hooks.mount![0]!()).toBe(cleanup)
78
78
  })
79
79
 
80
80
  test('accepts callback returning void', () => {
81
81
  const hooks: LifecycleHooks = { mount: [], unmount: [], update: [], error: [] }
82
82
  setCurrentHooks(hooks)
83
83
  onMount(() => {})
84
- expect(hooks.mount).toHaveLength(1)
85
- expect(hooks.mount[0]!()).toBeUndefined()
84
+ expect(hooks.mount!).toHaveLength(1)
85
+ expect(hooks.mount![0]!()).toBeUndefined()
86
86
  })
87
87
  })
88
88
 
@@ -96,8 +96,8 @@ describe('onUnmount', () => {
96
96
  setCurrentHooks(hooks)
97
97
  const fn = () => {}
98
98
  onUnmount(fn)
99
- expect(hooks.unmount).toHaveLength(1)
100
- expect(hooks.unmount[0]).toBe(fn)
99
+ expect(hooks.unmount!).toHaveLength(1)
100
+ expect(hooks.unmount![0]).toBe(fn)
101
101
  })
102
102
 
103
103
  test('warns when called outside component setup', () => {
@@ -120,8 +120,8 @@ describe('onUpdate', () => {
120
120
  setCurrentHooks(hooks)
121
121
  const fn = () => {}
122
122
  onUpdate(fn)
123
- expect(hooks.update).toHaveLength(1)
124
- expect(hooks.update[0]).toBe(fn)
123
+ expect(hooks.update!).toHaveLength(1)
124
+ expect(hooks.update![0]).toBe(fn)
125
125
  })
126
126
 
127
127
  test('warns when called outside component setup', () => {
@@ -144,8 +144,8 @@ describe('onErrorCaptured', () => {
144
144
  setCurrentHooks(hooks)
145
145
  const fn = () => true
146
146
  onErrorCaptured(fn)
147
- expect(hooks.error).toHaveLength(1)
148
- expect(hooks.error[0]).toBe(fn)
147
+ expect(hooks.error!).toHaveLength(1)
148
+ expect(hooks.error![0]).toBe(fn)
149
149
  })
150
150
 
151
151
  test('warns when called outside component setup', () => {
@@ -167,7 +167,7 @@ describe('onErrorCaptured', () => {
167
167
  })
168
168
  // Simulate calling the handler
169
169
  const testError = new Error('test')
170
- hooks.error[0]!(testError)
170
+ hooks.error![0]!(testError)
171
171
  expect(captured).toBe(testError)
172
172
  })
173
173
  })
@@ -186,10 +186,10 @@ describe('lifecycle hooks interaction', () => {
186
186
  onUpdate(() => {})
187
187
  onErrorCaptured(() => true)
188
188
 
189
- expect(hooks.mount).toHaveLength(1)
190
- expect(hooks.unmount).toHaveLength(1)
191
- expect(hooks.update).toHaveLength(1)
192
- expect(hooks.error).toHaveLength(1)
189
+ expect(hooks.mount!).toHaveLength(1)
190
+ expect(hooks.unmount!).toHaveLength(1)
191
+ expect(hooks.update!).toHaveLength(1)
192
+ expect(hooks.error!).toHaveLength(1)
193
193
  })
194
194
 
195
195
  test('hooks from different setCurrentHooks calls go to different stores', () => {
@@ -85,8 +85,13 @@ describe('gen-docs — core snapshot', () => {
85
85
 
86
86
  it('renders @pyreon/core to MCP api-reference entries — one per api[] item', () => {
87
87
  const record = renderApiReferenceEntries(coreManifest)
88
- expect(Object.keys(record).length).toBe(28)
88
+ expect(Object.keys(record).length).toBe(31)
89
89
  expect(Object.keys(record)).toContain('core/h')
90
+ // Compat-mode native marker — added so framework JSX components opt out
91
+ // of `@pyreon/{react,preact,vue,solid}-compat` wrapping.
92
+ expect(Object.keys(record)).toContain('core/nativeCompat')
93
+ expect(Object.keys(record)).toContain('core/isNativeCompat')
94
+ expect(Object.keys(record)).toContain('core/NATIVE_COMPAT_MARKER')
90
95
  // Spot-check the flagship API — h() is the hyperscript function
91
96
  const h = record['core/h']!
92
97
  expect(h.notes).toContain('JSX')