@pyreon/vue-compat 0.13.1 → 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
@@ -29,6 +29,7 @@ import {
29
29
  onUnmount,
30
30
  onUpdate,
31
31
  popContext,
32
+ Portal,
32
33
  pushContext,
33
34
  h as pyreonH,
34
35
  useContext,
@@ -46,8 +47,9 @@ import { getCurrentCtx, getHookIndex } from './jsx-runtime'
46
47
 
47
48
  // ─── Internal symbols ─────────────────────────────────────────────────────────
48
49
 
49
- const V_IS_REF = Symbol('__v_isRef')
50
+ const V_IS_REF = Symbol.for('__v_isRef')
50
51
  const V_IS_READONLY = Symbol('__v_isReadonly')
52
+ const V_SKIP = Symbol('__v_skip')
51
53
  const V_RAW = Symbol('__v_raw')
52
54
 
53
55
  // ─── Ref ──────────────────────────────────────────────────────────────────────
@@ -144,6 +146,16 @@ export function unref<T>(r: T | Ref<T>): T {
144
146
  return isRef(r) ? r.value : r
145
147
  }
146
148
 
149
+ /**
150
+ * Unwraps a ref, calls a getter, or returns the value as-is.
151
+ * Vue 3.3+ API for normalizing ref/getter/value inputs.
152
+ */
153
+ export function toValue<T>(source: Ref<T> | (() => T) | T): T {
154
+ if (isRef(source)) return source.value
155
+ if (typeof source === 'function') return (source as () => T)()
156
+ return source
157
+ }
158
+
147
159
  // ─── Computed ─────────────────────────────────────────────────────────────────
148
160
 
149
161
  export interface ComputedRef<T = unknown> extends Ref<T> {
@@ -225,6 +237,8 @@ const rawMap = new WeakMap<object, object>()
225
237
  * call `scheduleRerender()`.
226
238
  */
227
239
  export function reactive<T extends object>(obj: T): T {
240
+ if ((obj as Record<symbol, boolean>)[V_SKIP]) return obj
241
+
228
242
  const ctx = getCurrentCtx()
229
243
  if (ctx) {
230
244
  const idx = getHookIndex()
@@ -282,12 +296,54 @@ export function readonly<T extends object>(obj: T): Readonly<T> {
282
296
  return _createReadonlyProxy(obj)
283
297
  }
284
298
 
285
- function _createReadonlyProxy<T extends object>(obj: T): Readonly<T> {
299
+ /**
300
+ * Returns a shallow readonly proxy — only top-level properties throw on set.
301
+ * Nested objects are NOT wrapped in readonly (unlike `readonly()`).
302
+ */
303
+ export function shallowReadonly<T extends object>(obj: T): Readonly<T> {
304
+ const ctx = getCurrentCtx()
305
+ if (ctx) {
306
+ const idx = getHookIndex()
307
+ if (idx < ctx.hooks.length) return ctx.hooks[idx] as Readonly<T>
308
+
309
+ const proxy = _createShallowReadonlyProxy(obj)
310
+ ctx.hooks[idx] = proxy
311
+ return proxy
312
+ }
313
+
314
+ return _createShallowReadonlyProxy(obj)
315
+ }
316
+
317
+ function _createShallowReadonlyProxy<T extends object>(obj: T): Readonly<T> {
286
318
  const proxy = new Proxy(obj, {
287
319
  get(target, key) {
288
320
  if (key === V_IS_READONLY) return true
289
321
  if (key === V_RAW) return target
290
322
  return Reflect.get(target, key)
323
+ // NO recursive wrapping — shallow
324
+ },
325
+ set(_target, key) {
326
+ if (key === V_IS_READONLY || key === V_RAW) return true
327
+ throw new Error(`Cannot set property "${String(key)}" on a readonly object`)
328
+ },
329
+ deleteProperty(_target, key) {
330
+ throw new Error(`Cannot delete property "${String(key)}" from a readonly object`)
331
+ },
332
+ })
333
+ return proxy as Readonly<T>
334
+ }
335
+
336
+ function _createReadonlyProxy<T extends object>(obj: T): Readonly<T> {
337
+ const proxy = new Proxy(obj, {
338
+ get(target, key) {
339
+ if (key === V_IS_READONLY) return true
340
+ if (key === V_RAW) return target
341
+ const value = Reflect.get(target, key)
342
+ // Recursively wrap nested objects in readonly
343
+ if (value !== null && typeof value === 'object' && !isRef(value)) {
344
+ return _createReadonlyProxy(value as object)
345
+ }
346
+ return value
291
347
  },
292
348
  set(_target, key) {
293
349
  // Internal symbols used for identification are allowed
@@ -383,20 +439,169 @@ export interface WatchOptions {
383
439
  immediate?: boolean
384
440
  /** Ignored in Pyreon — dependencies are tracked automatically. */
385
441
  deep?: boolean
442
+ /** Accepted for compatibility but not meaningfully differentiated in Pyreon. */
443
+ flush?: 'pre' | 'post' | 'sync'
386
444
  }
387
445
 
388
446
  type WatchSource<T> = Ref<T> | (() => T)
389
447
 
390
448
  /**
391
- * Watches a reactive source and calls `cb` when it changes.
449
+ * Watches a reactive source (or array of sources) and calls `cb` when it changes.
392
450
  *
393
451
  * Inside a component: hook-indexed, created once. Disposed on unmount.
394
452
  */
453
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
454
+ export function watch<T>(
455
+ source: WatchSource<T>,
456
+ cb: (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void,
457
+ options?: WatchOptions,
458
+ ): () => void
459
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
460
+ export function watch<T extends readonly WatchSource<any>[]>(
461
+ sources: [...T],
462
+ cb: (
463
+ newValues: { [K in keyof T]: T[K] extends WatchSource<infer V> ? V : never },
464
+ oldValues: { [K in keyof T]: T[K] extends WatchSource<infer V> ? V | undefined : never },
465
+ onCleanup: (fn: () => void) => void,
466
+ ) => void,
467
+ options?: WatchOptions,
468
+ ): () => void
469
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
395
470
  export function watch<T>(
471
+ source: WatchSource<T> | WatchSource<T>[],
472
+ cb: (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void,
473
+ options?: WatchOptions,
474
+ ): () => void {
475
+ // Array of sources — multi-watch
476
+ if (Array.isArray(source)) {
477
+ return _watchArray(
478
+ source as WatchSource<unknown>[],
479
+ cb as (newValue: unknown, oldValue: unknown) => void,
480
+ options,
481
+ )
482
+ }
483
+ return _watchSingle(source as WatchSource<T>, cb, options)
484
+ }
485
+
486
+ function _watchArray(
487
+ sources: WatchSource<unknown>[],
488
+ cb: (newValues: unknown, oldValues: unknown, onCleanup: (fn: () => void) => void) => void,
489
+ options?: WatchOptions,
490
+ ): () => void {
491
+ const getters = sources.map((s) => (isRef(s) ? () => (s as Ref).value : (s as () => unknown)))
492
+
493
+ let cleanupFn: (() => void) | undefined
494
+ const onCleanup = (fn: () => void) => {
495
+ cleanupFn = fn
496
+ }
497
+
498
+ const runCleanup = () => {
499
+ if (cleanupFn) {
500
+ cleanupFn()
501
+ cleanupFn = undefined
502
+ }
503
+ }
504
+
505
+ const ctx = getCurrentCtx()
506
+ if (ctx) {
507
+ const idx = getHookIndex()
508
+ if (idx < ctx.hooks.length) return ctx.hooks[idx] as () => void
509
+
510
+ let oldValues: unknown[] | undefined
511
+ let initialized = false
512
+
513
+ if (options?.immediate) {
514
+ const current = getters.map((g) => g())
515
+ cb(current, getters.map(() => undefined), onCleanup)
516
+ oldValues = current
517
+ initialized = true
518
+ }
519
+
520
+ let running = false
521
+ const combined = pyreonComputed(() => getters.map((g) => g()))
522
+ const e = effect(() => {
523
+ if (running) return
524
+ running = true
525
+ try {
526
+ const newValues = combined()
527
+ if (initialized) {
528
+ runCleanup()
529
+ cb([...newValues], oldValues ? [...oldValues] : getters.map(() => undefined), onCleanup)
530
+ }
531
+ oldValues = [...newValues]
532
+ initialized = true
533
+ } finally {
534
+ running = false
535
+ }
536
+ })
537
+
538
+ const stop = () => {
539
+ runCleanup()
540
+ e.dispose()
541
+ }
542
+ ctx.hooks[idx] = stop
543
+ ctx.unmountCallbacks.push(stop)
544
+ return stop
545
+ }
546
+
547
+ // Outside component
548
+ let oldValues: unknown[] | undefined
549
+ let initialized = false
550
+
551
+ if (options?.immediate) {
552
+ const current = getters.map((g) => g())
553
+ cb(current, getters.map(() => undefined), onCleanup)
554
+ oldValues = current
555
+ initialized = true
556
+ }
557
+
558
+ let running = false
559
+ const combined = pyreonComputed(() => getters.map((g) => g()))
560
+ const e = effect(() => {
561
+ if (running) return
562
+ running = true
563
+ try {
564
+ const newValues = combined()
565
+ if (initialized) {
566
+ runCleanup()
567
+ cb([...newValues], oldValues ? [...oldValues] : getters.map(() => undefined), onCleanup)
568
+ }
569
+ oldValues = [...newValues]
570
+ initialized = true
571
+ } finally {
572
+ running = false
573
+ }
574
+ })
575
+
576
+ const stop = () => {
577
+ runCleanup()
578
+ e.dispose()
579
+ }
580
+ if (_currentEffectScope) {
581
+ ;(
582
+ _currentEffectScope as EffectScopeCompat & { _cleanups: (() => void)[] }
583
+ )._cleanups.push(stop)
584
+ }
585
+ return stop
586
+ }
587
+
588
+ function _watchSingle<T>(
396
589
  source: WatchSource<T>,
397
- cb: (newValue: T, oldValue: T | undefined) => void,
590
+ cb: (newValue: T, oldValue: T | undefined, onCleanup: (fn: () => void) => void) => void,
398
591
  options?: WatchOptions,
399
592
  ): () => void {
593
+ let cleanupFn: (() => void) | undefined
594
+ const onCleanup = (fn: () => void) => {
595
+ cleanupFn = fn
596
+ }
597
+
598
+ const runCleanup = () => {
599
+ if (cleanupFn) {
600
+ cleanupFn()
601
+ cleanupFn = undefined
602
+ }
603
+ }
604
+
400
605
  const ctx = getCurrentCtx()
401
606
  if (ctx) {
402
607
  const idx = getHookIndex()
@@ -409,7 +614,7 @@ export function watch<T>(
409
614
  if (options?.immediate) {
410
615
  oldValue = undefined
411
616
  const current = getter()
412
- cb(current, oldValue)
617
+ cb(current, oldValue, onCleanup)
413
618
  oldValue = current
414
619
  initialized = true
415
620
  }
@@ -421,7 +626,8 @@ export function watch<T>(
421
626
  try {
422
627
  const newValue = getter()
423
628
  if (initialized) {
424
- cb(newValue, oldValue)
629
+ runCleanup()
630
+ cb(newValue, oldValue, onCleanup)
425
631
  }
426
632
  oldValue = newValue
427
633
  initialized = true
@@ -430,7 +636,10 @@ export function watch<T>(
430
636
  }
431
637
  })
432
638
 
433
- const stop = () => e.dispose()
639
+ const stop = () => {
640
+ runCleanup()
641
+ e.dispose()
642
+ }
434
643
  ctx.hooks[idx] = stop
435
644
  ctx.unmountCallbacks.push(stop)
436
645
  return stop
@@ -444,7 +653,7 @@ export function watch<T>(
444
653
  if (options?.immediate) {
445
654
  oldValue = undefined
446
655
  const current = getter()
447
- cb(current, oldValue)
656
+ cb(current, oldValue, onCleanup)
448
657
  oldValue = current
449
658
  initialized = true
450
659
  }
@@ -456,7 +665,8 @@ export function watch<T>(
456
665
  try {
457
666
  const newValue = getter()
458
667
  if (initialized) {
459
- cb(newValue, oldValue)
668
+ runCleanup()
669
+ cb(newValue, oldValue, onCleanup)
460
670
  }
461
671
  oldValue = newValue
462
672
  initialized = true
@@ -465,17 +675,43 @@ export function watch<T>(
465
675
  }
466
676
  })
467
677
 
468
- return () => e.dispose()
678
+ const stop = () => {
679
+ runCleanup()
680
+ e.dispose()
681
+ }
682
+ if (_currentEffectScope) {
683
+ ;(
684
+ _currentEffectScope as EffectScopeCompat & { _cleanups: (() => void)[] }
685
+ )._cleanups.push(stop)
686
+ }
687
+ return stop
469
688
  }
470
689
 
471
690
  /**
472
691
  * Runs the given function reactively — re-executes whenever its tracked
473
- * dependencies change.
692
+ * dependencies change. Passes an `onCleanup` registration function to the
693
+ * callback, matching Vue 3's `watchEffect((onCleanup) => { ... })` API.
474
694
  *
475
695
  * Inside a component: hook-indexed, created once. Disposed on unmount.
476
696
  */
477
- export function watchEffect(fn: () => void): () => void {
697
+ export function watchEffect(
698
+ fn: (onCleanup: (fn: () => void) => void) => void,
699
+ ): () => void {
478
700
  const ctx = getCurrentCtx()
701
+
702
+ let cleanupFn: (() => void) | undefined
703
+ const onCleanup = (cleanup: () => void) => {
704
+ cleanupFn = cleanup
705
+ }
706
+
707
+ const runEffect = () => {
708
+ if (cleanupFn) {
709
+ cleanupFn()
710
+ cleanupFn = undefined
711
+ }
712
+ fn(onCleanup)
713
+ }
714
+
479
715
  if (ctx) {
480
716
  const idx = getHookIndex()
481
717
  if (idx < ctx.hooks.length) return ctx.hooks[idx] as () => void
@@ -485,12 +721,15 @@ export function watchEffect(fn: () => void): () => void {
485
721
  if (running) return
486
722
  running = true
487
723
  try {
488
- fn()
724
+ runEffect()
489
725
  } finally {
490
726
  running = false
491
727
  }
492
728
  })
493
- const stop = () => e.dispose()
729
+ const stop = () => {
730
+ if (cleanupFn) cleanupFn()
731
+ e.dispose()
732
+ }
494
733
  ctx.hooks[idx] = stop
495
734
  ctx.unmountCallbacks.push(stop)
496
735
  return stop
@@ -501,12 +740,22 @@ export function watchEffect(fn: () => void): () => void {
501
740
  if (running) return
502
741
  running = true
503
742
  try {
504
- fn()
743
+ runEffect()
505
744
  } finally {
506
745
  running = false
507
746
  }
508
747
  })
509
- return () => e.dispose()
748
+ const stop = () => {
749
+ if (cleanupFn) cleanupFn()
750
+ e.dispose()
751
+ }
752
+ // Register with current effect scope if one is active
753
+ if (_currentEffectScope) {
754
+ ;(
755
+ _currentEffectScope as EffectScopeCompat & { _cleanups: (() => void)[] }
756
+ )._cleanups.push(stop)
757
+ }
758
+ return stop
510
759
  }
511
760
 
512
761
  // ─── Lifecycle ────────────────────────────────────────────────────────────────
@@ -641,20 +890,32 @@ export function provide<T>(key: string | symbol, value: T): void {
641
890
 
642
891
  /**
643
892
  * Injects a value provided by an ancestor component.
893
+ * Supports Vue 3's factory default pattern: `inject(key, () => expensiveDefault, true)`.
644
894
  */
645
- export function inject<T>(key: string | symbol, defaultValue?: T): T | undefined {
895
+ export function inject<T>(
896
+ key: string | symbol,
897
+ defaultValue?: T | (() => T),
898
+ treatDefaultAsFactory?: boolean,
899
+ ): T | undefined {
646
900
  const ctx = getOrCreateContext<T>(key)
647
901
  const value = useContext(ctx)
648
- return value !== undefined ? value : defaultValue
902
+ if (value !== undefined) return value
903
+ if (defaultValue === undefined) return undefined
904
+ if (treatDefaultAsFactory && typeof defaultValue === 'function') {
905
+ return (defaultValue as () => T)()
906
+ }
907
+ return defaultValue as T
649
908
  }
650
909
 
651
910
  // ─── defineComponent ──────────────────────────────────────────────────────────
652
911
 
653
912
  interface ComponentOptions<P extends Props = Props> {
654
913
  /** The setup function — called once during component initialization. */
655
- setup: (props: P) => (() => VNodeChild) | VNodeChild
914
+ setup: (props: P, ctx?: SetupContext) => (() => VNodeChild) | VNodeChild
656
915
  /** Optional name for debugging. */
657
916
  name?: string
917
+ /** Prop definitions (not validated at runtime, used for type documentation). */
918
+ props?: Record<string, unknown>
658
919
  }
659
920
 
660
921
  /**
@@ -668,7 +929,21 @@ export function defineComponent<P extends Props = Props>(
668
929
  return options as ComponentFn<P>
669
930
  }
670
931
  const comp = (props: P) => {
671
- const result = options.setup(props)
932
+ // Extract children from props for slots
933
+ const children = (props as Record<string, unknown>).children as VNodeChild | undefined
934
+ // Create a minimal SetupContext
935
+ const setupCtx: SetupContext = {
936
+ emit: (event: string, ...args: unknown[]) => {
937
+ const handlerKey = `on${event.charAt(0).toUpperCase()}${event.slice(1)}`
938
+ const handler = (props as Record<string, unknown>)[handlerKey]
939
+ if (typeof handler === 'function') (handler as (...a: unknown[]) => void)(...args)
940
+ },
941
+ slots: {
942
+ default: children !== undefined ? (() => children) : undefined,
943
+ } as Record<string, (() => VNodeChild) | undefined>,
944
+ attrs: props as Record<string, unknown>,
945
+ }
946
+ const result = options.setup(props, setupCtx)
672
947
  if (typeof result === 'function') {
673
948
  return (result as () => VNodeChild)()
674
949
  }
@@ -680,6 +955,58 @@ export function defineComponent<P extends Props = Props>(
680
955
  return comp as ComponentFn<P>
681
956
  }
682
957
 
958
+ // ─── defineAsyncComponent ───────────────────────────────────────────────────
959
+
960
+ /**
961
+ * Defines an async component that lazily loads on first use.
962
+ * Supports both a bare loader function and an options object with
963
+ * loadingComponent, errorComponent, delay, and timeout.
964
+ *
965
+ * Returns a ComponentFn with a `__loading` property for Suspense integration.
966
+ */
967
+ export function defineAsyncComponent<P extends Props = Props>(
968
+ loader:
969
+ | (() => Promise<{ default: ComponentFn<P> }>)
970
+ | {
971
+ loader: () => Promise<{ default: ComponentFn<P> }>
972
+ loadingComponent?: ComponentFn
973
+ errorComponent?: ComponentFn
974
+ delay?: number
975
+ timeout?: number
976
+ },
977
+ ): ComponentFn<P> & { __loading: () => boolean } {
978
+ const load = typeof loader === 'function' ? loader : loader.loader
979
+
980
+ const loaded = signal<ComponentFn<P> | null>(null)
981
+ const error = signal<Error | null>(null)
982
+ let promise: Promise<unknown> | null = null
983
+
984
+ const startLoad = () => {
985
+ if (promise) return
986
+ promise = load().then(
987
+ (mod) => loaded.set(mod.default),
988
+ (err) => error.set(err instanceof Error ? err : new Error(String(err))),
989
+ )
990
+ }
991
+
992
+ const AsyncComp = ((props: P) => {
993
+ startLoad()
994
+ const err = error()
995
+ if (err) throw err
996
+ const comp = loaded()
997
+ if (!comp) return null
998
+ return comp(props)
999
+ }) as ComponentFn<P> & { __loading: () => boolean }
1000
+
1001
+ AsyncComp.__loading = () => {
1002
+ const isLoading = loaded() === null && error() === null
1003
+ if (isLoading) startLoad()
1004
+ return isLoading
1005
+ }
1006
+
1007
+ return AsyncComp
1008
+ }
1009
+
683
1010
  // ─── h ────────────────────────────────────────────────────────────────────────
684
1011
 
685
1012
  /**
@@ -692,24 +1019,298 @@ export { Fragment, pyreonH as h }
692
1019
  interface App {
693
1020
  /** Mount the application into a DOM element. Returns an unmount function. */
694
1021
  mount(el: string | Element): () => void
1022
+ /** Install a plugin. */
1023
+ use(plugin: { install: (app: App) => void }): App
1024
+ /** Provide a value to the entire app tree. */
1025
+ provide<T>(key: string | symbol, value: T): App
695
1026
  }
696
1027
 
697
1028
  /**
698
1029
  * Creates a Pyreon application instance — Vue 3 `createApp()` compatible.
699
1030
  */
700
1031
  export function createApp(component: ComponentFn, props?: Props): App {
701
- return {
1032
+ const provisions: Array<{ key: string | symbol; value: unknown }> = []
1033
+
1034
+ const app: App = {
702
1035
  mount(el: string | Element): () => void {
703
1036
  const container = typeof el === 'string' ? document.querySelector(el) : el
704
1037
  if (!container) {
705
1038
  throw new Error(`Cannot find mount target: ${el}`)
706
1039
  }
1040
+ // Push app-level provisions before mounting
1041
+ for (const { key, value } of provisions) {
1042
+ const ctx = getOrCreateContext(key)
1043
+ pushContext(new Map([[ctx.id, value]]))
1044
+ }
707
1045
  const vnode = pyreonH(component, props ?? null)
708
1046
  return pyreonMount(vnode, container)
709
1047
  },
1048
+ use(plugin: { install: (app: App) => void }): App {
1049
+ plugin.install(app)
1050
+ return app
1051
+ },
1052
+ provide<T>(key: string | symbol, value: T): App {
1053
+ provisions.push({ key, value })
1054
+ return app
1055
+ },
1056
+ }
1057
+
1058
+ return app
1059
+ }
1060
+
1061
+ // ─── isReactive / isReadonly / isProxy / markRaw ─────────────────────────────
1062
+
1063
+ /**
1064
+ * Returns `true` if the value was created by `reactive()`.
1065
+ */
1066
+ export function isReactive(value: unknown): boolean {
1067
+ return value !== null && typeof value === 'object' && rawMap.has(value as object)
1068
+ }
1069
+
1070
+ /**
1071
+ * Returns `true` if the value was created by `readonly()`.
1072
+ */
1073
+ export function isReadonly(value: unknown): boolean {
1074
+ return (
1075
+ value !== null &&
1076
+ typeof value === 'object' &&
1077
+ (value as Record<symbol, unknown>)[V_IS_READONLY] === true
1078
+ )
1079
+ }
1080
+
1081
+ /**
1082
+ * Returns `true` if the value is either reactive or readonly.
1083
+ */
1084
+ export function isProxy(value: unknown): boolean {
1085
+ return isReactive(value) || isReadonly(value)
1086
+ }
1087
+
1088
+ /**
1089
+ * Marks an object so that `reactive()` will return it as-is (not wrapped).
1090
+ */
1091
+ export function markRaw<T extends object>(obj: T): T {
1092
+ ;(obj as Record<symbol, boolean>)[V_SKIP] = true
1093
+ return obj
1094
+ }
1095
+
1096
+ // ─── effectScope / getCurrentScope / onScopeDispose ──────────────────────────
1097
+
1098
+ export interface EffectScopeCompat {
1099
+ /** Run a function within this scope. Returns undefined if scope is stopped. */
1100
+ run<T>(fn: () => T): T | undefined
1101
+ /** Stop the scope and dispose all collected effects/cleanups. */
1102
+ stop(): void
1103
+ /** Whether the scope is still active. */
1104
+ active: boolean
1105
+ }
1106
+
1107
+ let _currentEffectScope: EffectScopeCompat | null = null
1108
+
1109
+ /**
1110
+ * Creates an effect scope that collects reactive effects for grouped disposal.
1111
+ *
1112
+ * @param detached - If true, the scope is not collected by a parent scope.
1113
+ */
1114
+ export function effectScope(detached?: boolean): EffectScopeCompat {
1115
+ const cleanups: (() => void)[] = []
1116
+ let active = true
1117
+
1118
+ const scope: EffectScopeCompat = {
1119
+ get active() {
1120
+ return active
1121
+ },
1122
+ run<T>(fn: () => T): T | undefined {
1123
+ if (!active) return undefined
1124
+ const prev = _currentEffectScope
1125
+ _currentEffectScope = scope
1126
+ try {
1127
+ return fn()
1128
+ } finally {
1129
+ _currentEffectScope = prev
1130
+ }
1131
+ },
1132
+ stop() {
1133
+ if (!active) return
1134
+ active = false
1135
+ for (const fn of cleanups) fn()
1136
+ cleanups.length = 0
1137
+ },
1138
+ }
1139
+
1140
+ // Auto-collect in parent scope unless detached
1141
+ if (!detached && _currentEffectScope) {
1142
+ const parentCleanups = (_currentEffectScope as EffectScopeCompat & { _cleanups?: (() => void)[] })
1143
+ ._cleanups
1144
+ if (parentCleanups) parentCleanups.push(() => scope.stop())
1145
+ }
1146
+ ;(scope as EffectScopeCompat & { _cleanups: (() => void)[] })._cleanups = cleanups
1147
+
1148
+ return scope
1149
+ }
1150
+
1151
+ /**
1152
+ * Returns the current active effect scope, or undefined if none.
1153
+ */
1154
+ export function getCurrentScope(): EffectScopeCompat | undefined {
1155
+ return _currentEffectScope ?? undefined
1156
+ }
1157
+
1158
+ /**
1159
+ * Registers a cleanup function on the current effect scope.
1160
+ */
1161
+ export function onScopeDispose(fn: () => void): void {
1162
+ if (_currentEffectScope) {
1163
+ ;(
1164
+ _currentEffectScope as EffectScopeCompat & { _cleanups: (() => void)[] }
1165
+ )._cleanups.push(fn)
710
1166
  }
711
1167
  }
712
1168
 
1169
+ // ─── onErrorCaptured / onRenderTracked / onRenderTriggered ───────────────────
1170
+
1171
+ /**
1172
+ * Registers an error capture handler.
1173
+ * No direct equivalent in Pyreon — stored but not actively used.
1174
+ */
1175
+ export function onErrorCaptured(fn: (err: Error) => boolean | void): void {
1176
+ const ctx = getCurrentCtx()
1177
+ if (ctx) {
1178
+ const idx = getHookIndex()
1179
+ if (idx < ctx.hooks.length) return
1180
+ ctx.hooks[idx] = fn
1181
+ }
1182
+ }
1183
+
1184
+ /**
1185
+ * Dev-only lifecycle hook — no-op in Pyreon.
1186
+ */
1187
+ export function onRenderTracked(
1188
+ _fn: (event: { key: string; type: string }) => void,
1189
+ ): void {
1190
+ // Dev-only hook — no equivalent in Pyreon
1191
+ }
1192
+
1193
+ /**
1194
+ * Dev-only lifecycle hook — no-op in Pyreon.
1195
+ */
1196
+ export function onRenderTriggered(
1197
+ _fn: (event: { key: string; type: string }) => void,
1198
+ ): void {
1199
+ // Dev-only hook — no equivalent in Pyreon
1200
+ }
1201
+
1202
+ // ─── Teleport / KeepAlive ────────────────────────────────────────────────────
1203
+
1204
+ /**
1205
+ * Teleport — renders children into a different DOM element.
1206
+ * Maps to Pyreon's Portal.
1207
+ */
1208
+ export function Teleport(props: {
1209
+ to: string | Element
1210
+ children?: VNodeChild
1211
+ }): VNodeChild {
1212
+ const target =
1213
+ typeof props.to === 'string' ? document.querySelector(props.to) : props.to
1214
+ if (!target) return props.children ?? null
1215
+ return Portal({ target, children: props.children ?? null })
1216
+ }
1217
+
1218
+ /**
1219
+ * KeepAlive — not supported in Pyreon. Renders children as-is.
1220
+ */
1221
+ export function KeepAlive(props: { children?: VNodeChild }): VNodeChild {
1222
+ return props.children ?? null
1223
+ }
1224
+
1225
+ // ─── watchPostEffect / watchSyncEffect ───────────────────────────────────────
1226
+
1227
+ /**
1228
+ * Runs a watchEffect that flushes after DOM updates.
1229
+ * In Pyreon, same as `watchEffect()`.
1230
+ */
1231
+ export function watchPostEffect(
1232
+ fn: (onCleanup: (fn: () => void) => void) => void,
1233
+ ): () => void {
1234
+ return watchEffect(fn)
1235
+ }
1236
+
1237
+ /**
1238
+ * Runs a watchEffect that flushes synchronously.
1239
+ * In Pyreon, same as `watchEffect()`.
1240
+ */
1241
+ export function watchSyncEffect(
1242
+ fn: (onCleanup: (fn: () => void) => void) => void,
1243
+ ): () => void {
1244
+ return watchEffect(fn)
1245
+ }
1246
+
1247
+ // ─── customRef ───────────────────────────────────────────────────────────────
1248
+
1249
+ /**
1250
+ * Creates a customized ref with explicit control over dependency tracking
1251
+ * and update triggering.
1252
+ */
1253
+ export function customRef<T>(
1254
+ factory: (
1255
+ track: () => void,
1256
+ trigger: () => void,
1257
+ ) => { get: () => T; set: (v: T) => void },
1258
+ ): Ref<T> {
1259
+ const s = signal(0)
1260
+ const { get, set } = factory(
1261
+ () => { s(); return undefined as never }, // track — reading the signal subscribes
1262
+ () => s.set(s.peek() + 1), // trigger — bump version to re-notify
1263
+ )
1264
+ return {
1265
+ [V_IS_REF]: true as const,
1266
+ get value(): T {
1267
+ return get()
1268
+ },
1269
+ set value(v: T) {
1270
+ set(v)
1271
+ },
1272
+ } as Ref<T>
1273
+ }
1274
+
1275
+ // ─── version ─────────────────────────────────────────────────────────────────
1276
+
1277
+ /**
1278
+ * Compatibility version string — indicates Vue 3 API compatibility.
1279
+ */
1280
+ export const version = '3.5.0-pyreon'
1281
+
1282
+ // ─── Type exports ────────────────────────────────────────────────────────────
1283
+
1284
+ export type { ComponentFn as Component } from '@pyreon/core'
1285
+ export type { VNodeChild as VNode } from '@pyreon/core'
1286
+
1287
+ /** Vue-compatible PropType — a callable that returns T. */
1288
+ export type PropType<T> = { (): T }
1289
+
1290
+ /** Extract prop types from a component's props definition. */
1291
+ export type ExtractPropTypes<T> = {
1292
+ [K in keyof T]: T[K] extends PropType<infer V> ? V : T[K]
1293
+ }
1294
+
1295
+ /** Vue-compatible emits options type. */
1296
+ export type EmitsOptions = Record<string, (...args: unknown[]) => void>
1297
+
1298
+ /** Vue-compatible setup context. */
1299
+ export type SetupContext = {
1300
+ emit: (event: string, ...args: unknown[]) => void
1301
+ slots: Record<string, (() => VNodeChild) | undefined>
1302
+ attrs: Record<string, unknown>
1303
+ }
1304
+
1305
+ /** Vue-compatible plugin interface. */
1306
+ export type Plugin = { install: (app: App) => void }
1307
+
1308
+ /** Vue-compatible directive type (stub). */
1309
+ export type Directive = Record<string, unknown>
1310
+
1311
+ /** Vue-compatible injection key with type branding. */
1312
+ export type InjectionKey<T> = symbol & { __type: T }
1313
+
713
1314
  // ─── Additional re-exports ────────────────────────────────────────────────────
714
1315
 
715
1316
  export { batch } from '@pyreon/reactivity'