@pyreon/react-compat 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,274 +1,295 @@
1
1
  /**
2
2
  * @pyreon/react-compat
3
3
  *
4
- * React-compatible hook API that runs on Pyreon's reactive engine.
4
+ * Fully React-compatible hook API powered by Pyreon's reactive engine.
5
5
  *
6
- * Allows you to write familiar React-style code while getting Pyreon's
7
- * fine-grained reactivity, built-in router/store, and superior performance.
8
- *
9
- * DIFFERENCES FROM REACT:
10
- * - No hooks rules: call these anywhere in a component, in loops, conditions, etc.
11
- * - useEffect deps array is IGNORED — Pyreon tracks dependencies automatically.
12
- * - useCallback/memo are identity functions — no re-renders means no stale closures.
13
- * - Components run ONCE (setup), not on every render.
6
+ * Components re-render on state change — just like React. Hooks return plain
7
+ * values and use deps arrays for memoization. Existing React code works
8
+ * unchanged when paired with `pyreon({ compat: "react" })` in your vite config.
14
9
  *
15
10
  * USAGE:
16
- * Replace `import { useState, useEffect } from "react"` with
17
- * `import { useState, useEffect } from "@pyreon/react-compat"`
18
- * Replace `import { createRoot } from "react-dom/client"` with
19
- * `import { createRoot } from "@pyreon/react-compat/dom"`
11
+ * import { useState, useEffect } from "react" // aliased by vite plugin
12
+ * import { createRoot } from "react-dom/client" // aliased by vite plugin
20
13
  */
21
14
 
22
15
  export type { Props, VNode as ReactNode, VNodeChild } from "@pyreon/core"
23
- // Re-export Pyreon's JSX runtime so JSX transforms work the same way
24
- // Lifecycle
25
- export { Fragment, h as createElement, h, onMount as useLayoutEffect } from "@pyreon/core"
26
-
27
- import type { CleanupFn, VNodeChild } from "@pyreon/core"
28
- import {
29
- createContext,
30
- createRef,
31
- ErrorBoundary,
32
- onErrorCaptured,
33
- onMount,
34
- onUnmount,
35
- onUpdate,
36
- Portal,
37
- Suspense,
38
- useContext,
39
- } from "@pyreon/core"
40
- import {
41
- batch,
42
- computed,
43
- createSelector,
44
- effect,
45
- getCurrentScope,
46
- runUntracked,
47
- signal,
48
- } from "@pyreon/reactivity"
49
-
50
- // ─── State ────────────────────────────────────────────────────────────────────
16
+ export { Fragment, h as createElement, h } from "@pyreon/core"
17
+
18
+ import type { VNodeChild } from "@pyreon/core"
19
+ import { createContext, ErrorBoundary, Portal, Suspense, useContext } from "@pyreon/core"
20
+ import { batch } from "@pyreon/reactivity"
21
+ import type { EffectEntry } from "./jsx-runtime"
22
+ import { getCurrentCtx, getHookIndex } from "./jsx-runtime"
23
+
24
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
25
+
26
+ function requireCtx() {
27
+ const ctx = getCurrentCtx()
28
+ if (!ctx) throw new Error("Hook called outside of a component render")
29
+ return ctx
30
+ }
31
+
32
+ function depsChanged(a: unknown[] | undefined, b: unknown[] | undefined): boolean {
33
+ if (a === undefined || b === undefined) return true
34
+ if (a.length !== b.length) return true
35
+ for (let i = 0; i < a.length; i++) {
36
+ if (!Object.is(a[i], b[i])) return true
37
+ }
38
+ return false
39
+ }
40
+
41
+ // ─── State ───────────────────────────────────────────────────────────────────
51
42
 
52
43
  /**
53
- * Drop-in for React's `useState`.
54
- * Returns `[getter, setter]` call `getter()` to read, `setter(v)` to write.
55
- *
56
- * Unlike React: the getter is a signal, so any component or effect that reads
57
- * it will re-run automatically. No dep arrays needed.
44
+ * React-compatible `useState` returns `[value, setter]`.
45
+ * Triggers a component re-render when the setter is called.
58
46
  */
