@pyreon/react-compat 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.
package/src/index.ts CHANGED
@@ -12,11 +12,20 @@
12
12
  * import { createRoot } from "react-dom/client" // aliased by vite plugin
13
13
  */
14
14
 
15
- export type { Props, VNode as ReactNode, VNodeChild } from '@pyreon/core'
16
- export { Fragment, h as createElement, h } from '@pyreon/core'
17
-
18
- import type { VNode, VNodeChild } from '@pyreon/core'
19
- import { createContext, ErrorBoundary, h, Portal, Suspense, useContext } from '@pyreon/core'
15
+ export type { Props, VNode, VNodeChild } from '@pyreon/core'
16
+ export { Fragment, h as createElement, h, createRef } from '@pyreon/core'
17
+
18
+ import type { Context, VNode, VNodeChild } from '@pyreon/core'
19
+ import {
20
+ createContext as pyreonCreateContext,
21
+ ErrorBoundary,
22
+ h,
23
+ nativeCompat,
24
+ Portal,
25
+ provide as pyreonProvide,
26
+ Suspense,
27
+ useContext as pyreonUseContext,
28
+ } from '@pyreon/core'
20
29
  import { batch } from '@pyreon/reactivity'
21
30
  import type { EffectEntry } from './jsx-runtime'
22
31
  import { getCurrentCtx, getHookIndex } from './jsx-runtime'
@@ -49,47 +58,62 @@ export function useState<T>(initial: T | (() => T)): [T, (v: T | ((prev: T) => T
49
58
  const idx = getHookIndex()
50
59
 
51
60
  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
56
- const setter = (v: T | ((prev: T) => T)) => {
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()
61
+ const val = typeof initial === 'function' ? (initial as () => T)() : initial
62
+ // Store both value and a STABLE setter in one hook slot so setter identity
63
+ // never changes across renders (React guarantee).
64
+ const entry = { value: val, setter: null as unknown as (v: T | ((prev: T) => T)) => void }
65
+ entry.setter = (v: T | ((prev: T) => T)) => {
66
+ const current = entry.value
67
+ const next = typeof v === 'function' ? (v as (prev: T) => T)(current) : v
68
+ if (Object.is(current, next)) return
69
+ entry.value = next
70
+ ctx.scheduleRerender()
71
+ }
72
+ ctx.hooks.push(entry)
62
73
  }
63
74
 
64
- return [value, setter]
75
+ const entry = ctx.hooks[idx] as { value: T; setter: (v: T | ((prev: T) => T)) => void }
76
+ return [entry.value, entry.setter]
65
77
  }
66
78
 
67
79
  // ─── Reducer ─────────────────────────────────────────────────────────────────
68
80
 
69
81
  /**
70
82
  * React-compatible `useReducer` — returns `[state, dispatch]`.
83
+ * Supports the 3-argument form: `useReducer(reducer, initialArg, init)`.
71
84
  */
72
85
  export function useReducer<S, A>(
73
86
  reducer: (state: S, action: A) => S,
74
- initial: S | (() => S),
87
+ initialArg: S | (() => S),
88
+ init?: (arg: S) => S,
75
89
  ): [S, (action: A) => void] {
76
90
  const ctx = requireCtx()
77
91
  const idx = getHookIndex()
78
92
 
79
93
  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()
94
+ let initial: S
95
+ if (init) {
96
+ initial = init(initialArg as S)
97
+ } else if (typeof initialArg === 'function') {
98
+ initial = (initialArg as () => S)()
99
+ } else {
100
+ initial = initialArg
101
+ }
102
+ // Store both value and a STABLE dispatch in one hook slot so dispatch identity
103
+ // never changes across renders (React guarantee).
104
+ const entry = { value: initial, dispatch: null as unknown as (action: A) => void }
105
+ entry.dispatch = (action: A) => {
106
+ const current = entry.value
107
+ const next = reducer(current, action)
108
+ if (Object.is(current, next)) return
109
+ entry.value = next
110
+ ctx.scheduleRerender()
111
+ }
112
+ ctx.hooks.push(entry)
90
113
  }
91
114
 
92
- return [state, dispatch]
115
+ const entry = ctx.hooks[idx] as { value: S; dispatch: (action: A) => void }
116
+ return [entry.value, entry.dispatch]
93
117
  }
94
118
 
95
119
  // ─── Effects ─────────────────────────────────────────────────────────────────
@@ -138,6 +162,28 @@ export function useLayoutEffect(fn: () => (() => void) | void, deps?: unknown[])
138
162
  }
