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