@pyreon/vue-compat 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -29,6 +29,17 @@ import {
29
29
  watch,
30
30
  watchEffect,
31
31
  } from "../index"
32
+ import {
33
+ beginRender,
34
+ endRender,
35
+ getCurrentCtx,
36
+ jsx,
37
+ jsxDEV,
38
+ jsxs,
39
+ type RenderContext,
40
+ } from "../jsx-runtime"
41
+
42
+ // ─── Test helpers ──────────────────────────────────────────────────────────────
32
43
 
33
44
  function container(): HTMLElement {
34
45
  const el = document.createElement("div")
@@ -36,6 +47,46 @@ function container(): HTMLElement {
36
47
  return el
37
48
  }
38
49
 
50
+ /** Create a hook context for testing hooks outside of a full render cycle */
51
+ function withHookCtx<T>(fn: (ctx: RenderContext) => T): { result: T; ctx: RenderContext } {
52
+ const ctx: RenderContext = {
53
+ hooks: [],
54
+ scheduleRerender: () => {},
55
+ pendingEffects: [],
56
+ pendingLayoutEffects: [],
57
+ unmounted: false,
58
+ unmountCallbacks: [],
59
+ }
60
+ beginRender(ctx)
61
+ const result = fn(ctx)
62
+ endRender()
63
+ return { result, ctx }
64
+ }
65
+
66
+ /** Run a hook function multiple times to simulate re-renders */
67
+ function createHookRunner<T>(fn: () => T): {
68
+ run: () => T
69
+ ctx: RenderContext
70
+ } {
71
+ const ctx: RenderContext = {
72
+ hooks: [],
73
+ scheduleRerender: () => {},
74
+ pendingEffects: [],
75
+ pendingLayoutEffects: [],
76
+ unmounted: false,
77
+ unmountCallbacks: [],
78
+ }
79
+ return {
80
+ run: () => {
81
+ beginRender(ctx)
82
+ const result = fn()
83
+ endRender()
84
+ return result
85
+ },
86
+ ctx,
87
+ }
88
+ }
89
+
39
90
  describe("@pyreon/vue-compat", () => {
40
91
  // ─── ref ────────────────────────────────────────────────────────────────
41
92
 
@@ -50,6 +101,38 @@ describe("@pyreon/vue-compat", () => {
50
101
  expect(count.value).toBe(5)
51
102
  })
52
103
 
104
+ it("ref() is hook-indexed inside component", () => {
105
+ const runner = createHookRunner(() => {
106
+ const count = ref(42)
107
+ return count
108
+ })
109
+ const r1 = runner.run()
110
+ r1.value = 100
111
+ const r2 = runner.run()
112
+ expect(r1).toBe(r2)
113
+ expect(r2.value).toBe(100)
114
+ })
115
+
116
+ it("ref() setter calls scheduleRerender inside component", () => {
117
+ let rerenders = 0
118
+ const ctx: RenderContext = {
119
+ hooks: [],
120
+ scheduleRerender: () => {
121
+ rerenders++
122
+ },
123
+ pendingEffects: [],
124
+ pendingLayoutEffects: [],
125
+ unmounted: false,
126
+ unmountCallbacks: [],
127
+ }
128
+ beginRender(ctx)
129
+ const count = ref(0)
130
+ endRender()
131
+
132
+ count.value = 1
133
+ expect(rerenders).toBe(1)
134
+ })
135
+
53
136
  // ─── shallowRef ────────────────────────────────────────────────────────
54
137
 
55
138
  it("shallowRef() creates a ref (same as ref in Pyreon)", () => {
@@ -73,10 +156,35 @@ describe("@pyreon/vue-compat", () => {
73
156
 
74
157
  expect(runs).toBe(1)
75
158
  triggerRef(r)
76
- expect(runs).toBe(3) // set undefined then set back = 2 triggers
159
+ expect(runs).toBe(3)
77
160
  stop()
78
161
  })
79
162
 
163
+ it("triggerRef calls scheduleRerender for hook-indexed refs", () => {
164
+ let rerenders = 0
165
+ const ctx: RenderContext = {
166
+ hooks: [],
167
+ scheduleRerender: () => {
168
+ rerenders++
169
+ },
170
+ pendingEffects: [],
171
+ pendingLayoutEffects: [],
172
+ unmounted: false,
173
+ unmountCallbacks: [],
174
+ }
175
+ beginRender(ctx)
176
+ const r = ref(0)
177
+ endRender()
178
+
179
+ triggerRef(r)
180
+ expect(rerenders).toBe(1)
181
+ })
182
+
183
+ it("triggerRef is a no-op if ref has no _signal", () => {
184
+ const fakeRef = { value: 42 } as unknown as ReturnType<typeof ref>
185
+ expect(() => triggerRef(fakeRef)).not.toThrow()
186
+ })
187
+
80
188
  // ─── isRef ─────────────────────────────────────────────────────────────
81
189
 
82
190
  it("isRef() detects refs", () => {
@@ -90,6 +198,14 @@ describe("@pyreon/vue-compat", () => {
90
198
  expect(isRef(c)).toBe(true)
91
199
  })
92
200
 
201
+ it("isRef returns false for undefined", () => {
202
+ expect(isRef(undefined)).toBe(false)
203
+ })
204
+
205
+ it("isRef returns false for string", () => {
206
+ expect(isRef("hello")).toBe(false)
207
+ })
208
+
93
209
  // ─── unref ─────────────────────────────────────────────────────────────
94
210
 
95
211
  it("unref() unwraps refs", () => {
@@ -116,6 +232,19 @@ describe("@pyreon/vue-compat", () => {
116
232
  }).toThrow("readonly")
117
233
  })
118
234
 
235
+ it("computed() is hook-indexed inside component", () => {
236
+ const count = ref(5)
237
+ const runner = createHookRunner(() => {
238
+ return computed(() => count.value * 2)
239
+ })
240
+ const c1 = runner.run()
241
+ expect(c1.value).toBe(10)
242
+ count.value = 10
243
+ const c2 = runner.run()
244
+ expect(c1).toBe(c2)
245
+ expect(c2.value).toBe(20)
246
+ })
247
+
119
248
  // ─── reactive ──────────────────────────────────────────────────────────
120
249
 
121
250
  it("reactive() creates deep reactive object", () => {
@@ -133,6 +262,37 @@ describe("@pyreon/vue-compat", () => {
133
262
  stop()
134
263
  })
135
264
 
265
+ it("reactive() is hook-indexed inside component", () => {
266
+ const runner = createHookRunner(() => {
267
+ return reactive({ x: 0 })
268
+ })
269
+ const s1 = runner.run()
270
+ s1.x = 42
271
+ const s2 = runner.run()
272
+ expect(s1).toBe(s2)
273
+ expect(s2.x).toBe(42)
274
+ })
275
+
276
+ it("reactive() setter calls scheduleRerender inside component", () => {
277
+ let rerenders = 0
278
+ const ctx: RenderContext = {
279
+ hooks: [],
280
+ scheduleRerender: () => {
281
+ rerenders++
282
+ },
283
+ pendingEffects: [],
284
+ pendingLayoutEffects: [],
285
+ unmounted: false,
286
+ unmountCallbacks: [],
287
+ }
288
+ beginRender(ctx)
289
+ const state = reactive({ count: 0 })
290
+ endRender()
291
+
292
+ state.count = 1
293
+ expect(rerenders).toBe(1)
294
+ })
295
+
136
296
  // ─── shallowReactive ──────────────────────────────────────────────────
137
297
 
138
298
  it("shallowReactive() creates reactive object (same as reactive)", () => {
@@ -168,16 +328,18 @@ describe("@pyreon/vue-compat", () => {
168
328
  it("readonly() throws on symbol property set", () => {
169
329
  const obj = readonly({ count: 0 })
170
330
  const sym = Symbol("test")
171
- // Only internal symbols (V_IS_READONLY, V_RAW) are allowed; all others throw
172
331
  expect(() => {
173
332
  ;(obj as Record<symbol, unknown>)[sym] = "value"
174
333
  }).toThrow("readonly")
175
334
  })
176
335
 
177
- it("readonly() exposes V_IS_READONLY symbol", () => {
178
- const obj = readonly({ count: 0 })
179
- // The readonly proxy should have the V_IS_READONLY symbol accessible
180
- expect(typeof obj).toBe("object")
336
+ it("readonly() is hook-indexed inside component", () => {
337
+ const runner = createHookRunner(() => {
338
+ return readonly({ count: 0 })
339
+ })
340
+ const r1 = runner.run()
341
+ const r2 = runner.run()
342
+ expect(r1).toBe(r2)
181
343
  })
182
344
 
183
345
  // ─── toRaw ─────────────────────────────────────────────────────────────
@@ -210,15 +372,23 @@ describe("@pyreon/vue-compat", () => {
210
372
  expect(isRef(countRef)).toBe(true)
211
373
  expect(countRef.value).toBe(0)
212
374
 
213
- // Writing through ref updates original
214
375
  countRef.value = 10
215
376
  expect(state.count).toBe(10)
216
377
 
217
- // Writing to original updates ref
218
378
  state.count = 20
219
379
  expect(countRef.value).toBe(20)
220
380
  })
221
381
 
382
+ it("toRef() is hook-indexed inside component", () => {
383
+ const state = reactive({ count: 0 })
384
+ const runner = createHookRunner(() => {
385
+ return toRef(state, "count")
386
+ })
387
+ const r1 = runner.run()
388
+ const r2 = runner.run()
389
+ expect(r1).toBe(r2)
390
+ })
391
+
222
392
  // ─── toRefs ────────────────────────────────────────────────────────────
223
393
 
224
394
  it("toRefs() converts reactive to refs", () => {
@@ -233,6 +403,16 @@ describe("@pyreon/vue-compat", () => {
233
403
  expect(state.a).toBe(10)
234
404
  })
235
405
 
406
+ it("toRefs() is hook-indexed inside component", () => {
407
+ const state = reactive({ x: 1, y: 2 })
408
+ const runner = createHookRunner(() => {
409
+ return toRefs(state)
410
+ })
411
+ const r1 = runner.run()
412
+ const r2 = runner.run()
413
+ expect(r1).toBe(r2)
414
+ })
415
+
236
416
  // ─── watch ──────────────────────────────────────────────────────────────
237
417
 
238
418
  it("watch() fires on ref change", () => {
@@ -314,7 +494,61 @@ describe("@pyreon/vue-compat", () => {
314
494
 
315
495
  stop()
316
496
  count.value = 2
317
- expect(calls).toEqual([1]) // no more updates
497
+ expect(calls).toEqual([1])
498
+ })
499
+
500
+ it("watch() is hook-indexed inside component", () => {
501
+ const count = ref(0)
502
+ const calls: number[] = []
503
+ const runner = createHookRunner(() => {
504
+ return watch(count, (newVal) => {
505
+ calls.push(newVal)
506
+ })
507
+ })
508
+ const stop1 = runner.run()
509
+ const stop2 = runner.run()
510
+ expect(stop1).toBe(stop2)
511
+
512
+ count.value = 1
513
+ expect(calls).toEqual([1])
514
+ stop1()
515
+ })
516
+
517
+ it("watch with immediate tracks subsequent changes too", () => {
518
+ const count = ref(0)
519
+ const calls: [number, number | undefined][] = []
520
+
521
+ const stop = watch(
522
+ count,
523
+ (newVal, oldVal) => {
524
+ calls.push([newVal, oldVal])
525
+ },
526
+ { immediate: true },
527
+ )
528
+
529
+ expect(calls[0]).toEqual([0, undefined])
530
+
531
+ count.value = 10
532
+ const lastCall = calls[calls.length - 1]!
533
+ expect(lastCall[0]).toBe(10)
534
+
535
+ stop()
536
+ })
537
+
538
+ it("watch with getter function and immediate", () => {
539
+ const count = ref(5)
540
+ const calls: [number, number | undefined][] = []
541
+
542
+ const stop = watch(
543
+ () => count.value * 2,
544
+ (newVal, oldVal) => {
545
+ calls.push([newVal, oldVal])
546
+ },
547
+ { immediate: true },
548
+ )
549
+
550
+ expect(calls[0]).toEqual([10, undefined])
551
+ stop()
318
552
  })
319
553
 
320
554
  // ─── watchEffect ───────────────────────────────────────────────────────
@@ -337,6 +571,23 @@ describe("@pyreon/vue-compat", () => {
337
571
  expect(values).toEqual([0, 1, 2])
338
572
  })
339
573
 
574
+ it("watchEffect() is hook-indexed inside component", () => {
575
+ const count = ref(0)
576
+ const values: number[] = []
577
+ const runner = createHookRunner(() => {
578
+ return watchEffect(() => {
579
+ values.push(count.value)
580
+ })
581
+ })
582
+ const stop1 = runner.run()
583
+ const stop2 = runner.run()
584
+ expect(stop1).toBe(stop2)
585
+
586
+ count.value = 1
587
+ expect(values).toEqual([0, 1])
588
+ stop1()
589
+ })
590
+
340
591
  // ─── nextTick ──────────────────────────────────────────────────────────
341
592
 
342
593
  it("nextTick() resolves after flush", async () => {
@@ -346,9 +597,9 @@ describe("@pyreon/vue-compat", () => {
346
597
  expect(count.value).toBe(42)
347
598
  })
348
599
 
349
- // ─── lifecycle ─────────────────────────────────────────────────────────
600
+ // ─── lifecycle (with Pyreon fallback) ──────────────────────────────────
350
601
 
351
- it("onMounted/onUnmounted lifecycle hooks work", () => {
602
+ it("onMounted/onUnmounted lifecycle hooks work with defineComponent", () => {
352
603
  const mounted: string[] = []
353
604
  const unmounted: string[] = []
354
605
 
@@ -416,6 +667,25 @@ describe("@pyreon/vue-compat", () => {
416
667
  expect(typeof onUpdated).toBe("function")
417
668
  })
418
669
 
670
+ it("onMounted queues pendingEffect inside hook context", () => {
671
+ const { ctx } = withHookCtx(() => {
672
+ onMounted(() => {})
673
+ })
674
+ expect(ctx.pendingEffects.length).toBe(1)
675
+ })
676
+
677
+ it("onUnmounted pushes to unmountCallbacks inside hook context", () => {
678
+ const calls: string[] = []
679
+ const { ctx } = withHookCtx(() => {
680
+ onUnmounted(() => {
681
+ calls.push("unmounted")
682
+ })
683
+ })
684
+ expect(ctx.unmountCallbacks.length).toBe(1)
685
+ ctx.unmountCallbacks[0]!()
686
+ expect(calls).toEqual(["unmounted"])
687
+ })
688
+
419
689
  // ─── provide / inject ─────────────────────────────────────────────────
420
690
 
421
691
  it("provide/inject with string key", () => {
@@ -439,6 +709,23 @@ describe("@pyreon/vue-compat", () => {
439
709
  expect(inject(key)).toBeUndefined()
440
710
  })
441
711
 
712
+ it("provide is hook-indexed inside component", () => {
713
+ const key = "hook-provide-test"
714
+ const runner = createHookRunner(() => {
715
+ provide(key, "value")
716
+ })
717
+ runner.run()
718
+ runner.run()
719
+ })
720
+
721
+ it("provide overwrites previously provided value (outside component)", () => {
722
+ const key = "overwrite-test"
723
+ provide(key, "first")
724
+ expect(inject(key)).toBe("first")
725
+ provide(key, "second")
726
+ expect(inject(key)).toBe("second")
727
+ })
728
+
442
729
  // ─── defineComponent ──────────────────────────────────────────────────
443
730
 
444
731
  it("defineComponent with setup function returning render fn", () => {
@@ -521,12 +808,12 @@ describe("@pyreon/vue-compat", () => {
521
808
 
522
809
  it("createApp().mount with string selector", () => {
523
810
  const el = container()
524
- el.id = "test-app-mount"
811
+ el.id = "test-app-mount-vue"
525
812
  document.body.appendChild(el)
526
813
 
527
814
  const Comp = () => h("div", null, "selector-app")
528
815
  const app = createApp(Comp)
529
- const unmount = app.mount("#test-app-mount")
816
+ const unmount = app.mount("#test-app-mount-vue")
530
817
  expect(el.textContent).toBe("selector-app")
531
818
  unmount()
532
819
  })
@@ -546,6 +833,15 @@ describe("@pyreon/vue-compat", () => {
546
833
  unmount()
547
834
  })
548
835
 
836
+ it("createApp with no props", () => {
837
+ const Comp = () => h("div", null, "no-props")
838
+ const el = container()
839
+ const app = createApp(Comp)
840
+ const unmount = app.mount(el)
841
+ expect(el.textContent).toBe("no-props")
842
+ unmount()
843
+ })
844
+
549
845
  // ─── batch ────────────────────────────────────────────────────────────
550
846
 
551
847
  it("batch is re-exported and coalesces updates", () => {
@@ -562,137 +858,70 @@ describe("@pyreon/vue-compat", () => {
562
858
  count.value = 3
563
859
  })
564
860
 
565
- // Should have initial (0) and then final batch result (3)
566
861
  expect(values[0]).toBe(0)
567
862
  expect(values[values.length - 1]).toBe(3)
568
863
  stop()
569
864
  })
570
865
 
571
- // ─── triggerRef edge: no _signal ────────────────────────────────────────
866
+ // ─── jsx-runtime ─────────────────────────────────────────────────────
572
867
 
573
- it("triggerRef is a no-op if ref has no _signal", () => {
574
- // Create a fake ref without _signal
575
- const fakeRef = { value: 42 } as unknown as ReturnType<typeof ref>
576
- // Should not throw
577
- expect(() => triggerRef(fakeRef)).not.toThrow()
868
+ it("jsx creates DOM element VNodes", () => {
869
+ const vnode = jsx("div", { class: "test", children: "hello" })
870
+ expect(vnode.type).toBe("div")
578
871
  })
579
872
 
580
- // ─── isRef edge cases ──────────────────────────────────────────────────
581
-
582
- it("isRef returns false for undefined", () => {
583
- expect(isRef(undefined)).toBe(false)
873
+ it("jsxs is same as jsx", () => {
874
+ expect(jsxs).toBe(jsx)
584
875
  })
585
876
 
586
- it("isRef returns false for string", () => {
587
- expect(isRef("hello")).toBe(false)
877
+ it("jsxDEV is same as jsx", () => {
878
+ expect(jsxDEV).toBe(jsx)
588
879
  })
589
880
 
590
- // ─── readonly get V_IS_READONLY ────────────────────────────────────────
881
+ it("jsx wraps component functions", () => {
882
+ function MyComp() {
883
+ return h("div", null, "test")
884
+ }
885
+ const vnode = jsx(MyComp, {})
886
+ expect(vnode.type).not.toBe(MyComp)
887
+ expect(typeof vnode.type).toBe("function")
888
+ })
591
889
 
592
- it("readonly proxy reports V_IS_READONLY via symbol", () => {
593
- const obj = readonly({ count: 0 })
594
- // Access the internal V_IS_READONLY symbol via a known property read
595
- // The proxy get trap handles this symbol
596
- const _V_IS_READONLY = Symbol("__v_isReadonly")
597
- // We can't access the private symbol directly, but we can verify it doesn't throw
598
- // when accessing regular properties
599
- expect(obj.count).toBe(0)
890
+ it("jsx passes key as prop", () => {
891
+ const vnode = jsx("div", { children: "test" }, "my-key")
892
+ expect(vnode.props?.key).toBe("my-key")
600
893
  })
601
894
 
602
- // ─── readonly get V_RAW ─────────────────────────────────────────────────
895
+ it("getCurrentCtx returns null outside render", () => {
896
+ expect(getCurrentCtx()).toBeNull()
897
+ })
603
898
 
604
- it("toRaw retrieves raw from readonly proxy", () => {
605
- const original = { a: 1, b: 2 }
606
- const ro = readonly(original)
607
- const raw = toRaw(ro)
608
- expect(raw).toBe(original)
899
+ it("getCurrentCtx returns context during render", () => {
900
+ withHookCtx((c) => {
901
+ expect(getCurrentCtx()).toBe(c)
902
+ })
903
+ expect(getCurrentCtx()).toBeNull()
609
904
  })
610
905
 
611
- // ─── watch with immediate + subsequent changes ─────────────────────────
906
+ // ─── standalone (outside component) ──────────────────────────────────
612
907
 
613
- it("watch with immediate tracks subsequent changes too", () => {
908
+ it("ref() outside component creates standalone ref", () => {
614
909
  const count = ref(0)
615
- const calls: [number, number | undefined][] = []
616
-
617
- const stop = watch(
618
- count,
619
- (newVal, oldVal) => {
620
- calls.push([newVal, oldVal])
621
- },
622
- { immediate: true },
623
- )
624
-
625
- // First call from immediate
626
- expect(calls[0]).toEqual([0, undefined])
627
-
628
910
  count.value = 10
629
- // Should have the change tracked
630
- const lastCall = calls[calls.length - 1]!
631
- expect(lastCall[0]).toBe(10)
632
-
633
- stop()
911
+ expect(count.value).toBe(10)
634
912
  })
635
913
 
636
- // ─── watch with getter and immediate ──────────────────────────────────
637
-
638
- it("watch with getter function and immediate", () => {
914
+ it("computed() outside component creates standalone computed", () => {
639
915
  const count = ref(5)
640
- const calls: [number, number | undefined][] = []
641
-
642
- const stop = watch(
643
- () => count.value * 2,
644
- (newVal, oldVal) => {
645
- calls.push([newVal, oldVal])
646
- },
647
- { immediate: true },
648
- )
649
-
650
- expect(calls[0]).toEqual([10, undefined])
651
- stop()
652
- })
653
-
654
- // ─── createApp with no props ───────────────────────────────────────────
655
-
656
- it("createApp with no props", () => {
657
- const Comp = () => h("div", null, "no-props")
658
- const el = container()
659
- const app = createApp(Comp)
660
- const unmount = app.mount(el)
661
- expect(el.textContent).toBe("no-props")
662
- unmount()
663
- })
664
-
665
- // ─── defineComponent setup returning function ──────────────────────────
666
-
667
- it("defineComponent setup returning VNodeChild (non-function) renders", () => {
668
- const Comp = defineComponent({
669
- setup() {
670
- return h("p", null, "static-vnode")
671
- },
672
- })
673
- const el = container()
674
- const unmount = mount(h(Comp, null), el)
675
- expect(el.querySelector("p")?.textContent).toBe("static-vnode")
676
- unmount()
677
- })
678
-
679
- // ─── provide overwrite ──────────────────────────────────────────────────
680
-
681
- it("provide overwrites previously provided value", () => {
682
- const key = "overwrite-test"
683
- provide(key, "first")
684
- expect(inject(key)).toBe("first")
685
- provide(key, "second")
686
- expect(inject(key)).toBe("second")
916
+ const doubled = computed(() => count.value * 2)
917
+ expect(doubled.value).toBe(10)
918
+ count.value = 7
919
+ expect(doubled.value).toBe(14)
687
920
  })
688
921
 
689
- // ─── getOrCreateContext reuses existing ─────────────────────────────────
690
-
691
- it("inject returns provided value for existing string key", () => {
692
- const key = "reuse-context-test"
693
- provide(key, 123)
694
- expect(inject(key)).toBe(123)
695
- // Call again to ensure it reuses
696
- expect(inject(key)).toBe(123)
922
+ it("reactive() outside component creates standalone reactive", () => {
923
+ const state = reactive({ x: 1 })
924
+ state.x = 2
925
+ expect(state.x).toBe(2)
697
926
  })
698
927
  })