139
163
  }
140
164
 
165
+ /**
166
+ * React-compatible `useInsertionEffect` — runs synchronously before layout effects.
167
+ * Intended for CSS-in-JS libraries to inject styles before DOM reads.
168
+ */
169
+ export function useInsertionEffect(fn: () => (() => void) | void, deps?: unknown[]): void {
170
+ const ctx = requireCtx()
171
+ const idx = getHookIndex()
172
+
173
+ if (ctx.hooks.length <= idx) {
174
+ const entry: EffectEntry = { fn, deps, cleanup: undefined }
175
+ ctx.hooks.push(entry)
176
+ ctx.pendingInsertionEffects.push(entry)
177
+ } else {
178
+ const entry = ctx.hooks[idx] as EffectEntry
179
+ if (depsChanged(entry.deps, deps)) {
180
+ entry.fn = fn
181
+ entry.deps = deps
182
+ ctx.pendingInsertionEffects.push(entry)
183
+ }
184
+ }
185
+ }
186
+
141
187
  // ─── Memoization ─────────────────────────────────────────────────────────────
142
188
 
143
189
  /**
@@ -187,7 +233,108 @@ export function useRef<T>(initial?: T): { current: T | null } {
187
233
 
188
234
  // ─── Context ─────────────────────────────────────────────────────────────────
189
235
 
190
- export { createContext, useContext }
236
+ const COMPAT_CTX = Symbol.for('pyreon:compat-ctx')
237
+
238
+ /**
239
+ * Compat-specific context with subscriber notification and tree-scoped nesting.
240
+ *
241
+ * Uses Pyreon's native context stack for tree-scoped Provider nesting (inner
242
+ * Providers override outer ones for their subtree), with a subscriber set
243
+ * layered on top for React-style consumer re-rendering.
244
+ */
245
+ export interface CompatContext<T> {
246
+ /** Brand marker so `useContext` can distinguish compat contexts */
247
+ readonly [COMPAT_CTX_BRAND]: true
248
+ /** Default value when no Provider is mounted */
249
+ _defaultValue: T
250
+ /** Pyreon native context for tree-scoped value+subscriber storage */
251
+ _pyreonCtx: Context<{ value: T; subscribers: Set<() => void> }>
252
+ /** Subscribers at the default (no-Provider) level */
253
+ _subscribers: Set<() => void>
254
+ /** React-compatible Provider component (native Pyreon, NOT compat-wrapped).
255
+ * Returns a reactive accessor `() => VNodeChild` for Pyreon's renderer. */
256
+ Provider: (props: Record<string, unknown>) => unknown
257
+ }
258
+
259
+ const COMPAT_CTX_BRAND: typeof COMPAT_CTX = COMPAT_CTX
260
+
261
+ /**
262
+ * React-compatible `createContext` — creates a context with a Provider that
263
+ * supports nested Providers (inner overrides outer for its subtree) and
264
+ * notifies all `useContext` consumers when its value changes.
265
+ */
266
+ export function createContext<T>(defaultValue: T): CompatContext<T> {
267
+ // Pyreon native context: each Provider pushes { value, subscribers } onto
268
+ // the tree-scoped stack. Consumers read the nearest frame via useContext.
269
+ const pyreonCtx = pyreonCreateContext<{ value: T; subscribers: Set<() => void> }>({
270
+ value: defaultValue,
271
+ subscribers: new Set(),
272
+ })
273
+
274
+ // Default-level subscribers (for consumers with no Provider above them)
275
+ const defaultSubscribers = new Set<() => void>()
276
+
277
+ // Provider is a NATIVE Pyreon component (not compat-wrapped).
278
+ // It calls provide() once during setup to push onto the context stack,
279
+ // then returns a reactive accessor that updates the frame value on re-render.
280
+ const Provider = (props: Record<string, unknown>) => {
281
+ // Setup (runs once per mount):
282
+ const frame = { value: (props as { value: T }).value, subscribers: new Set<() => void>() }
283
+ pyreonProvide(pyreonCtx, frame)
284
+
285
+ // Return reactive accessor for children (re-evaluated when props change)
286
+ return () => {
287
+ const { value, children } = props as { value: T; children?: VNodeChild }
288
+ // On re-render: update the frame value and notify subscribers
289
+ if (!Object.is(frame.value, value)) {
290
+ frame.value = value
291
+ for (const sub of frame.subscribers) sub()
292
+ }
293
+ return children ?? null
294
+ }
295
+ }
296
+ // Mark as native so jsx() doesn't wrap it with wrapCompatComponent
297
+ nativeCompat(Provider)
298
+
299
+ const ctx: CompatContext<T> = {
300
+ [COMPAT_CTX_BRAND]: true as const,
301
+ _defaultValue: defaultValue,
302
+ _pyreonCtx: pyreonCtx,
303
+ _subscribers: defaultSubscribers,
304
+ Provider,
305
+ }
306
+ return ctx
307
+ }
308
+
309
+ /**
310
+ * React-compatible `useContext` — reads the current context value and
311
+ * subscribes the calling component to future value changes.
312
+ *
313
+ * Reads from Pyreon's tree-scoped context stack (correct nesting) and
314
+ * subscribes to the nearest Provider's subscriber set for re-rendering.
315
+ *
316
+ * Works with both compat contexts (from this module's `createContext`) and
317
+ * Pyreon native contexts (from `@pyreon/core`).
318
+ */
319
+ export function useContext<T>(context: CompatContext<T> | Context<T>): T {
320
+ if (COMPAT_CTX in context) {
321
+ const compatCtx = context as CompatContext<T>
322
+ // Read from Pyreon's tree-scoped stack (correct nesting)
323
+ const frame = pyreonUseContext(compatCtx._pyreonCtx)
324
+ const renderCtx = getCurrentCtx()
325
+ if (renderCtx) {
326
+ const idx = getHookIndex()
327
+ if (renderCtx.hooks.length <= idx) {
328
+ // Subscribe to the frame's subscriber set
329
+ const sub = () => renderCtx.scheduleRerender()
330
+ frame.subscribers.add(sub)
331
+ renderCtx.hooks.push({ _contextUnsub: () => frame.subscribers.delete(sub) })
332
+ }
333
+ }
334
+ return frame.value
335
+ }
336
+ return pyreonUseContext(context as Context<T>)
337
+ }
191
338
 
