@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.
@@ -0,0 +1,1303 @@
1
+ import type { ComponentFn } from '@pyreon/core'
2
+ import { mount } from '@pyreon/runtime-dom'
3
+ import {
4
+ createApp,
5
+ customRef,
6
+ defineAsyncComponent,
7
+ defineComponent,
8
+ effectScope,
9
+ getCurrentScope,
10
+ h,
11
+ inject,
12
+ isProxy,
13
+ isReactive,
14
+ isReadonly,
15
+ isRef,
16
+ KeepAlive,
17
+ markRaw,
18
+ onErrorCaptured,
19
+ onRenderTracked,
20
+ onRenderTriggered,
21
+ onScopeDispose,
22
+ provide,
23
+ reactive,
24
+ readonly,
25
+ ref,
26
+ shallowReadonly,
27
+ Teleport,
28
+ toValue,
29
+ version,
30
+ watch,
31
+ watchEffect,
32
+ watchPostEffect,
33
+ watchSyncEffect,
34
+ } from '../index'
35
+ import {
36
+ beginRender,
37
+ endRender,
38
+ type RenderContext,
39
+ } from '../jsx-runtime'
40
+
41
+ // ─── Test helpers ──────────────────────────────────────────────────────────────
42
+
43
+ function container(): HTMLElement {
44
+ const el = document.createElement('div')
45
+ document.body.appendChild(el)
46
+ return el
47
+ }
48
+
49
+ function withHookCtx<T>(fn: (ctx: RenderContext) => T): { result: T; ctx: RenderContext } {
50
+ const ctx: RenderContext = {
51
+ hooks: [],
52
+ scheduleRerender: () => {},
53
+ pendingEffects: [],
54
+ pendingLayoutEffects: [],
55
+ unmounted: false,
56
+ unmountCallbacks: [],
57
+ }
58
+ beginRender(ctx)
59
+ const result = fn(ctx)
60
+ endRender()
61
+ return { result, ctx }
62
+ }
63
+
64
+ describe('readonly() deep nesting', () => {
65
+ it('prevents mutation on nested objects', () => {
66
+ const ro = readonly({ nested: { x: 1 } })
67
+ expect(ro.nested.x).toBe(1)
68
+ expect(() => {
69
+ ;(ro.nested as { x: number }).x = 2
70
+ }).toThrow('readonly')
71
+ })
72
+
73
+ it('prevents mutation on deeply nested objects', () => {
74
+ const ro = readonly({ a: { b: { c: 3 } } })
75
+ expect(ro.a.b.c).toBe(3)
76
+ expect(() => {
77
+ ;(ro.a.b as { c: number }).c = 99
78
+ }).toThrow('readonly')
79
+ })
80
+
81
+ it('prevents delete on nested objects', () => {
82
+ const ro = readonly({ nested: { x: 1 } }) as Record<string, Record<string, unknown>>
83
+ expect(() => {
84
+ delete ro.nested!.x
85
+ }).toThrow('Cannot delete')
86
+ })
87
+
88
+ it('does not wrap ref values in readonly recursively', () => {
89
+ const r = ref(42)
90
+ const ro = readonly({ myRef: r })
91
+ // Accessing the ref should return the ref itself, not a readonly proxy of it
92
+ expect(isRef(ro.myRef)).toBe(true)
93
+ expect(ro.myRef.value).toBe(42)
94
+ })
95
+
96
+ it('does not wrap null or non-object values', () => {
97
+ const ro = readonly({ x: null, y: 5, z: 'hello' })
98
+ expect(ro.x).toBe(null)
99
+ expect(ro.y).toBe(5)
100
+ expect(ro.z).toBe('hello')
101
+ })
102
+
103
+ it('nested readonly reports isReadonly', () => {
104
+ const ro = readonly({ nested: { x: 1 } })
105
+ expect(isReadonly(ro)).toBe(true)
106
+ expect(isReadonly(ro.nested)).toBe(true)
107
+ })
108
+
109
+ it('readonly arrays are immutable', () => {
110
+ const ro = readonly({ items: [1, 2, 3] })
111
+ expect(ro.items[0]).toBe(1)
112
+ expect(() => {
113
+ ;(ro.items as number[])[0] = 99
114
+ }).toThrow('readonly')
115
+ expect(() => {
116
+ ;(ro.items as number[]).push(4)
117
+ }).toThrow('readonly')
118
+ })
119
+ })
120
+
121
+ describe('isReactive()', () => {
122
+ it('returns true for reactive objects', () => {
123
+ const state = reactive({ count: 0 })
124
+ expect(isReactive(state)).toBe(true)
125
+ })
126
+
127
+ it('returns false for plain objects', () => {
128
+ expect(isReactive({ a: 1 })).toBe(false)
129
+ })
130
+
131
+ it('returns false for primitives', () => {
132
+ expect(isReactive(null)).toBe(false)
133
+ expect(isReactive(undefined)).toBe(false)
134
+ expect(isReactive(42)).toBe(false)
135
+ expect(isReactive('hello')).toBe(false)
136
+ })
137
+
138
+ it('returns false for refs', () => {
139
+ const r = ref(0)
140
+ expect(isReactive(r)).toBe(false)
141
+ })
142
+
143
+ it('returns false for readonly objects', () => {
144
+ const ro = readonly({ x: 1 })
145
+ expect(isReactive(ro)).toBe(false)
146
+ })
147
+ })
148
+
149
+ describe('isReadonly()', () => {
150
+ it('returns true for readonly objects', () => {
151
+ const ro = readonly({ x: 1 })
152
+ expect(isReadonly(ro)).toBe(true)
153
+ })
154
+
155
+ it('returns false for reactive objects', () => {
156
+ const state = reactive({ x: 1 })
157
+ expect(isReadonly(state)).toBe(false)
158
+ })
159
+
160
+ it('returns false for plain objects', () => {
161
+ expect(isReadonly({ x: 1 })).toBe(false)
162
+ })
163
+
164
+ it('returns false for primitives', () => {
165
+ expect(isReadonly(null)).toBe(false)
166
+ expect(isReadonly(undefined)).toBe(false)
167
+ expect(isReadonly(42)).toBe(false)
168
+ })
169
+ })
170
+
171
+ describe('isProxy()', () => {
172
+ it('returns true for reactive objects', () => {
173
+ expect(isProxy(reactive({ x: 1 }))).toBe(true)
174
+ })
175
+
176
+ it('returns true for readonly objects', () => {
177
+ expect(isProxy(readonly({ x: 1 }))).toBe(true)
178
+ })
179
+
180
+ it('returns false for plain objects', () => {
181
+ expect(isProxy({ x: 1 })).toBe(false)
182
+ })
183
+
184
+ it('returns false for refs', () => {
185
+ expect(isProxy(ref(0))).toBe(false)
186
+ })
187
+
188
+ it('returns false for primitives', () => {
189
+ expect(isProxy(null)).toBe(false)
190
+ expect(isProxy(42)).toBe(false)
191
+ })
192
+ })
193
+
194
+ describe('markRaw()', () => {
195
+ it('prevents reactive wrapping', () => {
196
+ const raw = markRaw({ count: 0 })
197
+ const result = reactive(raw)
198
+ // Should return the same object — not wrapped
199
+ expect(result).toBe(raw)
200
+ })
201
+
202
+ it('returns the same object', () => {
203
+ const obj = { a: 1 }
204
+ expect(markRaw(obj)).toBe(obj)
205
+ })
206
+
207
+ it('marked object is not reactive', () => {
208
+ const raw = markRaw({ x: 1 })
209
+ const result = reactive(raw)
210
+ expect(isReactive(result)).toBe(false)
211
+ })
212
+ })
213
+
214
+ describe('effectScope()', () => {
215
+ it('collects and disposes effects', () => {
216
+ const scope = effectScope()
217
+ let runs = 0
218
+ const count = ref(0)
219
+
220
+ scope.run(() => {
221
+ watchEffect(() => {
222
+ void count.value
223
+ runs++
224
+ })
225
+ })
226
+
227
+ expect(runs).toBe(1)
228
+ count.value = 1
229
+ expect(runs).toBe(2)
230
+
231
+ scope.stop()
232
+ count.value = 2
233
+ expect(runs).toBe(2) // Should not run after stop
234
+ })
235
+
236
+ it('run returns the function result', () => {
237
+ const scope = effectScope()
238
+ const result = scope.run(() => 42)
239
+ expect(result).toBe(42)
240
+ scope.stop()
241
+ })
242
+
243
+ it('run returns undefined after stop', () => {
244
+ const scope = effectScope()
245
+ scope.stop()
246
+ const result = scope.run(() => 42)
247
+ expect(result).toBeUndefined()
248
+ })
249
+
250
+ it('active is true until stopped', () => {
251
+ const scope = effectScope()
252
+ expect(scope.active).toBe(true)
253
+ scope.stop()
254
+ expect(scope.active).toBe(false)
255
+ })
256
+
257
+ it('stop is idempotent', () => {
258
+ const scope = effectScope()
259
+ scope.stop()
260
+ expect(() => scope.stop()).not.toThrow()
261
+ })
262
+
263
+ it('nested scopes are collected by parent', () => {
264
+ const parent = effectScope()
265
+ let childStopped = false
266
+
267
+ parent.run(() => {
268
+ const child = effectScope()
269
+ child.run(() => {
270
+ onScopeDispose(() => {
271
+ childStopped = true
272
+ })
273
+ })
274
+ })
275
+
276
+ parent.stop()
277
+ expect(childStopped).toBe(true)
278
+ })
279
+
280
+ it('detached scopes are not collected by parent', () => {
281
+ const parent = effectScope()
282
+ let childStopped = false
283
+ let child: ReturnType<typeof effectScope> | undefined
284
+
285
+ parent.run(() => {
286
+ child = effectScope(true) // detached
287
+ child.run(() => {
288
+ onScopeDispose(() => {
289
+ childStopped = true
290
+ })
291
+ })
292
+ })
293
+
294
+ parent.stop()
295
+ expect(childStopped).toBe(false)
296
+ child!.stop()
297
+ expect(childStopped).toBe(true)
298
+ })
299
+ })
300
+
301
+ describe('getCurrentScope()', () => {
302
+ it('returns undefined outside of scope', () => {
303
+ expect(getCurrentScope()).toBeUndefined()
304
+ })
305
+
306
+ it('returns current scope inside run', () => {
307
+ const scope = effectScope()
308
+ let captured: ReturnType<typeof getCurrentScope>
309
+
310
+ scope.run(() => {
311
+ captured = getCurrentScope()
312
+ })
313
+
314
+ expect(captured!).toBe(scope)
315
+ scope.stop()
316
+ })
317
+
318
+ it('returns undefined after scope run completes', () => {
319
+ const scope = effectScope()
320
+ scope.run(() => {})
321
+ expect(getCurrentScope()).toBeUndefined()
322
+ scope.stop()
323
+ })
324
+ })
325
+
326
+ describe('onScopeDispose()', () => {
327
+ it('registers cleanup on current scope', () => {
328
+ const scope = effectScope()
329
+ let disposed = false
330
+
331
+ scope.run(() => {
332
+ onScopeDispose(() => {
333
+ disposed = true
334
+ })
335
+ })
336
+
337
+ expect(disposed).toBe(false)
338
+ scope.stop()
339
+ expect(disposed).toBe(true)
340
+ })
341
+
342
+ it('does nothing outside of scope', () => {
343
+ // Should not throw
344
+ expect(() => onScopeDispose(() => {})).not.toThrow()
345
+ })
346
+
347
+ it('multiple disposers are all called', () => {
348
+ const scope = effectScope()
349
+ const calls: number[] = []
350
+
351
+ scope.run(() => {
352
+ onScopeDispose(() => calls.push(1))
353
+ onScopeDispose(() => calls.push(2))
354
+ onScopeDispose(() => calls.push(3))
355
+ })
356
+
357
+ scope.stop()
358
+ expect(calls).toEqual([1, 2, 3])
359
+ })
360
+ })
361
+
362
+ describe('watch() with array source', () => {
363
+ it('watches multiple refs', () => {
364
+ const a = ref(1)
365
+ const b = ref('hello')
366
+ const calls: Array<[unknown[], unknown[]]> = []
367
+
368
+ const stop = watch([a, b] as const, (newVals, oldVals) => {
369
+ calls.push([newVals as unknown[], oldVals as unknown[]])
370
+ })
371
+
372
+ a.value = 2
373
+ expect(calls.length).toBeGreaterThanOrEqual(1)
374
+ expect(calls[calls.length - 1]![0]).toEqual([2, 'hello'])
375
+
376
+ b.value = 'world'
377
+ expect(calls[calls.length - 1]![0]).toEqual([2, 'world'])
378
+
379
+ stop()
380
+ })
381
+
382
+ it('watches array with immediate', () => {
383
+ const a = ref(1)
384
+ const b = ref(2)
385
+ const calls: unknown[][] = []
386
+
387
+ const stop = watch(
388
+ [a, b],
389
+ (newVals) => {
390
+ calls.push(newVals as unknown[])
391
+ },
392
+ { immediate: true },
393
+ )
394
+
395
+ expect(calls[0]).toEqual([1, 2])
396
+ stop()
397
+ })
398
+
399
+ it('watches array of getter functions', () => {
400
+ const count = ref(0)
401
+ const calls: unknown[][] = []
402
+
403
+ const stop = watch(
404
+ [() => count.value, () => count.value * 2],
405
+ (newVals) => {
406
+ calls.push(newVals as unknown[])
407
+ },
408
+ )
409
+
410
+ count.value = 5
411
+ expect(calls[calls.length - 1]).toEqual([5, 10])
412
+ stop()
413
+ })
414
+
415
+ it('stop disposes array watcher', () => {
416
+ const a = ref(1)
417
+ const b = ref(2)
418
+ let callCount = 0
419
+
420
+ const stop = watch([a, b], () => {
421
+ callCount++
422
+ })
423
+
424
+ a.value = 10
425
+ const countAfterChange = callCount
426
+
427
+ stop()
428
+ a.value = 20
429
+ expect(callCount).toBe(countAfterChange)
430
+ })
431
+
432
+ it('array watch is hook-indexed inside component', () => {
433
+ const a = ref(0)
434
+ const b = ref(0)
435
+ const ctx: RenderContext = {
436
+ hooks: [],
437
+ scheduleRerender: () => {},
438
+ pendingEffects: [],
439
+ pendingLayoutEffects: [],
440
+ unmounted: false,
441
+ unmountCallbacks: [],
442
+ }
443
+
444
+ beginRender(ctx)
445
+ const stop1 = watch([a, b], () => {})
446
+ endRender()
447
+
448
+ beginRender(ctx)
449
+ const stop2 = watch([a, b], () => {})
450
+ endRender()
451
+
452
+ expect(stop1).toBe(stop2)
453
+ stop1()
454
+ })
455
+ })
456
+
457
+ describe('onErrorCaptured()', () => {
458
+ it('is callable and stores handler in hook context', () => {
459
+ const { ctx } = withHookCtx(() => {
460
+ onErrorCaptured((_err) => true)
461
+ })
462
+ expect(ctx.hooks.length).toBe(1)
463
+ expect(typeof ctx.hooks[0]).toBe('function')
464
+ })
465
+
466
+ it('is idempotent on re-render', () => {
467
+ const ctx: RenderContext = {
468
+ hooks: [],
469
+ scheduleRerender: () => {},
470
+ pendingEffects: [],
471
+ pendingLayoutEffects: [],
472
+ unmounted: false,
473
+ unmountCallbacks: [],
474
+ }
475
+
476
+ beginRender(ctx)
477
+ onErrorCaptured(() => true)
478
+ endRender()
479
+
480
+ const hooksBefore = ctx.hooks.length
481
+
482
+ beginRender(ctx)
483
+ onErrorCaptured(() => false) // Different fn, should not overwrite
484
+ endRender()
485
+
486
+ expect(ctx.hooks.length).toBe(hooksBefore)
487
+ })
488
+
489
+ it('is a no-op outside component', () => {
490
+ // Should not throw
491
+ expect(() => onErrorCaptured(() => true)).not.toThrow()
492
+ })
493
+ })
494
+
495
+ describe('onRenderTracked()', () => {
496
+ it('is callable (no-op)', () => {
497
+ expect(() => onRenderTracked(() => {})).not.toThrow()
498
+ })
499
+ })
500
+
501
+ describe('onRenderTriggered()', () => {
502
+ it('is callable (no-op)', () => {
503
+ expect(() => onRenderTriggered(() => {})).not.toThrow()
504
+ })
505
+ })
506
+
507
+ describe('Teleport', () => {
508
+ it('renders children into target element', () => {
509
+ const target = document.createElement('div')
510
+ target.id = 'teleport-target'
511
+ document.body.appendChild(target)
512
+
513
+ const el = container()
514
+ const vnode = h(
515
+ Teleport as ComponentFn,
516
+ { to: target } as Record<string, unknown>,
517
+ h('span', null, 'teleported'),
518
+ )
519
+ const unmount = mount(vnode, el)
520
+
521
+ // Portal should have rendered children
522
+ expect(target.textContent).toBe('teleported')
523
+ unmount()
524
+ target.remove()
525
+ })
526
+
527
+ it('renders children into target via string selector', () => {
528
+ const target = document.createElement('div')
529
+ target.id = 'teleport-string-target'
530
+ document.body.appendChild(target)
531
+
532
+ const result = Teleport({ to: '#teleport-string-target', children: 'hello' })
533
+ // Should return a Portal VNode (not null)
534
+ expect(result).not.toBeNull()
535
+
536
+ target.remove()
537
+ })
538
+
539
+ it('returns children when target is not found', () => {
540
+ const result = Teleport({ to: '#nonexistent-target', children: 'fallback' })
541
+ expect(result).toBe('fallback')
542
+ })
543
+
544
+ it('returns null when no children and no target', () => {
545
+ const result = Teleport({ to: '#nonexistent-target' })
546
+ expect(result).toBeNull()
547
+ })
548
+ })
549
+
550
+ describe('KeepAlive', () => {
551
+ it('passes through children', () => {
552
+ const result = KeepAlive({ children: 'hello' })
553
+ expect(result).toBe('hello')
554
+ })
555
+
556
+ it('returns null without children', () => {
557
+ const result = KeepAlive({})
558
+ expect(result).toBeNull()
559
+ })
560
+ })
561
+
562
+ describe('watchPostEffect()', () => {
563
+ it('works like watchEffect', () => {
564
+ const count = ref(0)
565
+ const values: number[] = []
566
+
567
+ const stop = watchPostEffect(() => {
568
+ values.push(count.value)
569
+ })
570
+
571
+ count.value = 1
572
+ expect(values).toEqual([0, 1])
573
+ stop()
574
+ })
575
+ })
576
+
577
+ describe('watchSyncEffect()', () => {
578
+ it('works like watchEffect', () => {
579
+ const count = ref(0)
580
+ const values: number[] = []
581
+
582
+ const stop = watchSyncEffect(() => {
583
+ values.push(count.value)
584
+ })
585
+
586
+ count.value = 1
587
+ expect(values).toEqual([0, 1])
588
+ stop()
589
+ })
590
+ })
591
+
592
+ describe('customRef()', () => {
593
+ it('creates a ref with custom get/set', () => {
594
+ const r = customRef((track, trigger) => {
595
+ let value = 0
596
+ return {
597
+ get() {
598
+ track()
599
+ return value
600
+ },
601
+ set(v: number) {
602
+ value = v
603
+ trigger()
604
+ },
605
+ }
606
+ })
607
+
608
+ expect(isRef(r)).toBe(true)
609
+ expect(r.value).toBe(0)
610
+
611
+ r.value = 42
612
+ expect(r.value).toBe(42)
613
+ })
614
+
615
+ it('customRef integrates with watchEffect', () => {
616
+ const r = customRef((track, trigger) => {
617
+ let value = 'initial'
618
+ return {
619
+ get() {
620
+ track()
621
+ return value
622
+ },
623
+ set(v: string) {
624
+ value = v
625
+ trigger()
626
+ },
627
+ }
628
+ })
629
+
630
+ const values: string[] = []
631
+ const stop = watchEffect(() => {
632
+ values.push(r.value)
633
+ })
634
+
635
+ r.value = 'updated'
636
+ expect(values).toContain('updated')
637
+ stop()
638
+ })
639
+
640
+ it('customRef with debounce pattern', () => {
641
+ let triggerFn: () => void
642
+ const r = customRef((track, trigger) => {
643
+ triggerFn = trigger
644
+ let value = 0
645
+ return {
646
+ get() {
647
+ track()
648
+ return value
649
+ },
650
+ set(v: number) {
651
+ value = v
652
+ // Don't trigger immediately — simulate debounce
653
+ },
654
+ }
655
+ })
656
+
657
+ r.value = 10
658
+ expect(r.value).toBe(10) // Value is set
659
+
660
+ // Manually trigger
661
+ const values: number[] = []
662
+ const stop = watchEffect(() => {
663
+ values.push(r.value)
664
+ })
665
+
666
+ triggerFn!()
667
+ expect(values.length).toBeGreaterThanOrEqual(1)
668
+ stop()
669
+ })
670
+ })
671
+
672
+ describe('version', () => {
673
+ it('is a string starting with 3', () => {
674
+ expect(typeof version).toBe('string')
675
+ expect(version).toMatch(/^3\./)
676
+ })
677
+
678
+ it('contains pyreon identifier', () => {
679
+ expect(version).toContain('pyreon')
680
+ })
681
+ })
682
+
683
+ describe('createApp().use()', () => {
684
+ it('installs a plugin', () => {
685
+ let installed = false
686
+ const plugin = {
687
+ install(_app: { mount: Function; use: Function; provide: Function }) {
688
+ installed = true
689
+ },
690
+ }
691
+
692
+ const Comp = () => h('div', null, 'app')
693
+ const app = createApp(Comp)
694
+ app.use(plugin)
695
+
696
+ expect(installed).toBe(true)
697
+ })
698
+
699
+ it('returns app for chaining', () => {
700
+ const plugin = { install() {} }
701
+ const Comp = () => h('div', null, 'app')
702
+ const app = createApp(Comp)
703
+ const result = app.use(plugin)
704
+ expect(result).toBe(app)
705
+ })
706
+
707
+ it('chains multiple plugins', () => {
708
+ const installed: string[] = []
709
+ const plugin1 = { install() { installed.push('p1') } }
710
+ const plugin2 = { install() { installed.push('p2') } }
711
+
712
+ const Comp = () => h('div', null, 'app')
713
+ createApp(Comp).use(plugin1).use(plugin2)
714
+
715
+ expect(installed).toEqual(['p1', 'p2'])
716
+ })
717
+ })
718
+
719
+ describe('createApp().provide()', () => {
720
+ it('returns app for chaining', () => {
721
+ const Comp = () => h('div', null, 'app')
722
+ const app = createApp(Comp)
723
+ const result = app.provide('key', 'value')
724
+ expect(result).toBe(app)
725
+ })
726
+
727
+ it('provides value accessible via inject after mount', () => {
728
+ const key = Symbol('app-provide-test')
729
+ let injectedValue: string | undefined
730
+
731
+ const Comp = (() => {
732
+ injectedValue = inject(key, 'default') as string
733
+ return h('div', null, 'app')
734
+ }) as ComponentFn
735
+
736
+ const el = container()
737
+ const app = createApp(Comp)
738
+ app.provide(key, 'provided-value')
739
+ const unmount = app.mount(el)
740
+
741
+ expect(injectedValue).toBe('provided-value')
742
+ unmount()
743
+ })
744
+
745
+ it('chains provide and use', () => {
746
+ let installed = false
747
+ const plugin = { install() { installed = true } }
748
+ const Comp = () => h('div', null, 'app')
749
+
750
+ createApp(Comp)
751
+ .provide('key', 'value')
752
+ .use(plugin)
753
+
754
+ expect(installed).toBe(true)
755
+ })
756
+ })
757
+
758
+ describe('toValue()', () => {
759
+ it('unwraps a ref', () => {
760
+ const r = ref(42)
761
+ expect(toValue(r)).toBe(42)
762
+ })
763
+
764
+ it('calls a getter function', () => {
765
+ const getter = () => 'hello'
766
+ expect(toValue(getter)).toBe('hello')
767
+ })
768
+
769
+ it('returns a plain value as-is', () => {
770
+ expect(toValue(42)).toBe(42)
771
+ expect(toValue('str')).toBe('str')
772
+ expect(toValue(null)).toBe(null)
773
+ expect(toValue(undefined)).toBe(undefined)
774
+ })
775
+
776
+ it('prefers ref over function (ref with value)', () => {
777
+ const r = ref(99)
778
+ expect(toValue(r)).toBe(99)
779
+ r.value = 100
780
+ expect(toValue(r)).toBe(100)
781
+ })
782
+ })
783
+
784
+ describe('inject() with factory default', () => {
785
+ it('calls factory when treatDefaultAsFactory is true', () => {
786
+ let factoryCalls = 0
787
+ const key = Symbol('factory-test')
788
+ const result = inject(key, () => {
789
+ factoryCalls++
790
+ return 'from-factory'
791
+ }, true)
792
+ expect(result).toBe('from-factory')
793
+ expect(factoryCalls).toBe(1)
794
+ })
795
+
796
+ it('does not call factory when treatDefaultAsFactory is false', () => {
797
+ const key = Symbol('no-factory-test')
798
+ const factory = () => 'from-factory'
799
+ const result = inject(key, factory, false)
800
+ expect(result).toBe(factory) // returns the function itself
801
+ })
802
+
803
+ it('does not call factory when value is provided', () => {
804
+ const key = Symbol('provided-factory-test')
805
+ let factoryCalls = 0
806
+
807
+ const el = container()
808
+ let injectedValue: unknown
809
+
810
+ const Provider = (() => {
811
+ provide(key, 'provided')
812
+ const Child = (() => {
813
+ injectedValue = inject(key, () => {
814
+ factoryCalls++
815
+ return 'from-factory'
816
+ }, true)
817
+ return h('span', null, 'child')
818
+ }) as ComponentFn
819
+ return h(Child, null)
820
+ }) as ComponentFn
821
+
822
+ const unmount = mount(h(Provider, null), el)
823
+ expect(injectedValue).toBe('provided')
824
+ expect(factoryCalls).toBe(0)
825
+ unmount()
826
+ })
827
+
828
+ it('returns undefined when no default and no provider', () => {
829
+ const key = Symbol('no-default-test')
830
+ const result = inject(key)
831
+ expect(result).toBeUndefined()
832
+ })
833
+ })
834
+
835
+ describe('shallowReadonly()', () => {
836
+ it('prevents mutation on top-level properties', () => {
837
+ const ro = shallowReadonly({ x: 1, nested: { y: 2 } })
838
+ expect(ro.x).toBe(1)
839
+ expect(() => {
840
+ ;(ro as { x: number }).x = 2
841
+ }).toThrow('readonly')
842
+ })
843
+
844
+ it('allows mutation on nested objects', () => {
845
+ const ro = shallowReadonly({ nested: { y: 2 } })
846
+ // Nested objects are NOT wrapped — mutation is allowed
847
+ expect(() => {
848
+ ;(ro.nested as { y: number }).y = 99
849
+ }).not.toThrow()
850
+ expect(ro.nested.y).toBe(99)
851
+ })
852
+
853
+ it('prevents delete on top-level', () => {
854
+ const ro = shallowReadonly({ x: 1 }) as Record<string, unknown>
855
+ expect(() => {
856
+ delete ro.x
857
+ }).toThrow('Cannot delete')
858
+ })
859
+
860
+ it('reports isReadonly', () => {
861
+ const ro = shallowReadonly({ x: 1 })
862
+ expect(isReadonly(ro)).toBe(true)
863
+ })
864
+
865
+ it('nested does NOT report isReadonly (shallow)', () => {
866
+ const ro = shallowReadonly({ nested: { x: 1 } })
867
+ expect(isReadonly(ro.nested)).toBe(false)
868
+ })
869
+ })
870
+
871
+ describe('defineComponent() with setup context', () => {
872
+ it('passes SetupContext with emit to setup', () => {
873
+ let emittedArgs: unknown[] = []
874
+ const Comp = defineComponent({
875
+ setup(_props, ctx) {
876
+ ctx!.emit('click', 'arg1', 'arg2')
877
+ return () => h('div', null, 'test')
878
+ },
879
+ })
880
+
881
+ const el = container()
882
+ const unmount = mount(
883
+ h(Comp as ComponentFn, {
884
+ onClick: (...args: unknown[]) => {
885
+ emittedArgs = args
886
+ },
887
+ }),
888
+ el,
889
+ )
890
+
891
+ expect(emittedArgs).toEqual(['arg1', 'arg2'])
892
+ unmount()
893
+ })
894
+
895
+ it('accepts name option', () => {
896
+ const Comp = defineComponent({
897
+ name: 'MyComponent',
898
+ setup() {
899
+ return () => h('div', null, 'named')
900
+ },
901
+ })
902
+
903
+ expect(Comp.name).toBe('MyComponent')
904
+ })
905
+
906
+ it('accepts props option (for documentation)', () => {
907
+ const Comp = defineComponent({
908
+ props: {
909
+ title: { type: String, required: true },
910
+ },
911
+ setup(props) {
912
+ return () => h('div', null, (props as Record<string, unknown>).title as string)
913
+ },
914
+ })
915
+
916
+ const el = container()
917
+ const unmount = mount(h(Comp as ComponentFn, { title: 'Hello' }), el)
918
+ expect(el.textContent).toBe('Hello')
919
+ unmount()
920
+ })
921
+
922
+ it('still accepts function shorthand', () => {
923
+ const Comp = defineComponent((props: { msg: string }) => {
924
+ return h('span', null, props.msg)
925
+ })
926
+
927
+ const el = container()
928
+ const unmount = mount(h(Comp as ComponentFn, { msg: 'hi' }), el)
929
+ expect(el.textContent).toBe('hi')
930
+ unmount()
931
+ })
932
+ })
933
+
934
+ describe('template ref with Vue ref', () => {
935
+ it('converts Vue ref to callback ref for DOM elements', async () => {
936
+ const { jsx: jsxFn } = await import('../jsx-runtime')
937
+ const elRef = ref<HTMLDivElement | null>(null)
938
+
939
+ // Simulate JSX runtime creating a DOM element with a Vue ref
940
+ const vnode = jsxFn('div', { ref: elRef, children: 'hello' })
941
+
942
+ // The ref prop should have been converted to a callback function
943
+ expect(typeof vnode.props.ref).toBe('function')
944
+
945
+ // Calling the callback ref should set the Vue ref's value
946
+ const div = document.createElement('div')
947
+ ;(vnode.props.ref as (el: Element | null) => void)(div)
948
+ expect(elRef.value).toBe(div)
949
+
950
+ // Null on unmount
951
+ ;(vnode.props.ref as (el: Element | null) => void)(null)
952
+ expect(elRef.value).toBeNull()
953
+ })
954
+
955
+ it('callback ref still works unchanged', async () => {
956
+ const { jsx: jsxFn } = await import('../jsx-runtime')
957
+ const cbRef = (el: Element | null) => { void el }
958
+
959
+ const vnode = jsxFn('div', { ref: cbRef, children: 'hello' })
960
+
961
+ // Callback ref should pass through unchanged
962
+ expect(vnode.props.ref).toBe(cbRef)
963
+ })
964
+
965
+ it('does not convert refs on component types', async () => {
966
+ const { jsx: jsxFn } = await import('../jsx-runtime')
967
+ const elRef = ref<unknown>(null)
968
+ const MyComp = () => h('div', null, 'comp')
969
+
970
+ const vnode = jsxFn(MyComp, { ref: elRef, children: 'hello' })
971
+
972
+ // Component refs should NOT be converted (they go through wrapCompatComponent)
973
+ // The ref should be on the component props
974
+ expect(vnode.props).toBeDefined()
975
+ })
976
+ })
977
+
978
+ describe('watch() flush option', () => {
979
+ it('accepts flush option without error', () => {
980
+ const count = ref(0)
981
+ const values: number[] = []
982
+
983
+ const stop = watch(count, (v) => {
984
+ values.push(v)
985
+ }, { flush: 'post' })
986
+
987
+ count.value = 1
988
+ expect(values).toContain(1)
989
+ stop()
990
+ })
991
+
992
+ it('accepts flush sync option', () => {
993
+ const count = ref(0)
994
+ const stop = watch(count, () => {}, { flush: 'sync' })
995
+ stop()
996
+ })
997
+ })
998
+
999
+ describe('customRef() trigger forces update', () => {
1000
+ it('trigger causes watchEffect to re-run even without value change', () => {
1001
+ let triggerFn: () => void
1002
+ const r = customRef((track, trigger) => {
1003
+ triggerFn = trigger
1004
+ const fixedValue = 'constant'
1005
+ return {
1006
+ get() {
1007
+ track()
1008
+ return fixedValue
1009
+ },
1010
+ set() {
1011
+ // no-op — value never changes
1012
+ },
1013
+ }
1014
+ })
1015
+
1016
+ const values: string[] = []
1017
+ const stop = watchEffect(() => {
1018
+ values.push(r.value)
1019
+ })
1020
+
1021
+ expect(values).toEqual(['constant'])
1022
+
1023
+ // Trigger without changing value
1024
+ triggerFn!()
1025
+ expect(values.length).toBeGreaterThan(1)
1026
+ expect(values[1]).toBe('constant')
1027
+
1028
+ stop()
1029
+ })
1030
+ })
1031
+
1032
+ describe('type exports', () => {
1033
+ it('exports version as string', () => {
1034
+ expect(typeof version).toBe('string')
1035
+ })
1036
+
1037
+ // Type-level tests — these just verify the exports exist and are importable.
1038
+ // The actual type checking happens via `tsc --noEmit`.
1039
+ it('type exports are importable', async () => {
1040
+ const mod = await import('../index')
1041
+ // Verify runtime exports exist
1042
+ expect(mod.version).toBeDefined()
1043
+ expect(mod.isReactive).toBeDefined()
1044
+ expect(mod.isReadonly).toBeDefined()
1045
+ expect(mod.isProxy).toBeDefined()
1046
+ expect(mod.markRaw).toBeDefined()
1047
+ expect(mod.effectScope).toBeDefined()
1048
+ expect(mod.getCurrentScope).toBeDefined()
1049
+ expect(mod.onScopeDispose).toBeDefined()
1050
+ expect(mod.onErrorCaptured).toBeDefined()
1051
+ expect(mod.onRenderTracked).toBeDefined()
1052
+ expect(mod.onRenderTriggered).toBeDefined()
1053
+ expect(mod.Teleport).toBeDefined()
1054
+ expect(mod.KeepAlive).toBeDefined()
1055
+ expect(mod.watchPostEffect).toBeDefined()
1056
+ expect(mod.watchSyncEffect).toBeDefined()
1057
+ expect(mod.customRef).toBeDefined()
1058
+ expect(mod.toValue).toBeDefined()
1059
+ expect(mod.shallowReadonly).toBeDefined()
1060
+ expect(mod.defineAsyncComponent).toBeDefined()
1061
+ })
1062
+ })
1063
+
1064
+ // ─── watchEffect onCleanup ──────────────────────────────────────────────────
1065
+
1066
+ describe('watchEffect onCleanup', () => {
1067
+ it('passes onCleanup to the callback', () => {
1068
+ let cleaned = false
1069
+ const r = ref(1)
1070
+ const stop = watchEffect((onCleanup) => {
1071
+ void r.value // track
1072
+ onCleanup(() => {
1073
+ cleaned = true
1074
+ })
1075
+ })
1076
+ expect(cleaned).toBe(false)
1077
+ r.value = 2 // triggers re-run → cleanup called before re-execution
1078
+ expect(cleaned).toBe(true)
1079
+ stop()
1080
+ })
1081
+
1082
+ it('runs cleanup on stop', () => {
1083
+ let cleaned = false
1084
+ const stop = watchEffect((onCleanup) => {
1085
+ onCleanup(() => {
1086
+ cleaned = true
1087
+ })
1088
+ })
1089
+ expect(cleaned).toBe(false)
1090
+ stop()
1091
+ expect(cleaned).toBe(true)
1092
+ })
1093
+
1094
+ it('runs previous cleanup before re-execution', () => {
1095
+ const events: string[] = []
1096
+ const r = ref(0)
1097
+ const stop = watchEffect((onCleanup) => {
1098
+ const val = r.value
1099
+ events.push(`run:${val}`)
1100
+ onCleanup(() => events.push(`cleanup:${val}`))
1101
+ })
1102
+ expect(events).toEqual(['run:0'])
1103
+ r.value = 1
1104
+ expect(events).toEqual(['run:0', 'cleanup:0', 'run:1'])
1105
+ r.value = 2
1106
+ expect(events).toEqual(['run:0', 'cleanup:0', 'run:1', 'cleanup:1', 'run:2'])
1107
+ stop()
1108
+ // stop should also run the last cleanup
1109
+ expect(events).toEqual(['run:0', 'cleanup:0', 'run:1', 'cleanup:1', 'run:2', 'cleanup:2'])
1110
+ })
1111
+
1112
+ it('works without onCleanup being called', () => {
1113
+ const r = ref(0)
1114
+ const values: number[] = []
1115
+ const stop = watchEffect(() => {
1116
+ values.push(r.value)
1117
+ })
1118
+ r.value = 1
1119
+ expect(values).toEqual([0, 1])
1120
+ stop()
1121
+ })
1122
+
1123
+ it('works inside hook context', () => {
1124
+ let cleaned = false
1125
+ const r = ref(0)
1126
+ const { ctx } = withHookCtx(() => {
1127
+ watchEffect((onCleanup) => {
1128
+ void r.value
1129
+ onCleanup(() => {
1130
+ cleaned = true
1131
+ })
1132
+ })
1133
+ })
1134
+ expect(cleaned).toBe(false)
1135
+ r.value = 1
1136
+ expect(cleaned).toBe(true)
1137
+ // Dispose via unmount callbacks
1138
+ for (const cb of ctx.unmountCallbacks) cb()
1139
+ })
1140
+ })
1141
+
1142
+ // ─── watchPostEffect / watchSyncEffect onCleanup ────────────────────────────
1143
+
1144
+ describe('watchPostEffect onCleanup', () => {
1145
+ it('passes onCleanup to the callback', () => {
1146
+ let cleaned = false
1147
+ const r = ref(0)
1148
+ const stop = watchPostEffect((onCleanup) => {
1149
+ void r.value
1150
+ onCleanup(() => {
1151
+ cleaned = true
1152
+ })
1153
+ })
1154
+ r.value = 1
1155
+ expect(cleaned).toBe(true)
1156
+ stop()
1157
+ })
1158
+ })
1159
+
1160
+ describe('watchSyncEffect onCleanup', () => {
1161
+ it('passes onCleanup to the callback', () => {
1162
+ let cleaned = false
1163
+ const r = ref(0)
1164
+ const stop = watchSyncEffect((onCleanup) => {
1165
+ void r.value
1166
+ onCleanup(() => {
1167
+ cleaned = true
1168
+ })
1169
+ })
1170
+ r.value = 1
1171
+ expect(cleaned).toBe(true)
1172
+ stop()
1173
+ })
1174
+ })
1175
+
1176
+ // ─── watch onCleanup (3rd parameter) ────────────────────────────────────────
1177
+
1178
+ describe('watch onCleanup', () => {
1179
+ it('passes onCleanup as 3rd argument to callback', () => {
1180
+ let cleaned = false
1181
+ const r = ref(0)
1182
+ const stop = watch(r, (_newVal, _oldVal, onCleanup) => {
1183
+ onCleanup(() => {
1184
+ cleaned = true
1185
+ })
1186
+ })
1187
+ r.value = 1
1188
+ expect(cleaned).toBe(false) // first cb, no previous cleanup
1189
+ r.value = 2 // second change → cleanup from first callback runs
1190
+ expect(cleaned).toBe(true)
1191
+ stop()
1192
+ })
1193
+
1194
+ it('runs cleanup on stop', () => {
1195
+ let cleaned = false
1196
+ const r = ref(0)
1197
+ const stop = watch(r, (_newVal, _oldVal, onCleanup) => {
1198
+ onCleanup(() => {
1199
+ cleaned = true
1200
+ })
1201
+ })
1202
+ r.value = 1 // trigger first callback
1203
+ expect(cleaned).toBe(false)
1204
+ stop()
1205
+ expect(cleaned).toBe(true)
1206
+ })
1207
+
1208
+ it('passes onCleanup to array watch callback', () => {
1209
+ let cleaned = false
1210
+ const a = ref(0)
1211
+ const b = ref(0)
1212
+ const stop = watch([a, b] as const, (_newVals, _oldVals, onCleanup) => {
1213
+ onCleanup(() => {
1214
+ cleaned = true
1215
+ })
1216
+ })
1217
+ a.value = 1
1218
+ expect(cleaned).toBe(false)
1219
+ b.value = 1 // second change → cleanup from first callback
1220
+ expect(cleaned).toBe(true)
1221
+ stop()
1222
+ })
1223
+ })
1224
+
1225
+ // ─── defineAsyncComponent ───────────────────────────────────────────────────
1226
+
1227
+ describe('defineAsyncComponent', () => {
1228
+ it('loads component from loader', async () => {
1229
+ const MyComp: ComponentFn = () => 'loaded'
1230
+ const AsyncComp = defineAsyncComponent(async () => ({
1231
+ default: MyComp,
1232
+ }))
1233
+ expect(AsyncComp.__loading()).toBe(true)
1234
+ await new Promise((r) => setTimeout(r, 10))
1235
+ expect(AsyncComp.__loading()).toBe(false)
1236
+ })
1237
+
1238
+ it('returns null while loading', () => {
1239
+ const AsyncComp = defineAsyncComponent(
1240
+ () => new Promise<{ default: ComponentFn }>(() => {}), // never resolves
1241
+ )
1242
+ const result = AsyncComp({})
1243
+ expect(result).toBeNull()
1244
+ })
1245
+
1246
+ it('throws error on load failure', async () => {
1247
+ const AsyncComp = defineAsyncComponent(async () => {
1248
+ throw new Error('load failed')
1249
+ })
1250
+ // Trigger loading
1251
+ AsyncComp.__loading()
1252
+ await new Promise((r) => setTimeout(r, 10))
1253
+ expect(() => AsyncComp({})).toThrow('load failed')
1254
+ })
1255
+
1256
+ it('accepts options object form', async () => {
1257
+ const MyComp: ComponentFn = () => 'options-loaded'
1258
+ const AsyncComp = defineAsyncComponent({
1259
+ loader: async () => ({ default: MyComp }),
1260
+ })
1261
+ expect(AsyncComp.__loading()).toBe(true)
1262
+ await new Promise((r) => setTimeout(r, 10))
1263
+ expect(AsyncComp.__loading()).toBe(false)
1264
+ })
1265
+ })
1266
+
1267
+ // ─── defineComponent slots ──────────────────────────────────────────────────
1268
+
1269
+ describe('defineComponent slots', () => {
1270
+ it('setup receives slots.default from children', () => {
1271
+ let capturedSlots: Record<string, (() => unknown) | undefined> = {}
1272
+ const Comp = defineComponent({
1273
+ setup(_props, ctx) {
1274
+ capturedSlots = ctx!.slots as Record<string, (() => unknown) | undefined>
1275
+ return () => h('div', null, 'test')
1276
+ },
1277
+ })
1278
+
1279
+ const el = container()
1280
+ const vnode = h(Comp as ComponentFn, null, 'child content')
1281
+ const unmount = mount(vnode, el)
1282
+
1283
+ expect(typeof capturedSlots.default).toBe('function')
1284
+ expect(capturedSlots.default!()).toBe('child content')
1285
+ unmount()
1286
+ })
1287
+
1288
+ it('slots.default is undefined when no children', () => {
1289
+ let capturedSlots: Record<string, (() => unknown) | undefined> = {}
1290
+ const Comp = defineComponent({
1291
+ setup(_props, ctx) {
1292
+ capturedSlots = ctx!.slots as Record<string, (() => unknown) | undefined>
1293
+ return () => h('div', null, 'test')
1294
+ },
1295
+ })
1296
+
1297
+ const el = container()
1298
+ const unmount = mount(h(Comp as ComponentFn, null), el)
1299
+
1300
+ expect(capturedSlots.default).toBeUndefined()
1301
+ unmount()
1302
+ })
1303
+ })