ivue 1.5.8 → 2.0.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,1056 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import type { Ref } from 'vue';
3
+ import {
4
+ computed,
5
+ nextTick,
6
+ isReactive,
7
+ isRef,
8
+ reactive,
9
+ ref,
10
+ shallowRef,
11
+ toRaw,
12
+ watch,
13
+ } from 'vue';
14
+
15
+ import {
16
+ isClass,
17
+ propsWithDefaults,
18
+ Reactive,
19
+ type ReactiveInstance,
20
+ } from '../Reactive';
21
+
22
+ /**
23
+ * Test suite for Reactive.ts — the "ivue v2" engine.
24
+ *
25
+ * Design notes:
26
+ * - Reactive() MUTATES the class prototype in place (lazy getters / lazy-bound
27
+ * methods). Therefore every test defines its OWN fresh class so that prototype
28
+ * mutations never bleed across tests.
29
+ * - Instances are PLAIN class instances (no reactive() proxy). Reactivity is
30
+ * opt-in per-accessor by returning ref()/computed()/shallowRef() from getters.
31
+ */
32
+
33
+ afterEach(() => {
34
+ vi.restoreAllMocks();
35
+ });
36
+
37
+ describe('Reactive()', () => {
38
+ describe('identity & return value', () => {
39
+ it('returns the SAME class reference (mutates in place, no wrapper)', () => {
40
+ class Foo {
41
+ get x() {
42
+ return ref(1);
43
+ }
44
+ }
45
+ const Result = Reactive(Foo);
46
+ expect(Result).toBe(Foo as any);
47
+ });
48
+
49
+ it('instances are plain (NOT a reactive proxy) — the core perf invariant', () => {
50
+ class Foo {
51
+ get x() {
52
+ return ref(1);
53
+ }
54
+ bump() {
55
+ return 1;
56
+ }
57
+ }
58
+ const RFoo = Reactive(Foo);
59
+ const instance = new RFoo();
60
+ // No deep reactive proxy is created per instance.
61
+ expect(isReactive(instance)).toBe(false);
62
+ // toRaw() on a plain instance returns the instance itself.
63
+ expect(toRaw(instance)).toBe(instance);
64
+ });
65
+ });
66
+
67
+ describe('lazy reactive getters (ref-returning)', () => {
68
+ it('caches the SAME ref instance across accesses (stable identity)', () => {
69
+ class Box {
70
+ get width() {
71
+ return ref(100);
72
+ }
73
+ }
74
+ const instance = new (Reactive(Box))();
75
+ const r1 = (instance as any).width;
76
+ const r2 = (instance as any).width;
77
+ expect(isRef(r1)).toBe(true);
78
+ expect(r1).toBe(r2); // exact same ref → stable reactive cell
79
+ r1.value = 250;
80
+ expect((instance as any).width.value).toBe(250); // write survives
81
+ });
82
+
83
+ it('different instances get different ref instances', () => {
84
+ class Box {
85
+ get width() {
86
+ return ref(1);
87
+ }
88
+ }
89
+ const R = Reactive(Box);
90
+ const a: any = new R();
91
+ const b: any = new R();
92
+ a.width.value = 5;
93
+ expect(a.width.value).toBe(5);
94
+ expect(b.width.value).toBe(1); // isolated per instance
95
+ expect(a.width).not.toBe(b.width);
96
+ });
97
+
98
+ it('supports shallowRef and computed getters', () => {
99
+ class Box {
100
+ get depth() {
101
+ return shallowRef(10);
102
+ }
103
+ get w() {
104
+ return ref(4);
105
+ }
106
+ get area() {
107
+ return computed(() => (this as any).w.value * 2);
108
+ }
109
+ }
110
+ const instance: any = new (Reactive(Box))();
111
+ expect(instance.depth.value).toBe(10);
112
+ expect(instance.area.value).toBe(8);
113
+ instance.w.value = 5;
114
+ expect(instance.area.value).toBe(10); // computed reacts
115
+ });
116
+
117
+ it('keeps the prototype setter wired for a ref-returning getter (assign via property)', () => {
118
+ const sets: number[] = [];
119
+ class WithSetter {
120
+ _b = ref(1);
121
+ get x() {
122
+ return this._b; // returns the ref → cached, no de-opt
123
+ }
124
+ set x(v: number | Ref<number>) {
125
+ sets.push(v as number);
126
+ this._b.value = v as number; // standard setter writes through
127
+ }
128
+ }
129
+ const instance: any = new (Reactive(WithSetter))();
130
+ expect(instance.x.value).toBe(1); // getter returns the cached ref
131
+ instance.x = 7; // prototype setter → originalSetter.call(toRaw(this), 7)
132
+ expect(sets).toEqual([7]);
133
+ expect(instance.x.value).toBe(7);
134
+ });
135
+
136
+ it('writable computed getter (get/set on the computed) works via .value', () => {
137
+ class Box {
138
+ get w() {
139
+ return ref(2);
140
+ }
141
+ get label() {
142
+ return computed({
143
+ get: () => `w=${(this as any).w.value}`,
144
+ set: (v: string) => {
145
+ (this as any).w.value = parseInt(v.replace(/\D/g, ''), 10);
146
+ },
147
+ });
148
+ }
149
+ }
150
+ const instance: any = new (Reactive(Box))();
151
+ expect(instance.label.value).toBe('w=2');
152
+ instance.label.value = 'set 42';
153
+ expect(instance.w.value).toBe(42);
154
+ expect(instance.label.value).toBe('w=42');
155
+ });
156
+ });
157
+
158
+ describe('self-optimizing de-optimization (non-ref getters)', () => {
159
+ it('getter returning a plain value de-opts back to a native getter (no setter)', () => {
160
+ let calls = 0;
161
+ class Plain {
162
+ get answer() {
163
+ calls++;
164
+ return 42;
165
+ }
166
+ }
167
+ const R = Reactive(Plain);
168
+ const a: any = new R();
169
+ expect(a.answer).toBe(42); // first access via wrapper → triggers de-opt
170
+ expect(a.answer).toBe(42); // second access via restored getter
171
+ // A brand-new instance exercises the RESTORED prototype getter (toRaw path).
172
+ const b: any = new R();
173
+ expect(b.answer).toBe(42);
174
+ expect(calls).toBeGreaterThanOrEqual(3);
175
+ // The getter's VALUE is not cached under any symbol — only the
176
+ // engine's RAW back-pointer (stamped on first access by resolveRaw)
177
+ // may exist on the instance.
178
+ const syms = Object.getOwnPropertySymbols(a);
179
+ expect(syms.map((s) => s.toString())).toEqual(['Symbol(ivue.raw)']);
180
+ expect((a as any)[syms[0]]).toBe(a);
181
+ });
182
+
183
+ it('getter+setter returning a plain value de-opts but keeps the setter wired', () => {
184
+ class PlainRW {
185
+ _x = 7;
186
+ get x() {
187
+ return this._x; // plain value → de-opt
188
+ }
189
+ set x(v: number) {
190
+ this._x = v;
191
+ }
192
+ }
193
+ const R = Reactive(PlainRW);
194
+ const a: any = new R();
195
+ expect(a.x).toBe(7); // de-opt happens here
196
+ a.x = 99; // restored setter path
197
+ expect(a.x).toBe(99);
198
+ // second instance uses restored getter + setter on the prototype
199
+ const b: any = new R();
200
+ b.x = 123;
201
+ expect(b.x).toBe(123);
202
+ expect(a.x).toBe(99);
203
+ });
204
+
205
+ it('native accessor pair over reactive state stays fully reactive (no computed)', async () => {
206
+ class Thermo {
207
+ get celsius() {
208
+ return ref(20);
209
+ }
210
+ // plain derived getter + native setter — the accessor-pair form
211
+ get fahrenheit() {
212
+ return (this.celsius.value * 9) / 5 + 32;
213
+ }
214
+ set fahrenheit(value: number) {
215
+ this.celsius.value = ((value - 32) * 5) / 9;
216
+ }
217
+ }
218
+ const R = Reactive(Thermo);
219
+ const thermo: any = new R();
220
+ expect(thermo.fahrenheit).toBe(68);
221
+
222
+ // watch the plain derived getter on the RAW instance
223
+ const seen: number[] = [];
224
+ watch(
225
+ () => thermo.fahrenheit,
226
+ (fahrenheit: number) => seen.push(fahrenheit),
227
+ { flush: 'sync' },
228
+ );
229
+
230
+ thermo.fahrenheit = 212; // native setter writes through to the ref
231
+ expect(thermo.celsius.value).toBeCloseTo(100, 10);
232
+ expect(thermo.fahrenheit).toBeCloseTo(212, 10);
233
+ expect(seen.length).toBe(1);
234
+ expect(seen[0]).toBeCloseTo(212, 10);
235
+
236
+ thermo.celsius.value = 0; // writing the source ref re-fires too
237
+ expect(seen.length).toBe(2);
238
+ expect(seen[1]).toBeCloseTo(32, 10);
239
+ });
240
+ });
241
+
242
+ describe('$-prefixed singletons (cacheWhole)', () => {
243
+ it('caches the WHOLE result forever, even non-refs (composable/service pattern)', () => {
244
+ let created = 0;
245
+ class WithService {
246
+ get $service() {
247
+ created++;
248
+ return { tag: 'svc', n: 1 }; // a plain object, not a ref
249
+ }
250
+ }
251
+ const instance: any = new (Reactive(WithService))();
252
+ const s1 = instance.$service;
253
+ const s2 = instance.$service;
254
+ expect(s1).toBe(s2); // same object cached
255
+ expect(created).toBe(1); // original getter ran exactly once
256
+ s1.n = 99;
257
+ expect(instance.$service.n).toBe(99); // mutations persist on the singleton
258
+ });
259
+ });
260
+
261
+ describe('lazy-bound methods', () => {
262
+ it('returns a stable, bound function across accesses (referential equality)', () => {
263
+ class Counter {
264
+ count = 0;
265
+ inc() {
266
+ this.count++;
267
+ return this.count;
268
+ }
269
+ }
270
+ const instance: any = new (Reactive(Counter))();
271
+ const m1 = instance.inc;
272
+ const m2 = instance.inc;
273
+ expect(typeof m1).toBe('function');
274
+ expect(m1).toBe(m2); // stable identity → safe as event handler / dep
275
+ });
276
+
277
+ it('method is bound to the instance (this is correct when detached)', () => {
278
+ class Counter {
279
+ count = 10;
280
+ inc() {
281
+ this.count++;
282
+ return this.count;
283
+ }
284
+ }
285
+ const instance: any = new (Reactive(Counter))();
286
+ const { inc } = instance; // detached
287
+ expect(inc()).toBe(11);
288
+ expect(inc()).toBe(12);
289
+ expect(instance.count).toBe(12);
290
+ });
291
+
292
+ it('method can be overridden per-instance via the setter', () => {
293
+ class Counter {
294
+ greet() {
295
+ return 'hi';
296
+ }
297
+ }
298
+ const R = Reactive(Counter);
299
+ const a: any = new R();
300
+ const b: any = new R();
301
+ a.greet = () => 'custom'; // setter → stored on raw under super symbol
302
+ expect(a.greet()).toBe('custom');
303
+ expect(b.greet()).toBe('hi'); // other instance unaffected
304
+ });
305
+
306
+ it('works when the instance IS wrapped in reactive() (toRaw anchoring)', () => {
307
+ class Svc {
308
+ v = 1;
309
+ get val() {
310
+ return ref(5);
311
+ }
312
+ act() {
313
+ return 'acted';
314
+ }
315
+ }
316
+ const RSvc = Reactive(Svc);
317
+ const r: any = reactive(new RSvc());
318
+ // Bound method + ref cache resolve against the raw target, not the proxy.
319
+ expect(r.act()).toBe('acted');
320
+ // Vue's reactive proxy AUTO-UNWRAPS a ref returned from the getter, so
321
+ // through the proxy you read the value directly (no .value needed).
322
+ expect(r.val).toBe(5);
323
+ const raw = toRaw(r);
324
+ expect(raw).not.toBe(r); // proxy != raw
325
+ // caches live on the raw object, not the proxy
326
+ expect(Object.getOwnPropertySymbols(raw).length).toBeGreaterThan(0);
327
+ });
328
+ });
329
+
330
+ describe('raw resolution through proxy chains (resolveRaw)', () => {
331
+ // REGRESSION: the engine once resolved the raw as
332
+ // `this[RAW] ?? (this[RAW] = toRaw(this))`. Reading the symbol-keyed
333
+ // RAW back-pointer through a Vue reactive proxy DEEP-WRAPS the returned
334
+ // object (`reactive(raw)`), so once the pointer was stamped, the first
335
+ // access of any OTHER method through the proxy bound that method to the
336
+ // PROXY and cached it on the true raw — after which `this.x.value`
337
+ // crashed for every caller, because the proxy auto-unwraps refs.
338
+ it('a method first-accessed through reactive() AFTER the pointer is stamped still binds to the raw (no cache poisoning)', () => {
339
+ class Box {
340
+ get count() {
341
+ return ref(0);
342
+ }
343
+ inc() {
344
+ this.count.value++;
345
+ }
346
+ dec() {
347
+ this.count.value--;
348
+ }
349
+ }
350
+ const RBox = Reactive(Box);
351
+ const instance: any = new RBox();
352
+ const p: any = reactive(instance);
353
+
354
+ // First method access — pointer unstamped — bound correctly and
355
+ // stamps the RAW back-pointer.
356
+ void p.inc;
357
+ // Second method's FIRST access happens with the pointer stamped:
358
+ // this is the poisoning path.
359
+ const dec = p.dec;
360
+ expect(() => dec()).not.toThrow();
361
+ expect(instance.count.value).toBe(-1);
362
+ // The cached function keeps working from every access path.
363
+ p.inc();
364
+ instance.inc();
365
+ expect(instance.count.value).toBe(1);
366
+ });
367
+
368
+ // Vue's component expose proxy is a plain (non-Vue-reactive) Proxy that
369
+ // does not answer `__v_raw`, so `toRaw()` cannot see through it; the
370
+ // engine must fall back to the RAW back-pointer — and normalize it,
371
+ // because the pointer read through the chain comes back deep-wrapped.
372
+ it('resolves the true raw through an opaque foreign proxy (component expose-proxy shape)', () => {
373
+ class Box {
374
+ get count() {
375
+ return ref(0);
376
+ }
377
+ inc() {
378
+ this.count.value++;
379
+ }
380
+ dec() {
381
+ this.count.value--;
382
+ }
383
+ }
384
+ const RBox = Reactive(Box);
385
+ const instance: any = new RBox();
386
+ const p: any = reactive(instance);
387
+ void p.inc; // stamp the pointer via the normal component path
388
+
389
+ const foreign: any = new Proxy(p, {
390
+ get(target, key, receiver) {
391
+ if (key === '__v_raw') return undefined; // opaque to toRaw()
392
+ return Reflect.get(target, key, receiver); // accessors run with this=foreign
393
+ },
394
+ });
395
+
396
+ // First access of `dec` happens THROUGH the foreign chain.
397
+ const dec = foreign.dec;
398
+ expect(() => dec()).not.toThrow();
399
+ expect(instance.count.value).toBe(-1);
400
+ // Ref cells materialized through the foreign chain land on the raw.
401
+ expect(foreign.count).toBe(-1); // auto-unwrapped via the reactive layer
402
+ instance.inc();
403
+ expect(instance.count.value).toBe(0);
404
+ });
405
+ });
406
+
407
+ describe('inheritance & super chains', () => {
408
+ it('resolves getters across a 3-level chain with super.x.value', () => {
409
+ class Base {
410
+ get tag() {
411
+ return ref('div');
412
+ }
413
+ get summary() {
414
+ return computed(() => `[Base:${(this as any).tag.value}]`);
415
+ }
416
+ get chain() {
417
+ return 'Base';
418
+ }
419
+ }
420
+ class Mid extends Base {
421
+ get summary() {
422
+ return computed(() => `(Mid>${super.summary.value})`);
423
+ }
424
+ get chain() {
425
+ return super.chain + '->Mid';
426
+ }
427
+ }
428
+ class Leaf extends Mid {
429
+ get summary() {
430
+ return computed(() => `{Leaf>${super.summary.value}}`);
431
+ }
432
+ get chain() {
433
+ return super.chain + '->Leaf';
434
+ }
435
+ }
436
+ const instance: any = new (Reactive(Leaf))();
437
+ expect(instance.summary.value).toBe('{Leaf>(Mid>[Base:div])}');
438
+ // de-opt plain-value getter chain via super
439
+ expect(instance.chain).toBe('Base->Mid->Leaf');
440
+ });
441
+
442
+ it('super computed and child computed are cached under different symbols (no collision)', () => {
443
+ class Base {
444
+ get val() {
445
+ return computed(() => 1);
446
+ }
447
+ }
448
+ class Child extends Base {
449
+ get val() {
450
+ return computed(() => 10 + super.val.value);
451
+ }
452
+ }
453
+ const instance: any = new (Reactive(Child))();
454
+ expect(instance.val.value).toBe(11);
455
+ // Both a base-level and child-level cache symbol exist on the instance.
456
+ expect(Object.getOwnPropertySymbols(instance).length).toBeGreaterThanOrEqual(
457
+ 2,
458
+ );
459
+ });
460
+
461
+ it('inherited methods bind correctly', () => {
462
+ class Base {
463
+ hello() {
464
+ return 'base';
465
+ }
466
+ }
467
+ class Child extends Base {
468
+ world() {
469
+ return this.hello() + '+child';
470
+ }
471
+ }
472
+ const instance: any = new (Reactive(Child))();
473
+ expect(instance.world()).toBe('base+child');
474
+ });
475
+ });
476
+
477
+ describe('deep inheritance (parent / grandparent / great-grandparent)', () => {
478
+ // L1 (great-grandparent) -> L2 -> L3 -> L4 (child)
479
+ class L1 {
480
+ get base() {
481
+ return ref(10);
482
+ }
483
+ get tag() {
484
+ return computed(() => `L1:${(this as any).base.value}`);
485
+ }
486
+ get name() {
487
+ return 'L1';
488
+ }
489
+ greet() {
490
+ return 'hi-L1';
491
+ }
492
+ }
493
+ class L2 extends L1 {
494
+ get tag() {
495
+ return computed(() => `L2(${super.tag.value})`);
496
+ }
497
+ get name() {
498
+ return super.name + '>L2';
499
+ }
500
+ greet() {
501
+ return super.greet() + '/L2';
502
+ }
503
+ }
504
+ class L3 extends L2 {
505
+ get extra() {
506
+ return ref(5);
507
+ }
508
+ get tag() {
509
+ return computed(() => `L3[${super.tag.value}]`);
510
+ }
511
+ get name() {
512
+ return super.name + '>L3';
513
+ }
514
+ }
515
+ class L4 extends L3 {
516
+ get tag() {
517
+ return computed(() => `L4{${super.tag.value}}`);
518
+ }
519
+ get name() {
520
+ return super.name + '>L4';
521
+ }
522
+ // computed in the child aggregating refs declared 3 and 1 levels up
523
+ get sum() {
524
+ return computed(
525
+ () => (this as any).base.value + (this as any).extra.value,
526
+ );
527
+ }
528
+ greet() {
529
+ return super.greet() + '/L4';
530
+ }
531
+ }
532
+ Reactive(L4);
533
+
534
+ it('computed super-chains resolve through 4 levels', () => {
535
+ const d: any = new L4();
536
+ // L4 wraps L3 wraps L2 wraps L1
537
+ expect(d.tag.value).toBe('L4{L3[L2(L1:10)]}');
538
+ });
539
+
540
+ it('plain-getter (de-opt) super-chains resolve through 4 levels', () => {
541
+ const d: any = new L4();
542
+ expect(d.name).toBe('L1>L2>L3>L4');
543
+ });
544
+
545
+ it('refs declared in ancestors are inherited and aggregated by a child computed', () => {
546
+ const d: any = new L4();
547
+ expect(d.base.value).toBe(10); // from great-grandparent
548
+ expect(d.extra.value).toBe(5); // from grandparent
549
+ expect(d.sum.value).toBe(15);
550
+ });
551
+
552
+ it('mutating an ANCESTOR ref re-runs the full computed chain (reactivity through inheritance)', () => {
553
+ const d: any = new L4();
554
+ expect(d.tag.value).toBe('L4{L3[L2(L1:10)]}');
555
+ expect(d.sum.value).toBe(15);
556
+
557
+ d.base.value = 20; // great-grandparent ref
558
+ expect(d.tag.value).toBe('L4{L3[L2(L1:20)]}'); // chain recomputed
559
+ expect(d.sum.value).toBe(25);
560
+
561
+ d.extra.value = 7; // grandparent ref
562
+ expect(d.sum.value).toBe(27);
563
+ });
564
+
565
+ it('each level resolves correctly as a standalone instance', () => {
566
+ expect((new L2() as any).tag.value).toBe('L2(L1:10)');
567
+ expect((new L3() as any).tag.value).toBe('L3[L2(L1:10)]');
568
+ expect((new L2() as any).name).toBe('L1>L2');
569
+ expect((new L3() as any).name).toBe('L1>L2>L3');
570
+ });
571
+
572
+ it('every level caches its own computed under a distinct symbol (no shadow collision)', () => {
573
+ const d: any = new L4();
574
+ d.tag.value; // materializes L4, L3, L2, L1 caches via the super chain
575
+ // 4 distinct computed cache symbols (+ base ref once it is read)
576
+ d.base.value;
577
+ const symbols = Object.getOwnPropertySymbols(d).filter(
578
+ (s) =>
579
+ s !==
580
+ Object.getOwnPropertySymbols(d).find(
581
+ (x) => x.toString() === 'Symbol(ivue.raw)',
582
+ ),
583
+ );
584
+ // at least the four tag Refs + base must coexist (RAW pointer excluded)
585
+ expect(symbols.length).toBeGreaterThanOrEqual(5);
586
+ });
587
+
588
+ it('super method calls chain through 3 levels', () => {
589
+ const d: any = new L4();
590
+ expect(d.greet()).toBe('hi-L1/L2/L4'); // L3 does not override greet()
591
+ });
592
+ });
593
+
594
+ describe('idempotence (PROCESSED flag)', () => {
595
+ it('calling Reactive twice on the same class is safe and a no-op the 2nd time', () => {
596
+ class Foo {
597
+ get x() {
598
+ return ref(1);
599
+ }
600
+ m() {
601
+ return 2;
602
+ }
603
+ }
604
+ const A = Reactive(Foo);
605
+ const B = Reactive(Foo); // second pass must not double-wrap
606
+ expect(A).toBe(B);
607
+ const instance: any = new B();
608
+ expect(instance.x.value).toBe(1);
609
+ expect(instance.m()).toBe(2);
610
+ });
611
+
612
+ it('Reactive(Parent) then Reactive(Child) skips the already-processed parent proto', () => {
613
+ class Parent {
614
+ get p() {
615
+ return ref('p');
616
+ }
617
+ }
618
+ const RParent = Reactive(Parent);
619
+ class Child extends RParent {
620
+ get c() {
621
+ return ref('c');
622
+ }
623
+ }
624
+ const RChild = Reactive(Child as any);
625
+ const instance: any = new RChild();
626
+ expect(instance.p.value).toBe('p'); // parent getter still works (processed once)
627
+ expect(instance.c.value).toBe('c');
628
+ });
629
+ });
630
+
631
+ describe('non-transformed members', () => {
632
+ it('setter-only accessors are left as native setters (not transformed)', () => {
633
+ let received: any = null;
634
+ class SetterOnly {
635
+ _v = 0;
636
+ // getter present so we can read back; setter-only target is `sink`
637
+ set sink(v: number) {
638
+ received = v;
639
+ this._v = v;
640
+ }
641
+ get readV() {
642
+ return ref(this._v);
643
+ }
644
+ }
645
+ const instance: any = new (Reactive(SetterOnly))();
646
+ instance.sink = 55;
647
+ expect(received).toBe(55);
648
+ expect(instance._v).toBe(55);
649
+ });
650
+
651
+ it('plain instance data fields are untouched', () => {
652
+ class Data {
653
+ name = 'hello';
654
+ nums = [1, 2, 3];
655
+ }
656
+ const instance: any = new (Reactive(Data))();
657
+ expect(instance.name).toBe('hello');
658
+ expect(instance.nums).toEqual([1, 2, 3]);
659
+ });
660
+ });
661
+
662
+ describe('$stopEffects teardown', () => {
663
+ it('clears cached computeds so they re-materialize fresh after teardown', () => {
664
+ class Store {
665
+ get x() {
666
+ return ref(2);
667
+ }
668
+ get doubled() {
669
+ return computed(() => (this as any).x.value * 2);
670
+ }
671
+ }
672
+ const instance: any = new (Reactive(Store))();
673
+ const c = instance.doubled; // materialize + cache the computed
674
+ expect(c.value).toBe(4);
675
+
676
+ (instance as ReactiveInstance<Store>).$stopEffects();
677
+
678
+ // caches cleared → a fresh computed is produced on next access
679
+ const c2 = instance.doubled;
680
+ expect(c2).not.toBe(c);
681
+ expect(c2.value).toBe(4);
682
+ // no cache symbols should survive teardown (until re-access creates them)
683
+ // (c2 access above re-created exactly one)
684
+ expect(Object.getOwnPropertySymbols(toRaw(instance)).length).toBeGreaterThan(
685
+ 0,
686
+ );
687
+ });
688
+
689
+ it('deletes cached bound methods without error', () => {
690
+ class Store {
691
+ ping() {
692
+ return 'pong';
693
+ }
694
+ }
695
+ const instance: any = new (Reactive(Store))();
696
+ const m1 = instance.ping; // cache a bound fn (has no .effect)
697
+ expect(m1()).toBe('pong');
698
+ (instance as any).$stopEffects();
699
+ const m2 = instance.ping; // re-bound after cache clear
700
+ expect(m2).not.toBe(m1);
701
+ expect(m2()).toBe('pong');
702
+ });
703
+
704
+ it('never auto-calls user methods on teardown — no hooks, by design', () => {
705
+ let called = 0;
706
+ class Store {
707
+ get x() {
708
+ return ref(1);
709
+ }
710
+ // an ordinary method that HAPPENS to be named like the old hook —
711
+ // ivue must not invoke it (ivue never calls user code)
712
+ stopEffects() {
713
+ called++;
714
+ }
715
+ }
716
+ const instance: any = new (Reactive(Store))();
717
+ const originalX = instance.x; // materialize a cache entry
718
+ (instance as any).$stopEffects();
719
+ expect(called).toBe(0);
720
+ expect(instance.x).not.toBe(originalX); // teardown still complete
721
+ });
722
+
723
+ it('skips the RAW symbol while iterating (no crash on the raw anchor)', () => {
724
+ class Store {
725
+ m() {
726
+ return 1;
727
+ }
728
+ get r() {
729
+ return ref(9);
730
+ }
731
+ }
732
+ const instance: any = new (Reactive(Store))();
733
+ instance.m(); // sets the RAW anchor symbol + a bound-method symbol
734
+ instance.r; // sets a ref cache symbol
735
+ expect(() => instance.$stopEffects()).not.toThrow();
736
+ });
737
+
738
+ it('$stopEffects is injected only once (idempotent re-Reactive)', () => {
739
+ class Store {
740
+ m() {
741
+ return 1;
742
+ }
743
+ }
744
+ Reactive(Store);
745
+ const desc1 = Object.getOwnPropertyDescriptor(
746
+ Store.prototype,
747
+ '$stopEffects',
748
+ );
749
+ Reactive(Store); // must not redefine
750
+ const desc2 = Object.getOwnPropertyDescriptor(
751
+ Store.prototype,
752
+ '$stopEffects',
753
+ );
754
+ expect(desc1!.value).toBe(desc2!.value);
755
+ });
756
+ });
757
+
758
+ describe('$watch + lazy effect scope', () => {
759
+ it('a watcher registered via $watch fires on change', () => {
760
+ class Store {
761
+ get count() {
762
+ return ref(0);
763
+ }
764
+ }
765
+ const instance: any = new (Reactive(Store))();
766
+ const seen: number[] = [];
767
+ instance.$watch(
768
+ () => instance.count.value,
769
+ (v: number) => seen.push(v),
770
+ { flush: 'sync' },
771
+ );
772
+ instance.count.value = 1;
773
+ instance.count.value = 2;
774
+ expect(seen).toEqual([1, 2]);
775
+ });
776
+
777
+ it('$stopEffects stops watchers created via $watch', () => {
778
+ class Store {
779
+ get count() {
780
+ return ref(0);
781
+ }
782
+ }
783
+ const instance: any = new (Reactive(Store))();
784
+ const seen: number[] = [];
785
+ instance.$watch(
786
+ () => instance.count.value,
787
+ (v: number) => seen.push(v),
788
+ { flush: 'sync' },
789
+ );
790
+ instance.count.value = 1;
791
+ expect(seen).toEqual([1]);
792
+
793
+ instance.$stopEffects();
794
+
795
+ instance.count.value = 2; // scope stopped → no more callbacks
796
+ expect(seen).toEqual([1]);
797
+ });
798
+
799
+ it('pure-data instances never allocate a scope (zero overhead)', () => {
800
+ class Store {
801
+ get count() {
802
+ return ref(0);
803
+ }
804
+ bump() {
805
+ return 1;
806
+ }
807
+ }
808
+ const instance: any = new (Reactive(Store))();
809
+ instance.count.value;
810
+ instance.bump();
811
+ // No scope symbol exists because $watch was never called.
812
+ const hasScope = Object.getOwnPropertySymbols(instance).some(
813
+ (s) => s.toString() === 'Symbol(ivue_scope)',
814
+ );
815
+ expect(hasScope).toBe(false);
816
+ // $stopEffects is still safe with no scope present
817
+ expect(() => instance.$stopEffects()).not.toThrow();
818
+ });
819
+
820
+ it('reuses the same scope across multiple $watch calls', () => {
821
+ class Store {
822
+ get a() {
823
+ return ref(0);
824
+ }
825
+ get b() {
826
+ return ref(0);
827
+ }
828
+ }
829
+ const instance: any = new (Reactive(Store))();
830
+ const seen: string[] = [];
831
+ instance.$watch(
832
+ () => instance.a.value,
833
+ () => seen.push('a'),
834
+ { flush: 'sync' },
835
+ );
836
+ instance.$watch(
837
+ () => instance.b.value,
838
+ () => seen.push('b'),
839
+ { flush: 'sync' },
840
+ ); // reuses scope
841
+ instance.a.value = 1;
842
+ instance.b.value = 1;
843
+ expect(seen).toEqual(['a', 'b']);
844
+ // Both watchers stop together via the single shared scope
845
+ instance.$stopEffects();
846
+ instance.a.value = 2;
847
+ instance.b.value = 2;
848
+ expect(seen).toEqual(['a', 'b']);
849
+ });
850
+ });
851
+
852
+ describe('exotic prototype chains', () => {
853
+ it('handles a chain that bottoms out at null (no Object.prototype)', () => {
854
+ function NullBase(this: any) {}
855
+ NullBase.prototype = Object.create(null); // proto chain ends at null
856
+ class Child extends (NullBase as any) {
857
+ get x() {
858
+ return ref(7);
859
+ }
860
+ }
861
+ const R = Reactive(Child as any);
862
+ const instance: any = new R();
863
+ expect(instance.x.value).toBe(7); // walked past the null-proto base safely
864
+ });
865
+ });
866
+ });
867
+
868
+ describe('isClass()', () => {
869
+ it('returns false for non-functions', () => {
870
+ expect(isClass(42)).toBe(false);
871
+ expect(isClass('str')).toBe(false);
872
+ expect(isClass(null)).toBe(false);
873
+ expect(isClass({})).toBe(false);
874
+ expect(isClass(undefined)).toBe(false);
875
+ });
876
+
877
+ it('returns false for arrow functions (no prototype)', () => {
878
+ expect(isClass(() => {})).toBe(false);
879
+ });
880
+
881
+ it('returns false for normal functions (writable prototype)', () => {
882
+ expect(isClass(function named() {})).toBe(false);
883
+ function decl() {}
884
+ expect(isClass(decl)).toBe(false);
885
+ });
886
+
887
+ it('returns true for ES classes (non-writable prototype)', () => {
888
+ class Foo {}
889
+ expect(isClass(Foo)).toBe(true);
890
+ expect(isClass(class extends Foo {})).toBe(true);
891
+ });
892
+ });
893
+
894
+ describe('propsWithDefaults()', () => {
895
+ it('skips required props and props with no default', () => {
896
+ const typed = {
897
+ a: { type: String, required: true },
898
+ b: { type: Number },
899
+ };
900
+ const defaults = { a: 'should-be-ignored' };
901
+ const out = propsWithDefaults(defaults, { ...typed }) as Record<
902
+ string,
903
+ any
904
+ >;
905
+ expect('default' in out.a).toBe(false); // required → skipped
906
+ expect('default' in out.b).toBe(false); // def === undefined → skipped
907
+ });
908
+
909
+ it('assigns primitive & function defaults directly (no factory)', () => {
910
+ const fn = () => 'hi';
911
+ const typed = {
912
+ s: { type: String },
913
+ n: { type: Number },
914
+ b: { type: Boolean },
915
+ fn: { type: Function },
916
+ nul: { type: Object },
917
+ };
918
+ const defaults = { s: 'x', n: 5, b: true, fn, nul: null };
919
+ const out = propsWithDefaults(defaults, { ...typed }) as Record<
920
+ string,
921
+ any
922
+ >;
923
+ expect(out.s.default).toBe('x');
924
+ expect(out.n.default).toBe(5);
925
+ expect(out.b.default).toBe(true);
926
+ expect(out.fn.default).toBe(fn); // function passed through directly
927
+ expect(out.nul.default).toBe(null); // null → else branch, assigned directly
928
+ });
929
+
930
+ it('wraps object/array defaults in a factory that structuredClones', () => {
931
+ const typed = { o: { type: Object }, a: { type: Array } };
932
+ const defaults = { o: { nested: { k: 1 } }, a: [1, 2, 3] };
933
+ const out = propsWithDefaults(defaults, { ...typed }) as Record<
934
+ string,
935
+ any
936
+ >;
937
+
938
+ expect(typeof out.o.default).toBe('function');
939
+ const o1 = out.o.default();
940
+ const o2 = out.o.default();
941
+ expect(o1).toEqual({ nested: { k: 1 } });
942
+ expect(o1).not.toBe(o2); // fresh clone each call
943
+ expect(o1.nested).not.toBe(o2.nested); // deep clone
944
+ o1.nested.k = 999;
945
+ expect(o2.nested.k).toBe(1); // isolation
946
+
947
+ const a1 = out.a.default();
948
+ const a2 = out.a.default();
949
+ expect(a1).toEqual([1, 2, 3]);
950
+ expect(a1).not.toBe(a2);
951
+ });
952
+
953
+ it('uses a custom cloner when provided', () => {
954
+ const typed = { o: { type: Object } };
955
+ const defaults = { o: { k: 1 } };
956
+ const cloner = vi.fn((v: any) => ({ ...v, cloned: true }));
957
+ const out = propsWithDefaults(defaults, { ...typed }, cloner) as Record<
958
+ string,
959
+ any
960
+ >;
961
+ const v = out.o.default();
962
+ expect(cloner).toHaveBeenCalledWith(defaults.o);
963
+ expect(v).toEqual({ k: 1, cloned: true });
964
+ });
965
+
966
+ it('wraps a class default in a factory that returns the class itself', () => {
967
+ class Cool {
968
+ x = 1;
969
+ }
970
+ const typed = { c: { type: Object } };
971
+ const defaults = { c: Cool };
972
+ const out = propsWithDefaults(defaults, { ...typed }) as Record<
973
+ string,
974
+ any
975
+ >;
976
+ expect(typeof out.c.default).toBe('function');
977
+ expect(out.c.default()).toBe(Cool); // not instantiated, not cloned
978
+ });
979
+
980
+ it('never mutates the input descriptors — shared type maps stay clean', () => {
981
+ // Descriptors are routinely SHARED between props maps via spread
982
+ // (`{ ...baseParamsTypes }` copies the outer object only). A mutating
983
+ // implementation would rewrite the base component's defaults when a
984
+ // wrapper applies different ones.
985
+ const typed = { a: { type: Number }, b: { type: String } };
986
+ const aRef = typed.a;
987
+ const out = propsWithDefaults({ a: 1 }, typed);
988
+ expect(out.a).not.toBe(aRef); // copied, not augmented in place
989
+ expect(out.a.default).toBe(1);
990
+ expect('default' in aRef).toBe(false); // the shared input is untouched
991
+ expect('default' in out.b).toBe(false);
992
+
993
+ // the wrapper scenario: same descriptors, different defaults
994
+ const wrapperOut = propsWithDefaults({ a: 2 }, { ...typed });
995
+ expect(wrapperOut.a.default).toBe(2);
996
+ expect(out.a.default).toBe(1); // base defaults survive
997
+ });
998
+
999
+
1000
+ });
1001
+
1002
+
1003
+ describe('$watchEffect (scoped watchEffect)', () => {
1004
+ it('runs immediately, re-runs on dep change, and is stopped by $stopEffects', async () => {
1005
+ class Box {
1006
+ get n() {
1007
+ return ref(1);
1008
+ }
1009
+ }
1010
+ const R = Reactive(Box);
1011
+ const instance: any = new R();
1012
+ let runs = 0;
1013
+ let last = 0;
1014
+ instance.$watchEffect(() => {
1015
+ runs++;
1016
+ last = instance.n.value;
1017
+ });
1018
+ expect(runs).toBe(1);
1019
+ expect(last).toBe(1);
1020
+ instance.n.value = 5;
1021
+ await nextTick();
1022
+ expect(runs).toBe(2);
1023
+ expect(last).toBe(5);
1024
+ instance.$stopEffects();
1025
+ instance.n.value = 9;
1026
+ await nextTick();
1027
+ expect(runs).toBe(2); // scope stopped — no further runs
1028
+ });
1029
+
1030
+ it('returns a stop handle and shares the $watch scope', async () => {
1031
+ class Box {
1032
+ get n() {
1033
+ return ref(0);
1034
+ }
1035
+ }
1036
+ const R = Reactive(Box);
1037
+ const instance: any = new R();
1038
+ let effectRuns = 0;
1039
+ let watchRuns = 0;
1040
+ const stop = instance.$watchEffect(() => {
1041
+ effectRuns++;
1042
+ void instance.n.value;
1043
+ });
1044
+ instance.$watch(
1045
+ () => instance.n.value,
1046
+ () => {
1047
+ watchRuns++;
1048
+ }
1049
+ );
1050
+ stop(); // individual stop, scope stays alive
1051
+ instance.n.value = 3;
1052
+ await nextTick();
1053
+ expect(effectRuns).toBe(1); // stopped
1054
+ expect(watchRuns).toBe(1); // sibling watcher in the same scope still fires
1055
+ });
1056
+ });