59
- export function useState<T>(initial: T | (() => T)): [() => T, (v: T | ((prev: T) => T)) => void] {
60
- const s = signal<T>(typeof initial === "function" ? (initial as () => T)() : initial)
47
+ export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T)) => void] {
48
+ const ctx = requireCtx()
49
+ const idx = getHookIndex()
50
+
51
+ if (ctx.hooks.length <= idx) {
52
+ ctx.hooks.push(typeof initial === "function" ? (initial as () => T)() : initial)
53
+ }
54
+
55
+ const value = ctx.hooks[idx] as T
61
56
  const setter = (v: T | ((prev: T) => T)) => {
62
- if (typeof v === "function") s.update(v as (prev: T) => T)
63
- else s.set(v)
57
+ const current = ctx.hooks[idx] as T
58
+ const next = typeof v === "function" ? (v as (prev: T) => T)(current) : v
59
+ if (Object.is(current, next)) return
60
+ ctx.hooks[idx] = next
61
+ ctx.scheduleRerender()
64
62
  }
65
- return [s, setter]
63
+
64
+ return [value, setter]
66
65
  }
67
66
 
68
67
  // ─── Reducer ─────────────────────────────────────────────────────────────────
69
68
 
70
69
  /**
71
- * Drop-in for React's `useReducer`.
70
+ * React-compatible `useReducer` returns `[state, dispatch]`.
72
71
  */
73
72
  export function useReducer<S, A>(
74
73
  reducer: (state: S, action: A) => S,
75
74
  initial: S | (() => S),
76
- ): [() => S, (action: A) => void] {
77
- const s = signal<S>(typeof initial === "function" ? (initial as () => S)() : initial)
78
- const dispatch = (action: A) => s.update((prev) => reducer(prev, action))
79
- return [s, dispatch]
75
+ ): [S, (action: A) => void] {
76
+ const ctx = requireCtx()
77
+ const idx = getHookIndex()
78
+
79
+ if (ctx.hooks.length <= idx) {
80
+ ctx.hooks.push(typeof initial === "function" ? (initial as () => S)() : initial)
81
+ }
82
+
83
+ const state = ctx.hooks[idx] as S
84
+ const dispatch = (action: A) => {
85
+ const current = ctx.hooks[idx] as S
86
+ const next = reducer(current, action)
87
+ if (Object.is(current, next)) return
88
+ ctx.hooks[idx] = next
89
+ ctx.scheduleRerender()
90
+ }
91
+
92
+ return [state, dispatch]
80
93
  }
81
94
 
82
95
  // ─── Effects ─────────────────────────────────────────────────────────────────
83
96
 
84
97
  /**
85
- * Drop-in for React's `useEffect`.
86
- *
87
- * The `deps` array is IGNORED — Pyreon tracks reactive dependencies automatically.
88
- * If `deps` is `[]` (mount-only), wrap the body in `runUntracked(() => ...)`.
89
- *
90
- * Returns a cleanup the same way React does (return a function from `fn`).
98
+ * React-compatible `useEffect` runs after render when deps change.
99
+ * Returns cleanup on unmount and before re-running.
91
100
  */
92
- // biome-ignore lint/suspicious/noConfusingVoidType: void is intentional — callers may return void
93
- export function useEffect(fn: () => CleanupFn | void, deps?: unknown[]): void {
94
- if (deps !== undefined && deps.length === 0) {
95
- // [] means "run once on mount" — use onMount instead of a tracking effect
96
- onMount((): undefined => {
97
- const cleanup = runUntracked(fn)
98
- if (typeof cleanup === "function") onUnmount(cleanup)
99
- })
101
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches React's useEffect signature
102
+ export function useEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
103
+ const ctx = requireCtx()
104
+ const idx = getHookIndex()
105
+
106
+ if (ctx.hooks.length <= idx) {
107
+ // First render always run
108
+ const entry: EffectEntry = { fn, deps, cleanup: undefined }
109
+ ctx.hooks.push(entry)
110
+ ctx.pendingEffects.push(entry)
100
111
  } else {
101
- // No deps or non-empty deps: run reactively (Pyreon auto-tracks).
102
- // effect() natively supports cleanup: if fn() returns a function,
103
- // it's called before re-runs and on dispose.
104
- const e = effect(fn)
105
- onUnmount(() => {
106
- e.dispose()
107
- })
112
+ const entry = ctx.hooks[idx] as EffectEntry
113
+ if (depsChanged(entry.deps, deps)) {
114
+ entry.fn = fn
115
+ entry.deps = deps
116
+ ctx.pendingEffects.push(entry)
117
+ }
108
118
  }
109
119
  }
110
120
 
111
121
  /**
112
- * Drop-in for React's `useLayoutEffect`.
113
- * In Pyreon there is no paint distinction — maps to `onMount` (same as useEffect).
122
+ * React-compatible `useLayoutEffect` runs synchronously after DOM mutations.
114
123
  */
115
- export { useEffect as useLayoutEffect_ }
124
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches React's useLayoutEffect signature
125
+ export function useLayoutEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
126
+ const ctx = requireCtx()
127
+ const idx = getHookIndex()
128
+
129
+ if (ctx.hooks.length <= idx) {
130
+ const entry: EffectEntry = { fn, deps, cleanup: undefined }
131
+ ctx.hooks.push(entry)
132
+ ctx.pendingLayoutEffects.push(entry)
133
+ } else {
134
+ const entry = ctx.hooks[idx] as EffectEntry
135
+ if (depsChanged(entry.deps, deps)) {
136
+ entry.fn = fn
137
+ entry.deps = deps
138
+ ctx.pendingLayoutEffects.push(entry)
139
+ }
140
+ }
141
+ }
116
142
 