192
339
  // ─── ID ──────────────────────────────────────────────────────────────────────
193
340
 
@@ -209,37 +356,65 @@ export function useId(): string {
209
356
 
210
357
  // ─── Optimization ────────────────────────────────────────────────────────────
211
358
 
359
+ function shallowEqual<P extends Record<string, unknown>>(a: P, b: P): boolean {
360
+ const keysA = Object.keys(a)
361
+ const keysB = Object.keys(b)
362
+ if (keysA.length !== keysB.length) return false
363
+ for (const k of keysA) {
364
+ if (!Object.is(a[k], b[k])) return false
365
+ }
366
+ return true
367
+ }
368
+
212
369
  /**
213
370
  * React-compatible `memo` — wraps a component to skip re-render when props
214
371
  * are shallowly equal.
372
+ *
373
+ * Each component INSTANCE gets its own props/result cache via a hook slot,
374
+ * so two `<MemoComp />` usages don't share memoization state.
215
375
  */
216
376
  export function memo<P extends Record<string, unknown>>(
217
377
  component: (props: P) => VNodeChild,
218
378
  areEqual?: (prevProps: P, nextProps: P) => boolean,
219
379
  ): (props: P) => VNodeChild {
220
- const compare =
221
- areEqual ??
222
- ((a: P, b: P) => {
223
- const keysA = Object.keys(a)
224
- const keysB = Object.keys(b)
225
- if (keysA.length !== keysB.length) return false
226
- for (const k of keysA) {
227
- if (!Object.is(a[k], b[k])) return false
380
+ const compare = areEqual ?? shallowEqual
381
+
382
+ const MEMO_MARKER = Symbol.for('pyreon:memo')
383
+
384
+ // Fallback closure-level cache for calls outside a compat render context
385
+ // (e.g. direct function calls in tests). Inside a render context, each
386
+ // component instance gets its own cache via a hook slot.
387
+ let _fallbackPrevProps: P | null = null
388
+ let _fallbackPrevResult: VNodeChild = null
389
+
390
+ const memoized = (props: P) => {
391
+ const ctx = getCurrentCtx()
392
+ if (ctx) {
393
+ // Per-instance cache via hook slot
394
+ const idx = getHookIndex()
395
+ if (ctx.hooks.length <= idx) {
396
+ ctx.hooks.push({ prevProps: null as P | null, prevResult: null as VNodeChild })
228
397
  }
229
- return true
230
- })
231
-
232
- let prevProps: P | null = null
233
- let prevResult: VNodeChild = null
234
-
235
- return (props: P) => {
236
- if (prevProps !== null && compare(prevProps, props)) {
237
- return prevResult
398
+ const cache = ctx.hooks[idx] as { prevProps: P | null; prevResult: VNodeChild }
399
+ if (cache.prevProps !== null && compare(cache.prevProps, props)) {
400
+ return cache.prevResult
401
+ }
402
+ cache.prevProps = props
403
+ cache.prevResult = component(props)
404
+ return cache.prevResult
238
405
  }
239
- prevProps = props
240
- prevResult = (component as (p: P) => VNodeChild)(props)
241
- return prevResult
406
+ // No compat context — use closure-level fallback cache
407
+ if (_fallbackPrevProps !== null && compare(_fallbackPrevProps, props)) {
408
+ return _fallbackPrevResult
409
+ }
410
+ _fallbackPrevProps = props
411
+ _fallbackPrevResult = component(props)
412
+ return _fallbackPrevResult
242
413
  }
414
+ ;(memoized as unknown as Record<symbol, boolean>)[MEMO_MARKER] = true
415
+ memoized.displayName =
416
+ (component as unknown as { displayName?: string }).displayName || component.name || 'Memo'
417
+ return memoized
243
418
  }
244
419
 
245
420
  /**
@@ -302,10 +477,13 @@ export { ErrorBoundary, Suspense }
302
477
  export function forwardRef<P extends Record<string, unknown>>(
303
478
  render: (props: P, ref: { current: unknown } | null) => VNodeChild,
304
479
  ): (props: P & { ref?: { current: unknown } | null }) => VNodeChild {
305
- return (props: P & { ref?: { current: unknown } | null }) => {
480
+ const forwarded = (props: P & { ref?: { current: unknown } | null }) => {
306
481
  const { ref, ...rest } = props
307
482
  return render(rest as P, ref ?? null)
308
483
  }
484
+ forwarded.displayName =
485
+ (render as unknown as { displayName?: string }).displayName || render.name || 'ForwardRef'
486
+ return forwarded
309
487
  }
310
488
 
311
489
  // ─── cloneElement ───────────────────────────────────────────────────────────
@@ -349,10 +527,20 @@ export const Children = {
349
527
  map<T>(children: VNodeChild | VNodeChild[], fn: (child: VNodeChild, index: number) => T): T[] {
350
528
  const flat = flattenChildren(children)
351
529
  const result: T[] = []
530
+ let validIndex = 0
352
531
  for (let i = 0; i < flat.length; i++) {
353
532
  const child = flat[i]
354
533
  if (child == null || child === true || child === false) continue
355
- result.push(fn(child, i))
534
+ const mapped = fn(child, validIndex)
535
+ // Assign key to mapped VNode children if they don't already have one
536
+ if (mapped && typeof mapped === 'object' && 'type' in mapped && 'props' in mapped) {
537
+ const vnode = mapped as unknown as VNode
538
+ if (vnode.key == null) {
539
+ vnode.key = `.${validIndex}`
540
+ }
541
+ }
542
+ result.push(mapped)
543
+ validIndex++
356
544
  }
357
545
  return result
358
546
  },
@@ -362,10 +550,11 @@ export const Children = {
362
550
  */
363
551
  forEach(children: VNodeChild | VNodeChild[], fn: (child: VNodeChild, index: number) => void): void {
364
552
  const flat = flattenChildren(children)
553
+ let validIndex = 0
365
554
  for (let i = 0; i < flat.length; i++) {
366
555
  const child = flat[i]
367
556
  if (child == null || child === true || child === false) continue
368
- fn(child, i)
557
+ fn(child, validIndex++)
369
558
  }
370
559
  },
371
560
 
@@ -400,3 +589,311 @@ export const Children = {
400
589
  return arr[0] as VNodeChild
401
590
  },
402
591
  }
592
+
593
+ // ─── useSyncExternalStore ───────────────────────────────────────────────────
594
+
595
+ /**
596
+ * React-compatible `useSyncExternalStore` — subscribes to an external store.
597
+ * Re-subscribes automatically when the `subscribe` function identity changes.
598
+ */
599
+ export function useSyncExternalStore<T>(
600
+ subscribe: (onStoreChange: () => void) => () => void,
601
+ getSnapshot: () => T,
602
+ getServerSnapshot?: () => T,
603
+ ): T {
604
+ const ctx = requireCtx()
605
+ const idx = getHookIndex()
606
+
607
+ // SSR path
608
+ if (typeof window === 'undefined' && getServerSnapshot) {
609
+ if (ctx.hooks.length <= idx) {
610
+ ctx.hooks.push({ subscribe, unsubscribe: undefined, snapshot: getServerSnapshot() })
611
+ }
612
+ return (ctx.hooks[idx] as { snapshot: T }).snapshot
613
+ }
614
+
615
+ if (ctx.hooks.length <= idx) {
616
+ const snapshot = getSnapshot()
617
+ const entry = {
618
+ subscribe,
619
+ unsubscribe: undefined as (() => void) | undefined,
620
+ snapshot,
621
+ }
622
+ const onChange = () => {
623
+ const next = getSnapshot()
624
+ if (!Object.is(entry.snapshot, next)) {
625
+ entry.snapshot = next
626
+ ctx.scheduleRerender()
627
+ }
628
+ }
629
+ entry.unsubscribe = subscribe(onChange)
630
+ ctx.hooks.push(entry)
631
+ return snapshot
632
+ }
633
+
634
+ const entry = ctx.hooks[idx] as {
635
+ subscribe: typeof subscribe
636
+ unsubscribe: (() => void) | undefined
637
+ snapshot: T
638
+ }
639
+
640
+ // Re-subscribe if subscribe function identity changed
641
+ if (entry.subscribe !== subscribe) {
642
+ if (entry.unsubscribe) entry.unsubscribe()
643
+ const onChange = () => {
644
+ const next = getSnapshot()
645
+ if (!Object.is(entry.snapshot, next)) {
646
+ entry.snapshot = next
647
+ ctx.scheduleRerender()
648
+ }
649
+ }
650
+ entry.unsubscribe = subscribe(onChange)
651
+ entry.subscribe = subscribe
652
+ }
653
+
654
+ // Always read fresh snapshot during render
655
+ entry.snapshot = getSnapshot()
656
+ return entry.snapshot
657
+ }
658
+
659
+ // ─── use ────────────────────────────────────────────────────────────────────
660
+
661
+ const _promiseCache = new WeakMap<
662
+ Promise<unknown>,
663
+ { status: 'pending' | 'resolved' | 'rejected'; value?: unknown; error?: unknown }
664
+ >()
665
+
666
+ /**
667
+ * React-compatible `use` — reads a Context or suspends on a Promise.
668
+ * Can be called conditionally (unlike other hooks).
669
+ *
670
+ * IMPORTANT: Promises must have a stable identity across renders.
671
+ * Create promises outside the component or memoize them. Calling
672
+ * `use(fetch('/api'))` creates a new promise each render and will
673
+ * cause infinite suspension.
674
+ */
675
+ export function use<T>(resource: Context<T> | CompatContext<T> | Promise<T>): T {
676
+ // Compat context path
677
+ if (resource && typeof resource === 'object' && COMPAT_CTX in resource) {
678
+ return useContext(resource as CompatContext<T>)
679
+ }
680
+ // Pyreon native context path
681
+ if (resource && typeof resource === 'object' && 'id' in resource && 'defaultValue' in resource) {
682
+ return pyreonUseContext(resource as Context<T>)
683
+ }
684
+ // Promise path — suspend via throw
685
+ const promise = resource as Promise<T>
686
+ let entry = _promiseCache.get(promise)
687
+ if (!entry) {
688
+ entry = { status: 'pending' }
689
+ _promiseCache.set(promise, entry)
690
+ promise.then(
691
+ (value) => {
692
+ entry!.status = 'resolved'
693
+ entry!.value = value
694
+ },
695
+ (error) => {
696
+ entry!.status = 'rejected'
697
+ entry!.error = error
698
+ },
699
+ )
700
+ }
701
+ if (entry.status === 'resolved') return entry.value as T
702
+ if (entry.status === 'rejected') throw entry.error
703
+ throw promise // Suspense catches this
704
+ }
705
+
706
+ // ─── useActionState ─────────────────────────────────────────────────────────
707
+
708
+ /**
709
+ * React-compatible `useActionState` — manages async action state with pending indicator.
710
+ */
711
+ export function useActionState<S, P>(
712
+ action: (state: S, payload: P) => S | Promise<S>,
713
+ initialState: S,
714
+ ): [S, (payload: P) => void, boolean] {
715
+ const [state, setState] = useState(initialState)
716
+ const [isPending, setIsPending] = useState(false)
717
+
718
+ const dispatch = (payload: P) => {
719
+ setIsPending(true)
720
+ const result = action(state, payload)
721
+ if (result instanceof Promise) {
722
+ result.then((next) => {
723
+ setState(next)
724
+ setIsPending(false)
725
+ })
726
+ } else {
727
+ setState(result)
728
+ setIsPending(false)
729
+ }
730
+ }
731
+
732
+ return [state, dispatch, isPending]
733
+ }
734
+
735
+ // ─── startTransition ────────────────────────────────────────────────────────
736
+
737
+ /**
738
+ * React-compatible `startTransition` — runs the callback synchronously.
739
+ * No concurrent mode in Pyreon, so transitions are immediate.
740
+ */
741
+ export function startTransition(fn: () => void): void {
742
+ fn()
743
+ }
744
+
745
+ // ─── isValidElement ─────────────────────────────────────────────────────────
746
+
747
+ /**
748
+ * React-compatible `isValidElement` — checks if a value is a VNode.
749
+ */
750
+ export function isValidElement(value: unknown): value is VNode {
751
+ return value != null && typeof value === 'object' && 'type' in value && 'props' in value
752
+ }
753
+
754
+ // ─── useDebugValue ──────────────────────────────────────────────────────────
755
+
756
+ /**
757
+ * React-compatible `useDebugValue` — no-op in Pyreon (no React DevTools integration).
758
+ */
759
+ export function useDebugValue<T>(_value: T, _format?: (v: T) => unknown): void {}
760
+
761
+ // ─── flushSync ──────────────────────────────────────────────────────────────
762
+
763
+ /**
764
+ * React-compatible `flushSync` — runs the callback synchronously.
765
+ *
766
+ * BEHAVIORAL DIFFERENCE: In Pyreon's compat model, state updates are
767
+ * batched via microtask. flushSync runs the callback and returns its
768
+ * result, but the DOM updates triggered by state changes inside the
769
+ * callback still fire asynchronously. For DOM measurement after state
770
+ * updates, use `await act(() => setState(...))` in tests, or
771
+ * `requestAnimationFrame` in production code.
772
+ */
773
+ export function flushSync<T>(fn: () => T): T {
774
+ return fn()
775
+ }
776
+
777
+ // ─── act (testing) ──────────────────────────────────────────────────────────
778
+
779
+ /**
780
+ * React-compatible `act` — flushes pending microtasks for testing.
781
+ */
782
+ export async function act(fn: () => void | Promise<void>): Promise<void> {
783
+ const result = fn()
784
+ if (result instanceof Promise) await result
785
+ // Flush two rounds of microtasks to drain pending effects and rerenders
786
+ await new Promise<void>((r) => queueMicrotask(r))
787
+ await new Promise<void>((r) => queueMicrotask(r))
788
+ }
789
+
790
+ // ─── version ────────────────────────────────────────────────────────────────
791
+
792
+ export const version = '19.0.0-pyreon'
793
+
794
+ // ─── StrictMode / Profiler ──────────────────────────────────────────────────
795
+
796
+ /**
797
+ * React-compatible `StrictMode` — pass-through in Pyreon (no double-invoke behavior).
798
+ */
799
+ export function StrictMode(props: { children?: VNodeChild }): VNodeChild {
800
+ return props.children ?? null
801
+ }
802
+
803
+ /**
804
+ * React-compatible `Profiler` — pass-through in Pyreon (no profiling integration).
805
+ */
806
+ export function Profiler(props: {
807
+ id: string
808
+ onRender?: (...args: unknown[]) => void
809
+ children?: VNodeChild
810
+ }): VNodeChild {
811
+ return props.children ?? null
812
+ }
813
+
814
+ // ─── Component / PureComponent (class stubs) ────────────────────────────────
815
+
816
+ /**
817
+ * React-compatible `Component` class stub.
818
+ * Class components are not fully supported — use function components with hooks.
819
+ */
820
+ export class Component<P = Record<string, unknown>, S = Record<string, unknown>> {
821
+ props: Readonly<P>
822
+ state: Readonly<S>
823
+
824
+ constructor(props: P) {
825
+ this.props = props
826
+ this.state = {} as S
827
+ }
828
+
829
+ setState(_partial: Partial<S> | ((prev: S) => Partial<S>)): void {
830
+ console.warn(
831
+ '[Pyreon] Class component setState is not supported. Use function components with hooks.',
832
+ )
833
+ }
834
+
835
+ forceUpdate(): void {
836
+ console.warn(
837
+ '[Pyreon] Class component forceUpdate is not supported. Use function components with hooks.',
838
+ )
839
+ }
840
+
841
+ render(): VNodeChild {
842
+ return null
843
+ }
844
+ }
845
+
846
+ /**
847
+ * React-compatible `PureComponent` class stub.
848
+ */
849
+ export class PureComponent<
850
+ P = Record<string, unknown>,
851
+ S = Record<string, unknown>,
852
+ > extends Component<P, S> {}
853
+
854
+ // ─── React-compatible type exports ──────────────────────────────────────────
855
+
856
+ export type { Context }
857
+
858
+ export type FC<P = Record<string, unknown>> = (props: P) => VNodeChild
859
+ export type FunctionComponent<P = Record<string, unknown>> = FC<P>
860
+ export type ReactElement = VNode
861
+ export type ReactNode = VNodeChild
862
+ export type JSXElementConstructor<P> = (props: P) => VNodeChild
863
+ export type Dispatch<A> = (action: A) => void
864
+ export type SetStateAction<S> = S | ((prev: S) => S)
865
+ export type RefObject<T> = { readonly current: T | null }
866
+ export type MutableRefObject<T> = { current: T }
867
+ export type RefCallback<T> = (instance: T | null) => void
868
+ export type ForwardedRef<T> = RefObject<T> | RefCallback<T> | null
869
+ export type PropsWithChildren<P = Record<string, unknown>> = P & { children?: ReactNode }
870
+ export type PropsWithRef<P> = P & { ref?: RefObject<unknown> | RefCallback<unknown> | null }
871
+ export type { CSSProperties } from '@pyreon/core'
872
+
873
+ // Event types — aliases for TargetedEvent patterns
874
+ export type SyntheticEvent<T = Element> = Event & { currentTarget: T }
875
+ export type ChangeEvent<T = Element> = Event & { currentTarget: T; target: T }
876
+ export type FormEvent<T = Element> = Event & { currentTarget: T }
877
+ export type MouseEvent<T = Element> = globalThis.MouseEvent & { currentTarget: T }
878
+ export type KeyboardEvent<T = Element> = globalThis.KeyboardEvent & { currentTarget: T }
879
+ export type FocusEvent<T = Element> = globalThis.FocusEvent & { currentTarget: T }
880
+ export type DragEvent<T = Element> = globalThis.DragEvent & { currentTarget: T }
881
+ export type PointerEvent<T = Element> = globalThis.PointerEvent & { currentTarget: T }
882
+ export type TouchEvent<T = Element> = globalThis.TouchEvent & { currentTarget: T }
883
+ export type ClipboardEvent<T = Element> = globalThis.ClipboardEvent & { currentTarget: T }
884
+ export type AnimationEvent<T = Element> = globalThis.AnimationEvent & { currentTarget: T }
885
+ export type TransitionEvent<T = Element> = globalThis.TransitionEvent & { currentTarget: T }
886
+ export type WheelEvent<T = Element> = globalThis.WheelEvent & { currentTarget: T }
887
+
888
+ // HTML attribute types
889
+ export type HTMLAttributes<T = HTMLElement> = Record<string, unknown> & {
890
+ ref?: RefObject<T> | RefCallback<T> | null
891
+ }
892
+ export type InputHTMLAttributes<T = HTMLInputElement> = HTMLAttributes<T>
893
+ export type TextareaHTMLAttributes<T = HTMLTextAreaElement> = HTMLAttributes<T>
894
+ export type SelectHTMLAttributes<T = HTMLSelectElement> = HTMLAttributes<T>
895
+ export type ButtonHTMLAttributes<T = HTMLButtonElement> = HTMLAttributes<T>
896
+ export type AnchorHTMLAttributes<T = HTMLAnchorElement> = HTMLAttributes<T>
897
+ export type FormHTMLAttributes<T = HTMLFormElement> = HTMLAttributes<T>
898
+ export type ImgHTMLAttributes<T = HTMLImageElement> = HTMLAttributes<T>
899
+ export type SVGAttributes<T = SVGElement> = HTMLAttributes<T>