117
143
  // ─── Memoization ─────────────────────────────────────────────────────────────
118
144
 
119
145
  /**
120
- * Drop-in for React's `useMemo`.
121
- * The `deps` array is IGNORED — Pyreon's `computed` tracks dependencies automatically.
122
- * Returns a getter: call `value()` to read the memoized result.
146
+ * React-compatible `useMemo` returns the cached value, recomputed when deps change.
123
147
  */
124
- export function useMemo<T>(fn: () => T, _deps?: unknown[]): () => T {
125
- return computed(fn)
148
+ export function useMemo<T>(fn: () => T, deps: unknown[]): T {
149
+ const ctx = requireCtx()
150
+ const idx = getHookIndex()
151
+
152
+ if (ctx.hooks.length <= idx) {
153
+ const value = fn()
154
+ ctx.hooks.push({ value, deps })
155
+ return value
156
+ }
157
+
158
+ const entry = ctx.hooks[idx] as { value: T; deps: unknown[] }
159
+ if (depsChanged(entry.deps, deps)) {
160
+ entry.value = fn()
161
+ entry.deps = deps
162
+ }
163
+ return entry.value
126
164
  }
127
165
 
128
166
  /**
129
- * Drop-in for React's `useCallback`.
130
- * In Pyreon, components run once so callbacks are never recreated — returns `fn` as-is.
167
+ * React-compatible `useCallback` — returns the cached function when deps haven't changed.
131
168
  */
132
- // biome-ignore lint/suspicious/noExplicitAny: any is needed for contravariant function params
133
- export function useCallback<T extends (...args: any[]) => any>(fn: T, _deps?: unknown[]): T {
134
- return fn
169
+ export function useCallback<T extends (...args: never[]) => unknown>(fn: T, deps: unknown[]): T {
170
+ return useMemo(() => fn, deps)
135
171
  }
136
172
 
137
- // ─── Refs ─────────────────────────────────────────────────────────────────────
173
+ // ─── Refs ────────────────────────────────────────────────────────────────────
138
174
 
139
175
  /**
140
- * Drop-in for React's `useRef`.
141
- * Returns `{ current: T }` — same shape as React's ref object.
176
+ * React-compatible `useRef` returns `{ current }` persisted across re-renders.
142
177
  */
143
178
  export function useRef<T>(initial?: T): { current: T | null } {
144
- const ref = createRef<T>()
145
- if (initial !== undefined) ref.current = initial as T
146
- return ref
179
+ const ctx = requireCtx()
180
+ const idx = getHookIndex()
181
+
182
+ if (ctx.hooks.length <= idx) {
183
+ const ref = { current: initial !== undefined ? (initial as T) : null }
184
+ ctx.hooks.push(ref)
185
+ }
186
+
187
+ return ctx.hooks[idx] as { current: T | null }
147
188
  }
148
189
 
149
190
  // ─── Context ─────────────────────────────────────────────────────────────────
150
191
 
151
- /**
152
- * Drop-in for React's `createContext` + `useContext`.
153
- * Usage mirrors React: `const Ctx = createContext(defaultValue)`.
154
- */
155
192
  export { createContext, useContext }
156
193
 
157
- // ─── ID ───────────────────────────────────────────────────────────────────────
194
+ // ─── ID ──────────────────────────────────────────────────────────────────────
195
+
196
+ let _idCounter = 0
158
197
 
159
198
  /**
160
- * Drop-in for React's `useId` — returns a stable unique string per component instance.
161
- *
162
- * Uses the component's effectScope as the key so the counter starts at 0 for every
163
- * component on both server and client — IDs are deterministic and hydration-safe.
199
+ * React-compatible `useId` — returns a stable unique string per hook call.
164
200
  */
165
- const _idCounters = new WeakMap<object, number>()
166
-
167
201
  export function useId(): string {
168
- const scope = getCurrentScope()
169
- if (!scope) return `:r${Math.random().toString(36).slice(2, 9)}:`
170
- const count = _idCounters.get(scope) ?? 0
171
- _idCounters.set(scope, count + 1)
172
- return `:r${count.toString(36)}:`
202
+ const ctx = requireCtx()
203
+ const idx = getHookIndex()
204
+
205
+ if (ctx.hooks.length <= idx) {
206
+ ctx.hooks.push(`:r${(_idCounter++).toString(36)}:`)
207
+ }
208
+
209
+ return ctx.hooks[idx] as string
173
210
  }
174
211
 
175
- // ─── Optimization ─────────────────────────────────────────────────────────────
212
+ // ─── Optimization ────────────────────────────────────────────────────────────
176
213
 
177
214
  /**
178
- * Drop-in for React's `memo` — wraps a component.
179
- * In Pyreon, components run once (no re-renders), so memoization is a no-op.
180
- * Kept for API compatibility when migrating React code.
215
+ * React-compatible `memo` — wraps a component to skip re-render when props
216
+ * are shallowly equal.
181
217
  */
182
218
  export function memo<P extends Record<string, unknown>>(
183
219
  component: (props: P) => VNodeChild,
220
+ areEqual?: (prevProps: P, nextProps: P) => boolean,
184
221
  ): (props: P) => VNodeChild {
185
- return component
222
+ const compare =
223
+ areEqual ??
224
+ ((a: P, b: P) => {
225
+ const keysA = Object.keys(a)
226
+ const keysB = Object.keys(b)
227
+ if (keysA.length !== keysB.length) return false
228
+ for (const k of keysA) {
229
+ if (!Object.is(a[k], b[k])) return false
230
+ }
231
+ return true
232
+ })
233
+
234
+ let prevProps: P | null = null
235
+ let prevResult: VNodeChild = null
236
+
237
+ return (props: P) => {
238
+ if (prevProps !== null && compare(prevProps, props)) {
239
+ return prevResult
240
+ }
241
+ prevProps = props
242
+ prevResult = (component as (p: P) => VNodeChild)(props)
243
+ return prevResult
244
+ }
186
245
  }
187
246
 
188
247
  /**
189
- * Drop-in for React's `useTransition` — no-op in Pyreon (no concurrent mode).
190
- * Returns `[false, (fn) => fn()]` to keep code runnable without changes.
248
+ * React-compatible `useTransition` — no concurrent mode in Pyreon.
191
249
  */
192
250
  export function useTransition(): [boolean, (fn: () => void) => void] {
193
251
  return [false, (fn) => fn()]
194
252
  }
195
253
 
196
254
  /**
197
- * Drop-in for React's `useDeferredValue` — returns the value as-is in Pyreon.
255
+ * React-compatible `useDeferredValue` — returns the value as-is.
198
256
  */
199
257
  export function useDeferredValue<T>(value: T): T {
200
258
  return value
201
259
  }
202
260
 
203
- // ─── Batching ─────────────────────────────────────────────────────────────────
261
+ // ─── Imperative handle ───────────────────────────────────────────────────────
204
262
 
205
263
  /**
206
- * Drop-in for React's `unstable_batchedUpdates` / React 18's automatic batching.
207
- * Pyreon's `batch()` does the same thing.
208
- */
209
- export { batch }
210
-
211
- // ─── Error boundaries ─────────────────────────────────────────────────────────
212
-
213
- /**
214
- * Drop-in for React's error boundary pattern.
215
- * Return `true` from `handler` to prevent error propagation (like `componentDidCatch`).
216
- */
217
- export { onErrorCaptured as useErrorBoundary }
218
-
219
- // ─── Portals ─────────────────────────────────────────────────────────────────
220
-
221
- /**
222
- * Drop-in for React's `createPortal(children, target)`.
223
- */
224
- export function createPortal(children: VNodeChild, target: Element): VNodeChild {
225
- return Portal({ target, children })
226
- }
227
-
228
- // ─── Imperative handle ────────────────────────────────────────────────────────
229
-
230
- /**
231
- * Drop-in for React's `useImperativeHandle`.
232
- * In Pyreon, expose methods via a ref prop directly — this is a compatibility shim.
264
+ * React-compatible `useImperativeHandle`.
233
265
  */
234
266
  export function useImperativeHandle<T>(
235
267
  ref: { current: T | null } | null | undefined,
236
268
  init: () => T,
237
- _deps?: unknown[],
269
+ deps?: unknown[],
238
270
  ): void {
239
- onMount((): undefined => {
271
+ useLayoutEffect(() => {
240
272
  if (ref) ref.current = init()
241
- })
242
- onUnmount(() => {
243
- if (ref) ref.current = null
244
- })
273
+ return () => {
274
+ if (ref) ref.current = null
275
+ }
276
+ }, deps)
245
277
  }
246
278
 
247
- // ─── Selector ─────────────────────────────────────────────────────────────────
279
+ // ─── Batching ────────────────────────────────────────────────────────────────
248
280
 
249
- /**
250
- * Pyreon-specific: O(1) equality selector (no React equivalent).
251
- * Useful for large lists where only the selected item should re-render.
252
- * @see createSelector in @pyreon/reactivity
253
- */
254
- export { createSelector }
255
-
256
- // ─── onUpdate ─────────────────────────────────────────────────────────────────
257
-
258
- /** Pyreon-specific lifecycle hook — runs after each reactive update. */
259
- export { onMount, onUnmount, onUpdate }
281
+ export { batch }
260
282
 
261
- // ─── Suspense / lazy ──────────────────────────────────────────────────────────
283
+ // ─── Portals ─────────────────────────────────────────────────────────────────
262
284
 
263
285
  /**
264
- * Drop-in for React's `lazy()`.
265
- * Re-exported from `@pyreon/core` — wraps a dynamic import, renders null until
266
- * the module resolves. Pair with `<Suspense>` to show a fallback during loading.
286
+ * React-compatible `createPortal(children, target)`.
267
287
  */
268
- export { lazy } from "@pyreon/core"
288
+ export function createPortal(children: VNodeChild, target: Element): VNodeChild {
289
+ return Portal({ target, children })
290
+ }
269
291
 
270
- /**
271
- * Drop-in for React's `<Suspense>`.
272
- * Shows `fallback` while a `lazy()` child is still loading.
273
- */
292
+ // ─── Suspense / lazy / ErrorBoundary ─────────────────────────────────────────
293
+
294
+ export { lazy } from "@pyreon/core"
274
295
  export { ErrorBoundary, Suspense }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Compat JSX runtime for React compatibility mode.
3
+ *
4
+ * When `jsxImportSource` is set to `@pyreon/react-compat` (via the vite plugin's
5
+ * `compat: "react"` option), OXC rewrites JSX to import from this file:
6
+ * <div className="x" /> → jsx("div", { className: "x" })
7
+ *
8
+ * For component VNodes, we wrap the component function so it returns a reactive
9
+ * accessor — enabling React-style re-renders on state change while Pyreon's
10
+ * existing renderer handles all DOM work.
11
+ */
12
+
13
+ import type { ComponentFn, Props, VNode, VNodeChild } from "@pyreon/core"
14
+ import { Fragment, h } from "@pyreon/core"
15
+ import { signal } from "@pyreon/reactivity"
16
+
17
+ export { Fragment }
18
+
19
+ // ─── Render context (used by hooks) ──────────────────────────────────────────
20
+
21
+ export interface RenderContext {
22
+ hooks: unknown[]
23
+ scheduleRerender: () => void
24
+ /** Effect entries pending execution after render */
25
+ pendingEffects: EffectEntry[]
26
+ /** Layout effect entries pending execution after render */
27
+ pendingLayoutEffects: EffectEntry[]
28
+ /** Set to true when the component is unmounted */
29
+ unmounted: boolean
30
+ }
31
+
32
+ export interface EffectEntry {
33
+ // biome-ignore lint/suspicious/noConfusingVoidType: matches React's effect signature
34
+ fn: () => (() => void) | void
35
+ deps: unknown[] | undefined
36
+ cleanup: (() => void) | undefined
37
+ }
38
+
39
+ let _currentCtx: RenderContext | null = null
40
+ let _hookIndex = 0
41
+
42
+ export function getCurrentCtx(): RenderContext | null {
43
+ return _currentCtx
44
+ }
45
+
46
+ export function getHookIndex(): number {
47
+ return _hookIndex++
48
+ }
49
+
50
+ export function beginRender(ctx: RenderContext): void {
51
+ _currentCtx = ctx
52
+ _hookIndex = 0
53
+ ctx.pendingEffects = []
54
+ ctx.pendingLayoutEffects = []
55
+ }
56
+
57
+ export function endRender(): void {
58
+ _currentCtx = null
59
+ _hookIndex = 0
60
+ }
61
+
62
+ // ─── Effect runners ──────────────────────────────────────────────────────────
63
+
64
+ function runLayoutEffects(entries: EffectEntry[]): void {
65
+ for (const entry of entries) {
66
+ if (entry.cleanup) entry.cleanup()
67
+ const cleanup = entry.fn()
68
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
69
+ }
70
+ }
71
+
72
+ function scheduleEffects(ctx: RenderContext, entries: EffectEntry[]): void {
73
+ if (entries.length === 0) return
74
+ queueMicrotask(() => {
75
+ for (const entry of entries) {
76
+ if (ctx.unmounted) return
77
+ if (entry.cleanup) entry.cleanup()
78
+ const cleanup = entry.fn()
79
+ entry.cleanup = typeof cleanup === "function" ? cleanup : undefined
80
+ }
81
+ })
82
+ }
83
+
84
+ // ─── Component wrapping ──────────────────────────────────────────────────────
85
+
86
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
87
+ const _wrapperCache = new WeakMap<Function, ComponentFn>()
88
+
89
+ // biome-ignore lint/complexity/noBannedTypes: Function is needed for generic component wrapping
90
+ function wrapCompatComponent(reactComponent: Function): ComponentFn {
91
+ let wrapped = _wrapperCache.get(reactComponent)
92
+ if (wrapped) return wrapped
93
+
94
+ // The wrapper returns a reactive accessor (() => VNodeChild) which Pyreon's
95
+ // mountChild treats as a reactive expression via mountReactive.
96
+ wrapped = ((props: Props) => {
97
+ const ctx: RenderContext = {
98
+ hooks: [],
99
+ scheduleRerender: () => {
100
+ // Will be replaced below after version signal is created
101
+ },
102
+ pendingEffects: [],
103
+ pendingLayoutEffects: [],
104
+ unmounted: false,
105
+ }
106
+
107
+ const version = signal(0)
108
+ let updateScheduled = false
109
+
110
+ ctx.scheduleRerender = () => {
111
+ if (ctx.unmounted || updateScheduled) return
112
+ updateScheduled = true
113
+ queueMicrotask(() => {
114
+ updateScheduled = false
115
+ if (!ctx.unmounted) version.set(version.peek() + 1)
116
+ })
117
+ }
118
+
119
+ // Return reactive accessor — Pyreon's mountChild calls mountReactive
120
+ return () => {
121
+ version() // tracked read — triggers re-execution when state changes
122
+ beginRender(ctx)
123
+ const result = (reactComponent as ComponentFn)(props)
124
+ const layoutEffects = ctx.pendingLayoutEffects
125
+ const effects = ctx.pendingEffects
126
+ endRender()
127
+
128
+ runLayoutEffects(layoutEffects)
129
+ scheduleEffects(ctx, effects)
130
+
131
+ return result
132
+ }
133
+ }) as unknown as ComponentFn
134
+
135
+ _wrapperCache.set(reactComponent, wrapped)
136
+ return wrapped
137
+ }
138
+
139
+ // ─── JSX functions ───────────────────────────────────────────────────────────
140
+
141
+ export function jsx(
142
+ type: string | ComponentFn | symbol,
143
+ props: Props & { children?: VNodeChild | VNodeChild[] },
144
+ key?: string | number | null,
145
+ ): VNode {
146
+ const { children, ...rest } = props
147
+ const propsWithKey = (key != null ? { ...rest, key } : rest) as Props
148
+
149
+ if (typeof type === "function") {
150
+ // Wrap React-style component for re-render support
151
+ const wrapped = wrapCompatComponent(type)
152
+ const componentProps = children !== undefined ? { ...propsWithKey, children } : propsWithKey
153
+ return h(wrapped, componentProps)
154
+ }
155
+
156
+ // DOM element or symbol (Fragment): children go in vnode.children
157
+ const childArray = children === undefined ? [] : Array.isArray(children) ? children : [children]
158
+
159
+ // Map className → class for React compat
160
+ if (typeof type === "string" && propsWithKey.className !== undefined) {
161
+ propsWithKey.class = propsWithKey.className
162
+ delete propsWithKey.className
163
+ }
164
+
165
+ return h(type, propsWithKey, ...(childArray as VNodeChild[]))
166
+ }
167
+
168
+ export const jsxs = jsx
169
+ export const jsxDEV = jsx