mutts 1.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.
Files changed (82) hide show
  1. package/README.md +150 -0
  2. package/dist/chunks/decorator-BXsign4Z.js +176 -0
  3. package/dist/chunks/decorator-BXsign4Z.js.map +1 -0
  4. package/dist/chunks/decorator-CPbZNnsX.esm.js +168 -0
  5. package/dist/chunks/decorator-CPbZNnsX.esm.js.map +1 -0
  6. package/dist/decorator.d.ts +50 -0
  7. package/dist/decorator.esm.js +2 -0
  8. package/dist/decorator.esm.js.map +1 -0
  9. package/dist/decorator.js +11 -0
  10. package/dist/decorator.js.map +1 -0
  11. package/dist/destroyable.d.ts +48 -0
  12. package/dist/destroyable.esm.js +91 -0
  13. package/dist/destroyable.esm.js.map +1 -0
  14. package/dist/destroyable.js +98 -0
  15. package/dist/destroyable.js.map +1 -0
  16. package/dist/eventful.d.ts +11 -0
  17. package/dist/eventful.esm.js +88 -0
  18. package/dist/eventful.esm.js.map +1 -0
  19. package/dist/eventful.js +90 -0
  20. package/dist/eventful.js.map +1 -0
  21. package/dist/index.d.ts +15 -0
  22. package/dist/index.esm.js +7 -0
  23. package/dist/index.esm.js.map +1 -0
  24. package/dist/index.js +52 -0
  25. package/dist/index.js.map +1 -0
  26. package/dist/indexable.d.ts +31 -0
  27. package/dist/indexable.esm.js +85 -0
  28. package/dist/indexable.esm.js.map +1 -0
  29. package/dist/indexable.js +89 -0
  30. package/dist/indexable.js.map +1 -0
  31. package/dist/mutts.umd.js +2 -0
  32. package/dist/mutts.umd.js.map +1 -0
  33. package/dist/mutts.umd.min.js +2 -0
  34. package/dist/mutts.umd.min.js.map +1 -0
  35. package/dist/promiseChain.d.ts +11 -0
  36. package/dist/promiseChain.esm.js +72 -0
  37. package/dist/promiseChain.esm.js.map +1 -0
  38. package/dist/promiseChain.js +74 -0
  39. package/dist/promiseChain.js.map +1 -0
  40. package/dist/reactive.d.ts +114 -0
  41. package/dist/reactive.esm.js +1455 -0
  42. package/dist/reactive.esm.js.map +1 -0
  43. package/dist/reactive.js +1472 -0
  44. package/dist/reactive.js.map +1 -0
  45. package/dist/std-decorators.d.ts +17 -0
  46. package/dist/std-decorators.esm.js +161 -0
  47. package/dist/std-decorators.esm.js.map +1 -0
  48. package/dist/std-decorators.js +169 -0
  49. package/dist/std-decorators.js.map +1 -0
  50. package/docs/decorator.md +300 -0
  51. package/docs/destroyable.md +294 -0
  52. package/docs/events.md +225 -0
  53. package/docs/indexable.md +561 -0
  54. package/docs/promiseChain.md +218 -0
  55. package/docs/reactive.md +2072 -0
  56. package/docs/std-decorators.md +558 -0
  57. package/package.json +132 -0
  58. package/src/decorator.test.ts +495 -0
  59. package/src/decorator.ts +205 -0
  60. package/src/destroyable.test.ts +155 -0
  61. package/src/destroyable.ts +158 -0
  62. package/src/eventful.test.ts +380 -0
  63. package/src/eventful.ts +69 -0
  64. package/src/index.ts +7 -0
  65. package/src/indexable.test.ts +388 -0
  66. package/src/indexable.ts +124 -0
  67. package/src/promiseChain.test.ts +201 -0
  68. package/src/promiseChain.ts +99 -0
  69. package/src/reactive/array.test.ts +923 -0
  70. package/src/reactive/array.ts +352 -0
  71. package/src/reactive/core.test.ts +1663 -0
  72. package/src/reactive/core.ts +866 -0
  73. package/src/reactive/index.ts +28 -0
  74. package/src/reactive/interface.test.ts +1477 -0
  75. package/src/reactive/interface.ts +231 -0
  76. package/src/reactive/map.test.ts +866 -0
  77. package/src/reactive/map.ts +162 -0
  78. package/src/reactive/set.test.ts +289 -0
  79. package/src/reactive/set.ts +142 -0
  80. package/src/std-decorators.test.ts +679 -0
  81. package/src/std-decorators.ts +182 -0
  82. package/src/utils.ts +52 -0
@@ -0,0 +1,1477 @@
1
+ import { computed, effect, reactive, watch } from './index'
2
+
3
+ describe('computed', () => {
4
+ it('returns computed value and caches it', () => {
5
+ const state = reactive({ a: 1, b: 2 })
6
+ let runs = 0
7
+ const getter = () => {
8
+ runs++
9
+ return state.a + state.b
10
+ }
11
+
12
+ const v1 = computed(getter)
13
+ expect(v1).toBe(3)
14
+ expect(runs).toBe(1)
15
+
16
+ const v2 = computed(getter)
17
+ expect(v2).toBe(3)
18
+ // still cached, no re-run
19
+ expect(runs).toBe(1)
20
+ })
21
+
22
+ it('recomputes after a dependency change (at least once)', () => {
23
+ const state = reactive({ a: 1, b: 2 })
24
+ let runs = 0
25
+ const getter = () => {
26
+ runs++
27
+ return state.a + state.b
28
+ }
29
+
30
+ // initial compute
31
+ expect(computed(getter)).toBe(3)
32
+ expect(runs).toBe(1)
33
+
34
+ // mutate dependency -> internal effect should refresh cache once
35
+ state.a = 5
36
+ expect(computed(getter)).toBe(7)
37
+ // getter should have run again exactly once
38
+ expect(runs).toBe(2)
39
+ })
40
+
41
+ it('flows with the effects', () => {
42
+ const state = reactive({ a: 1, b: 2 })
43
+ let runs = 0
44
+ const getter = () => {
45
+ runs++
46
+ return state.b + 1
47
+ }
48
+
49
+ effect(() => {
50
+ state.a = computed(getter) + 1
51
+ })
52
+ // initial compute
53
+ expect(computed(getter)).toBe(3)
54
+ expect(runs).toBe(1)
55
+
56
+ // mutate dependency -> internal effect should refresh cache once
57
+ state.b = 3
58
+ expect(state.a).toBe(5)
59
+ // getter should have run again exactly once
60
+ expect(runs).toBe(2)
61
+ expect(computed(getter)).toBe(4)
62
+ // getter should have not run again
63
+ expect(runs).toBe(2)
64
+ })
65
+
66
+ it('should properly track dependencies when computed is called within an effect', () => {
67
+ const state = reactive({ a: 1, b: 2, c: 0 })
68
+ let effectRuns = 0
69
+ let computedRuns = 0
70
+
71
+ const getter = () => {
72
+ computedRuns++
73
+ return state.a + state.b
74
+ }
75
+
76
+ effect(() => {
77
+ effectRuns++
78
+ // This should properly track the computed's dependencies
79
+ state.c = computed(getter)
80
+ })
81
+
82
+ // Initial run
83
+ expect(effectRuns).toBe(1)
84
+ expect(computedRuns).toBe(1)
85
+ expect(state.c).toBe(3)
86
+
87
+ // Change a dependency of the computed
88
+ state.a = 5
89
+ // The effect should re-run because it depends on the computed
90
+ expect(effectRuns).toBe(2)
91
+ expect(computedRuns).toBe(2)
92
+ expect(state.c).toBe(7)
93
+
94
+ // Change another dependency
95
+ state.b = 10
96
+ expect(effectRuns).toBe(3)
97
+ expect(computedRuns).toBe(3)
98
+ expect(state.c).toBe(15)
99
+ })
100
+
101
+ it('should properly invalidate computed cache when dependencies change within effect', () => {
102
+ const state = reactive({ a: 1, b: 2, result: 0 })
103
+ let effectRuns = 0
104
+ let computedRuns = 0
105
+
106
+ const getter = () => {
107
+ computedRuns++
108
+ return state.a * state.b
109
+ }
110
+
111
+ effect(() => {
112
+ effectRuns++
113
+ // The computed should be properly tracked and invalidated
114
+ state.result = computed(getter)
115
+ })
116
+
117
+ // Initial run
118
+ expect(effectRuns).toBe(1)
119
+ expect(computedRuns).toBe(1)
120
+ expect(state.result).toBe(2)
121
+
122
+ // Change dependency - effect should re-run and computed should re-execute
123
+ state.a = 3
124
+ expect(effectRuns).toBe(2)
125
+ expect(computedRuns).toBe(2)
126
+ expect(state.result).toBe(6)
127
+
128
+ // Change another dependency
129
+ state.b = 4
130
+ expect(effectRuns).toBe(3)
131
+ expect(computedRuns).toBe(3)
132
+ expect(state.result).toBe(12)
133
+ })
134
+ })
135
+
136
+ describe('watch', () => {
137
+ describe('watch with value function', () => {
138
+ it('should watch a specific value and trigger on changes', () => {
139
+ const state = reactive({ count: 0, name: 'John' })
140
+ let newValue: number | undefined
141
+ let oldValue: number | undefined
142
+ let callCount = 0
143
+
144
+ const stop = watch(
145
+ () => state.count,
146
+ (newVal, oldVal) => {
147
+ newValue = newVal
148
+ oldValue = oldVal
149
+ callCount++
150
+ }
151
+ )
152
+
153
+ expect(callCount).toBe(0) // Should not trigger on setup
154
+
155
+ state.count = 5
156
+ expect(callCount).toBe(1)
157
+ expect(newValue).toBe(5)
158
+ expect(oldValue).toBe(0)
159
+
160
+ state.count = 10
161
+ expect(callCount).toBe(2)
162
+ expect(newValue).toBe(10)
163
+ expect(oldValue).toBe(5)
164
+
165
+ // Changing other properties should not trigger
166
+ state.name = 'Jane'
167
+ expect(callCount).toBe(2) // Should remain 2
168
+
169
+ stop()
170
+ })
171
+
172
+ it('should not trigger when watching non-reactive values', () => {
173
+ const state = reactive({ count: 0 })
174
+ let callCount = 0
175
+
176
+ const stop = watch(
177
+ () => 42, // Non-reactive value
178
+ () => {
179
+ callCount++
180
+ }
181
+ )
182
+
183
+ state.count = 5
184
+ expect(callCount).toBe(0) // Should not trigger
185
+
186
+ stop()
187
+ })
188
+
189
+ it('should handle multiple watchers on the same value', () => {
190
+ const state = reactive({ count: 0 })
191
+ let watcher1Calls = 0
192
+ let watcher2Calls = 0
193
+
194
+ const stop1 = watch(
195
+ () => state.count,
196
+ () => {
197
+ watcher1Calls++
198
+ }
199
+ )
200
+
201
+ const stop2 = watch(
202
+ () => state.count,
203
+ () => {
204
+ watcher2Calls++
205
+ }
206
+ )
207
+
208
+ state.count = 5
209
+ expect(watcher1Calls).toBe(1)
210
+ expect(watcher2Calls).toBe(1)
211
+
212
+ state.count = 10
213
+ expect(watcher1Calls).toBe(2)
214
+ expect(watcher2Calls).toBe(2)
215
+
216
+ stop1()
217
+ stop2()
218
+ })
219
+
220
+ it('should stop watching when cleanup is called', () => {
221
+ const state = reactive({ count: 0 })
222
+ let callCount = 0
223
+
224
+ const stop = watch(
225
+ () => state.count,
226
+ () => {
227
+ callCount++
228
+ }
229
+ )
230
+
231
+ state.count = 5
232
+ expect(callCount).toBe(1)
233
+
234
+ stop()
235
+
236
+ state.count = 10
237
+ expect(callCount).toBe(1) // Should not increment after stop
238
+ })
239
+
240
+ it('should handle computed values in watch', () => {
241
+ const state = reactive({ a: 1, b: 2 })
242
+ let callCount = 0
243
+ let lastValue: number | undefined
244
+
245
+ const stop = watch(
246
+ () => state.a + state.b,
247
+ (newValue) => {
248
+ lastValue = newValue
249
+ callCount++
250
+ }
251
+ )
252
+
253
+ state.a = 3
254
+ expect(callCount).toBe(1)
255
+ expect(lastValue).toBe(5)
256
+
257
+ state.b = 4
258
+ expect(callCount).toBe(2)
259
+ expect(lastValue).toBe(7)
260
+
261
+ stop()
262
+ })
263
+
264
+ it('should watch computed properties that return new objects (simplified)', () => {
265
+ // Simplified test: watching a computed property that returns a new object each time
266
+ // The bug occurs when modifying existing objects, not just adding new ones
267
+ @reactive
268
+ class TestClass {
269
+ public slots: { item: string; count: number }[] = []
270
+
271
+ addItem(item: string) {
272
+ this.slots.push({ item, count: 1 })
273
+ }
274
+
275
+ incrementCount(item: string) {
276
+ const slot = this.slots.find((s) => s.item === item)
277
+ if (slot) slot.count++
278
+ }
279
+
280
+ //@computed
281
+ get summary(): { [k: string]: number } {
282
+ const result: { [k: string]: number } = {}
283
+ for (const slot of this.slots) {
284
+ result[slot.item] = slot.count
285
+ }
286
+ return result
287
+ }
288
+ }
289
+
290
+ const obj = new TestClass()
291
+ let callCount = 0
292
+
293
+ const stop = watch(
294
+ () => obj.summary,
295
+ () => callCount++,
296
+ { deep: true }
297
+ )
298
+
299
+ expect(callCount).toBe(0)
300
+
301
+ // First change should trigger
302
+ obj.addItem('wood')
303
+ expect(callCount).toBe(1)
304
+
305
+ // Second change should trigger
306
+ obj.addItem('stone')
307
+ expect(callCount).toBe(2)
308
+
309
+ // Third change should trigger but doesn't (this is the bug)
310
+ // Modifying existing object property doesn't trigger the watch
311
+ obj.incrementCount('wood')
312
+ expect(callCount).toBe(3)
313
+
314
+ stop()
315
+ })
316
+
317
+ it('should watch computed properties that return new objects (with computed)', () => {
318
+ // This test shows the same issue but with @computed for comparison
319
+ @reactive
320
+ class TestClass {
321
+ public slots: { item: string; count: number }[] = []
322
+
323
+ addItem(item: string) {
324
+ this.slots.push({ item, count: 1 })
325
+ }
326
+
327
+ incrementCount(item: string) {
328
+ const slot = this.slots.find((s) => s.item === item)
329
+ if (slot) slot.count++
330
+ }
331
+
332
+ @computed
333
+ get summary(): { [k: string]: number } {
334
+ const result: { [k: string]: number } = {}
335
+ for (const slot of this.slots) {
336
+ result[slot.item] = slot.count
337
+ }
338
+ return result
339
+ }
340
+ }
341
+
342
+ const obj = new TestClass()
343
+ let callCount = 0
344
+
345
+ // Watch the computed property that returns new objects
346
+ const stop = watch(
347
+ () => obj.summary,
348
+ () => callCount++,
349
+ { deep: true }
350
+ )
351
+
352
+ expect(callCount).toBe(0)
353
+
354
+ // First change should trigger
355
+ obj.addItem('wood')
356
+ expect(callCount).toBe(1)
357
+
358
+ // Second change should trigger
359
+ obj.addItem('stone')
360
+ expect(callCount).toBe(2)
361
+
362
+ // Third change should trigger but doesn't (this is the bug)
363
+ obj.incrementCount('wood')
364
+ expect(callCount).toBe(3)
365
+
366
+ stop()
367
+ })
368
+ })
369
+
370
+ describe('watch object properties', () => {
371
+ it('should watch any property change on a reactive object', () => {
372
+ const user = reactive({
373
+ name: 'John',
374
+ age: 30,
375
+ email: 'john@example.com',
376
+ })
377
+ let callCount = 0
378
+ let lastUser: any
379
+
380
+ const stop = watch(user, () => {
381
+ callCount++
382
+ lastUser = { ...user }
383
+ })
384
+
385
+ expect(callCount).toBe(0) // Should not trigger on setup
386
+
387
+ user.name = 'Jane'
388
+ expect(callCount).toBe(1)
389
+ expect(lastUser.name).toBe('Jane')
390
+
391
+ user.age = 31
392
+ expect(callCount).toBe(2)
393
+ expect(lastUser.age).toBe(31)
394
+
395
+ user.email = 'jane@example.com'
396
+ expect(callCount).toBe(3)
397
+ expect(lastUser.email).toBe('jane@example.com')
398
+
399
+ stop()
400
+ })
401
+ it('should watch nested object property changes', () => {
402
+ const state = reactive({
403
+ user: {
404
+ name: 'John',
405
+ profile: { age: 30 },
406
+ },
407
+ })
408
+ let callCount = 0
409
+
410
+ const stop = watch(
411
+ state,
412
+ () => {
413
+ callCount++
414
+ },
415
+ { deep: true }
416
+ )
417
+
418
+ state.user.name = 'Jane'
419
+ expect(callCount).toBe(1)
420
+
421
+ state.user.profile.age = 31
422
+ expect(callCount).toBe(2)
423
+
424
+ stop()
425
+ })
426
+
427
+ it('should watch array changes when object contains arrays', () => {
428
+ const state = reactive({
429
+ items: [1, 2, 3],
430
+ name: 'test',
431
+ })
432
+ let callCount = 0
433
+
434
+ const stop = watch(
435
+ state,
436
+ () => {
437
+ callCount++
438
+ },
439
+ { deep: true }
440
+ )
441
+
442
+ state.items.push(4)
443
+ expect(callCount).toBe(1)
444
+
445
+ state.items[0] = 10
446
+ expect(callCount).toBe(2)
447
+
448
+ state.name = 'updated'
449
+ expect(callCount).toBe(3)
450
+
451
+ stop()
452
+ })
453
+ it('should handle multiple watchers on the same object', () => {
454
+ const user = reactive({ name: 'John', age: 30 })
455
+ let watcher1Calls = 0
456
+ let watcher2Calls = 0
457
+
458
+ const stop1 = watch(user, () => {
459
+ watcher1Calls++
460
+ })
461
+
462
+ const stop2 = watch(user, () => {
463
+ watcher2Calls++
464
+ })
465
+
466
+ user.name = 'Jane'
467
+ expect(watcher1Calls).toBe(1)
468
+ expect(watcher2Calls).toBe(1)
469
+
470
+ user.age = 31
471
+ expect(watcher1Calls).toBe(2)
472
+ expect(watcher2Calls).toBe(2)
473
+
474
+ stop1()
475
+ stop2()
476
+ })
477
+
478
+ it('should stop watching when cleanup is called', () => {
479
+ const user = reactive({ name: 'John', age: 30 })
480
+ let callCount = 0
481
+
482
+ const stop = watch(user, () => {
483
+ callCount++
484
+ })
485
+
486
+ user.name = 'Jane'
487
+ expect(callCount).toBe(1)
488
+
489
+ stop()
490
+
491
+ user.age = 31
492
+ expect(callCount).toBe(1) // Should not increment after stop
493
+ })
494
+
495
+ it('should handle non-reactive objects gracefully', () => {
496
+ const plainObject = { name: 'John', age: 30 }
497
+ let callCount = 0
498
+
499
+ // This should not throw but also not trigger
500
+ const stop = watch(plainObject, () => {
501
+ callCount++
502
+ })
503
+
504
+ plainObject.name = 'Jane'
505
+ expect(callCount).toBe(0) // Should not trigger for non-reactive objects
506
+
507
+ stop()
508
+ })
509
+
510
+ it('should watch property additions and deletions', () => {
511
+ const state = reactive({ name: 'John' }) as any
512
+ let callCount = 0
513
+
514
+ const stop = watch(state, () => {
515
+ callCount++
516
+ })
517
+
518
+ // Add new property
519
+ state.age = 30
520
+ expect(callCount).toBe(1)
521
+
522
+ // Delete property
523
+ delete state.age
524
+ expect(callCount).toBe(2)
525
+
526
+ stop()
527
+ })
528
+
529
+ it('should watch reactive array mutations (currently fails)', () => {
530
+ const state = reactive([1, 2, 3])
531
+ let callCount = 0
532
+
533
+ const stop = watch(state, () => {
534
+ callCount++
535
+ })
536
+
537
+ // These should trigger watch but currently don't
538
+ state.push(4)
539
+ expect(callCount).toBe(1)
540
+
541
+ state[0] = 10
542
+ expect(callCount).toBe(2)
543
+
544
+ state[5] = 10
545
+ expect(callCount).toBe(3)
546
+
547
+ stop()
548
+ })
549
+
550
+ it('should watch reactive array length changes (currently fails)', () => {
551
+ const state = reactive([1, 2, 3])
552
+ let callCount = 0
553
+
554
+ const stop = watch(state, () => {
555
+ callCount++
556
+ })
557
+
558
+ state.length = 2
559
+ expect(callCount).toBe(1)
560
+
561
+ stop()
562
+ })
563
+
564
+ it('should watch nested object properties in arrays (simplified)', () => {
565
+ // This test demonstrates the core deep watch bug: nested object property changes in arrays
566
+ @reactive
567
+ class TestClass {
568
+ public items: { name: string; count: number }[] = []
569
+
570
+ addItem(name: string) {
571
+ this.items.push({ name, count: 1 })
572
+ }
573
+
574
+ incrementCount(name: string) {
575
+ const item = this.items.find((i) => i.name === name)
576
+ if (item) item.count++
577
+ }
578
+ }
579
+
580
+ const obj = new TestClass()
581
+ let callCount = 0
582
+
583
+ const stop = watch(
584
+ () => obj.items,
585
+ () => callCount++,
586
+ { deep: true }
587
+ )
588
+
589
+ expect(callCount).toBe(0)
590
+
591
+ // Adding new items works
592
+ obj.addItem('wood')
593
+ expect(callCount).toBe(1)
594
+
595
+ // But modifying existing object properties doesn't trigger deep watch
596
+ obj.incrementCount('wood')
597
+ expect(callCount).toBe(2) // This fails - deep watch doesn't detect nested property changes
598
+
599
+ stop()
600
+ })
601
+ it('should watch computed properties that return new objects (without computed)', () => {
602
+ // This test shows the same issue but with @computed for comparison
603
+ @reactive
604
+ class TestClass {
605
+ public slots: { item: string; count: number }[] = []
606
+
607
+ addItem(item: string) {
608
+ this.slots.push({ item, count: 1 })
609
+ }
610
+
611
+ incrementCount(item: string) {
612
+ const slot = this.slots.find((s) => s.item === item)
613
+ if (slot) slot.count++
614
+ }
615
+
616
+ get summary(): { [k: string]: number } {
617
+ const result: { [k: string]: number } = {}
618
+ for (const slot of this.slots) {
619
+ result[slot.item] = slot.count
620
+ }
621
+ return result
622
+ }
623
+ }
624
+
625
+ const obj = new TestClass()
626
+ let callCount = 0
627
+
628
+ // Watch the computed property that returns new objects
629
+ const stop = watch(
630
+ () => obj.summary,
631
+ () => callCount++,
632
+ { deep: true }
633
+ )
634
+
635
+ expect(callCount).toBe(0)
636
+
637
+ // First change should trigger
638
+ obj.addItem('wood')
639
+ expect(callCount).toBe(1)
640
+
641
+ // Second change should trigger
642
+ obj.addItem('stone')
643
+ expect(callCount).toBe(2)
644
+
645
+ // Third change should trigger but doesn't (this is the bug)
646
+ obj.incrementCount('wood')
647
+ expect(callCount).toBe(3)
648
+
649
+ stop()
650
+ })
651
+
652
+ it('should watch nested object properties in objects (not arrays)', () => {
653
+ // Test if the issue is specific to arrays or also affects nested objects
654
+ @reactive
655
+ class TestClass {
656
+ public data: { wood: { count: number }; stone: { count: number } } = {
657
+ wood: { count: 1 },
658
+ stone: { count: 1 },
659
+ }
660
+
661
+ incrementCount(item: string) {
662
+ if (this.data[item as keyof typeof this.data]) {
663
+ this.data[item as keyof typeof this.data].count++
664
+ }
665
+ }
666
+ }
667
+
668
+ const obj = new TestClass()
669
+ let callCount = 0
670
+
671
+ const stop = watch(
672
+ () => obj.data,
673
+ () => callCount++,
674
+ { deep: true }
675
+ )
676
+
677
+ expect(callCount).toBe(0)
678
+
679
+ // This should trigger the watch
680
+ obj.incrementCount('wood')
681
+ expect(callCount).toBe(1) // Does this work with nested objects?
682
+
683
+ stop()
684
+ })
685
+
686
+ it('should watch array property changes with direct access (not methods)', () => {
687
+ // Test if the issue is about method calls vs direct property access
688
+ @reactive
689
+ class TestClass {
690
+ public items: { name: string; count: number }[] = []
691
+ }
692
+
693
+ const obj = new TestClass()
694
+ let callCount = 0
695
+
696
+ const stop = watch(
697
+ () => obj.items,
698
+ () => callCount++,
699
+ { deep: true }
700
+ )
701
+
702
+ expect(callCount).toBe(0)
703
+
704
+ // Add item directly (not through method)
705
+ obj.items.push({ name: 'wood', count: 1 })
706
+ expect(callCount).toBe(1)
707
+
708
+ // Modify property directly (not through method)
709
+ obj.items[0].count++
710
+ expect(callCount).toBe(2) // Does this work with direct access?
711
+
712
+ stop()
713
+ })
714
+
715
+ it('should watch array element access vs find method (hypothesis test)', () => {
716
+ // Test the hypothesis: find() method doesn't track individual elements
717
+ @reactive
718
+ class TestClass {
719
+ public items: { name: string; count: number }[] = []
720
+
721
+ addItem(name: string) {
722
+ this.items.push({ name, count: 1 })
723
+ }
724
+
725
+ // Method 1: Using find() - should fail
726
+ incrementWithFind(name: string) {
727
+ const item = this.items.find((i) => i.name === name)
728
+ if (item) item.count++
729
+ }
730
+
731
+ // Method 2: Using direct index access - should work
732
+ incrementWithIndex(name: string) {
733
+ const index = this.items.findIndex((i) => i.name === name)
734
+ if (index >= 0) this.items[index].count++
735
+ }
736
+ }
737
+
738
+ const obj = new TestClass()
739
+ obj.addItem('wood')
740
+ obj.addItem('stone')
741
+
742
+ let callCount = 0
743
+
744
+ const stop = watch(
745
+ () => obj.items,
746
+ () => callCount++,
747
+ { deep: true }
748
+ )
749
+
750
+ expect(callCount).toBe(0)
751
+
752
+ // This should fail (find() doesn't track individual elements)
753
+ obj.incrementWithFind('wood')
754
+ expect(callCount).toBe(1) // This will likely fail
755
+
756
+ // This should work (direct index access tracks the element)
757
+ obj.incrementWithIndex('stone')
758
+ expect(callCount).toBe(2) // This should pass
759
+
760
+ stop()
761
+ })
762
+
763
+ it('should watch new added objects', () => {
764
+ const state = reactive({ x: null }) as any
765
+ let callCount = 0
766
+ const stop = watch(
767
+ state,
768
+ () => {
769
+ callCount++
770
+ },
771
+ { deep: true }
772
+ )
773
+ state.x = { y: 1 }
774
+ expect(callCount).toBe(1)
775
+ state.x.y = 2
776
+ expect(callCount).toBe(2)
777
+ stop()
778
+ })
779
+ })
780
+
781
+ describe('watch edge cases', () => {
782
+ it('should handle undefined and null values', () => {
783
+ const state = reactive({ value: undefined as any })
784
+ let callCount = 0
785
+ let lastValue: any
786
+
787
+ const stop = watch(
788
+ () => state.value,
789
+ (newValue) => {
790
+ lastValue = newValue
791
+ callCount++
792
+ }
793
+ )
794
+
795
+ state.value = null
796
+ expect(callCount).toBe(1)
797
+ expect(lastValue).toBe(null)
798
+
799
+ state.value = 'test'
800
+ expect(callCount).toBe(2)
801
+ expect(lastValue).toBe('test')
802
+
803
+ stop()
804
+ })
805
+
806
+ it('should handle circular references in object watching', () => {
807
+ const state = reactive({ name: 'John' }) as any
808
+ state.self = state // Create circular reference
809
+ let callCount = 0
810
+
811
+ const stop = watch(state, () => {
812
+ callCount++
813
+ })
814
+
815
+ state.name = 'Jane'
816
+ expect(callCount).toBe(1)
817
+
818
+ stop()
819
+ })
820
+
821
+ it('should handle rapid successive changes', () => {
822
+ const state = reactive({ count: 0 })
823
+ let callCount = 0
824
+ let lastValue: number | undefined
825
+
826
+ const stop = watch(
827
+ () => state.count,
828
+ (newValue) => {
829
+ lastValue = newValue
830
+ callCount++
831
+ }
832
+ )
833
+
834
+ // Rapid changes
835
+ state.count = 1
836
+ state.count = 2
837
+ state.count = 3
838
+ state.count = 4
839
+ state.count = 5
840
+
841
+ expect(callCount).toBe(5)
842
+ expect(lastValue).toBe(5)
843
+
844
+ stop()
845
+ })
846
+
847
+ it('should handle watching during effect execution', () => {
848
+ const state = reactive({ count: 0, multiplier: 2 })
849
+ let watchCalls = 0
850
+ let effectCalls = 0
851
+
852
+ const stopWatch = watch(
853
+ () => state.multiplier,
854
+ () => {
855
+ watchCalls++
856
+ }
857
+ )
858
+
859
+ const stopEffect = effect(() => {
860
+ effectCalls++
861
+ // Change watched value during effect
862
+ state.count = state.count + 1
863
+ })
864
+
865
+ expect(effectCalls).toBe(1)
866
+ expect(watchCalls).toBe(0) // Should trigger once during effect
867
+
868
+ state.multiplier = 3
869
+ expect(effectCalls).toBe(1)
870
+ expect(watchCalls).toBe(1) // Should trigger again
871
+
872
+ stopWatch()
873
+ stopEffect()
874
+ })
875
+ })
876
+ })
877
+
878
+ describe('deep watch via watch({ deep: true })', () => {
879
+ describe('basic deep watching functionality', () => {
880
+ it('should watch nested object property changes', () => {
881
+ const state = reactive({
882
+ user: {
883
+ name: 'John',
884
+ profile: { age: 30 },
885
+ },
886
+ })
887
+ let callCount = 0
888
+
889
+ const stop = watch(
890
+ state,
891
+ () => {
892
+ callCount++
893
+ },
894
+ { deep: true }
895
+ )
896
+
897
+ state.user.name = 'Jane'
898
+ expect(callCount).toBe(1)
899
+
900
+ state.user.profile.age = 31
901
+ expect(callCount).toBe(2)
902
+
903
+ stop()
904
+ })
905
+
906
+ it('should watch array changes when object contains arrays', () => {
907
+ const state = reactive({
908
+ items: [1, 2, 3],
909
+ name: 'test',
910
+ })
911
+ let callCount = 0
912
+
913
+ const stop = watch(
914
+ state,
915
+ () => {
916
+ callCount++
917
+ },
918
+ { deep: true }
919
+ )
920
+
921
+ state.items.push(4)
922
+ expect(callCount).toBe(1)
923
+
924
+ state.items[0] = 10
925
+ expect(callCount).toBe(2)
926
+
927
+ state.name = 'updated'
928
+ expect(callCount).toBe(3)
929
+
930
+ stop()
931
+ })
932
+
933
+ it('should handle object replacement correctly', () => {
934
+ const state = reactive({
935
+ user: {
936
+ name: 'John',
937
+ profile: { age: 30 },
938
+ },
939
+ })
940
+ let callCount = 0
941
+
942
+ const stop = watch(
943
+ state,
944
+ () => {
945
+ callCount++
946
+ },
947
+ { deep: true }
948
+ )
949
+
950
+ // Replace the entire user object
951
+ state.user = { name: 'Jane', profile: { age: 25 } }
952
+ expect(callCount).toBe(1)
953
+
954
+ // Changes to the new user object should trigger
955
+ state.user.name = 'Bob'
956
+ expect(callCount).toBe(2)
957
+
958
+ state.user.profile.age = 26
959
+ expect(callCount).toBe(3)
960
+
961
+ stop()
962
+ })
963
+
964
+ it('should handle immediate option', () => {
965
+ const state = reactive({
966
+ user: {
967
+ name: 'John',
968
+ profile: { age: 30 },
969
+ },
970
+ })
971
+ let callCount = 0
972
+
973
+ const stop = watch(
974
+ state,
975
+ () => {
976
+ callCount++
977
+ },
978
+ { immediate: true, deep: true }
979
+ )
980
+
981
+ // Should trigger immediately
982
+ expect(callCount).toBe(1)
983
+
984
+ state.user.name = 'Jane'
985
+ expect(callCount).toBe(2)
986
+
987
+ stop()
988
+ })
989
+
990
+ it('should handle multiple deep watchers on the same object', () => {
991
+ const state = reactive({
992
+ user: {
993
+ name: 'John',
994
+ profile: { age: 30 },
995
+ },
996
+ })
997
+ let watcher1Calls = 0
998
+ let watcher2Calls = 0
999
+
1000
+ const stop1 = watch(
1001
+ state,
1002
+ () => {
1003
+ watcher1Calls++
1004
+ },
1005
+ { deep: true }
1006
+ )
1007
+
1008
+ const stop2 = watch(
1009
+ state,
1010
+ () => {
1011
+ watcher2Calls++
1012
+ },
1013
+ { deep: true }
1014
+ )
1015
+
1016
+ state.user.name = 'Jane'
1017
+ expect(watcher1Calls).toBe(1)
1018
+ expect(watcher2Calls).toBe(1)
1019
+
1020
+ state.user.profile.age = 31
1021
+ expect(watcher1Calls).toBe(2)
1022
+ expect(watcher2Calls).toBe(2)
1023
+
1024
+ stop1()
1025
+ stop2()
1026
+ })
1027
+
1028
+ it('should stop watching when cleanup is called', () => {
1029
+ const state = reactive({
1030
+ user: {
1031
+ name: 'John',
1032
+ profile: { age: 30 },
1033
+ },
1034
+ })
1035
+ let callCount = 0
1036
+
1037
+ const stop = watch(
1038
+ state,
1039
+ () => {
1040
+ callCount++
1041
+ },
1042
+ { deep: true }
1043
+ )
1044
+
1045
+ state.user.name = 'Jane'
1046
+ expect(callCount).toBe(1)
1047
+
1048
+ stop()
1049
+
1050
+ state.user.profile.age = 31
1051
+ expect(callCount).toBe(1) // Should not increment after stop
1052
+ })
1053
+
1054
+ it('should handle circular references', () => {
1055
+ const state = reactive({ name: 'John' }) as any
1056
+ state.self = state // Create circular reference
1057
+ let callCount = 0
1058
+
1059
+ const stop = watch(
1060
+ state,
1061
+ () => {
1062
+ callCount++
1063
+ },
1064
+ { deep: true }
1065
+ )
1066
+
1067
+ state.name = 'Jane'
1068
+ expect(callCount).toBe(1)
1069
+
1070
+ stop()
1071
+ })
1072
+
1073
+ it('should handle deeply nested objects', () => {
1074
+ const state = reactive({
1075
+ level1: {
1076
+ level2: {
1077
+ level3: {
1078
+ level4: {
1079
+ value: 'deep',
1080
+ },
1081
+ },
1082
+ },
1083
+ },
1084
+ })
1085
+ let callCount = 0
1086
+
1087
+ const stop = watch(
1088
+ state,
1089
+ () => {
1090
+ callCount++
1091
+ },
1092
+ { deep: true }
1093
+ )
1094
+
1095
+ state.level1.level2.level3.level4.value = 'deeper'
1096
+ expect(callCount).toBe(1)
1097
+
1098
+ stop()
1099
+ })
1100
+ })
1101
+
1102
+ describe('performance and edge cases', () => {
1103
+ it('should handle large object graphs efficiently', () => {
1104
+ // Create a large object graph
1105
+ const state = reactive({ items: [] as any[] })
1106
+ for (let i = 0; i < 100; i++) {
1107
+ state.items.push({ id: i, nested: { value: i * 2 } })
1108
+ }
1109
+
1110
+ let callCount = 0
1111
+ const stop = watch(
1112
+ state,
1113
+ () => {
1114
+ callCount++
1115
+ },
1116
+ { deep: true }
1117
+ )
1118
+
1119
+ // Change one item
1120
+ state.items[50].nested.value = 999
1121
+ // deep reactivity should be in touch, not `set`
1122
+ expect(callCount).toBe(1)
1123
+
1124
+ stop()
1125
+ })
1126
+
1127
+ it('should trigger deep watch when pushing objects to reactive array', () => {
1128
+ const state = reactive({ items: [] as any[] })
1129
+
1130
+ let callCount = 0
1131
+ const stop = watch(
1132
+ state,
1133
+ () => {
1134
+ callCount++
1135
+ },
1136
+ { deep: true }
1137
+ )
1138
+
1139
+ // Push an object with nested properties
1140
+ state.items.push({ id: 1, nested: { value: 'test' } })
1141
+
1142
+ // This should trigger deep watch because we're adding a new object to the array
1143
+ // If this fails, it means push() is not properly tracking deep changes
1144
+ expect(callCount).toBe(1)
1145
+
1146
+ state.items[0].nested.value = 'updated'
1147
+ expect(callCount).toBe(2)
1148
+
1149
+ stop()
1150
+ })
1151
+
1152
+ it('should trigger deep watch when pushing nested objects to reactive array', () => {
1153
+ const state = reactive({ items: [] as any[] })
1154
+
1155
+ let callCount = 0
1156
+ const stop = watch(
1157
+ state,
1158
+ () => {
1159
+ callCount++
1160
+ },
1161
+ { deep: true }
1162
+ )
1163
+
1164
+ // Push a nested object
1165
+ state.items.push({
1166
+ id: 1,
1167
+ data: {
1168
+ config: {
1169
+ enabled: true,
1170
+ },
1171
+ },
1172
+ })
1173
+
1174
+ // This should trigger deep watch because we're adding a deeply nested object
1175
+ // If this fails, it means push() is not properly tracking deep changes for nested objects
1176
+ expect(callCount).toBe(1)
1177
+
1178
+ stop()
1179
+ })
1180
+
1181
+ it('should handle native operations', () => {
1182
+ // Create a large object graph
1183
+ const state = reactive({ items: [] as any[] })
1184
+
1185
+ let callCount = 0
1186
+ const stop = watch(
1187
+ state,
1188
+ () => {
1189
+ callCount++
1190
+ },
1191
+ { deep: true }
1192
+ )
1193
+
1194
+ expect(callCount).toBe(0)
1195
+ state.items.push({ nested: { value: 0 } })
1196
+
1197
+ expect(callCount).toBe(1)
1198
+ // Change one item
1199
+ state.items[0].nested.value = 999
1200
+ // deep reactivity should be in touch, not `set`
1201
+ expect(callCount).toBe(2)
1202
+
1203
+ stop()
1204
+ })
1205
+
1206
+ it('should follow native operations', () => {
1207
+ // Create a large object graph
1208
+ const state = reactive({ items: [] as any[] })
1209
+ const item = reactive({ value: 0 })
1210
+
1211
+ let callCount = 0
1212
+ const stop = watch(
1213
+ state,
1214
+ () => {
1215
+ callCount++
1216
+ },
1217
+ { deep: true }
1218
+ )
1219
+
1220
+ expect(callCount).toBe(0)
1221
+
1222
+ state.items.push(item)
1223
+ //state.items[0] = item
1224
+
1225
+ expect(callCount).toBe(1)
1226
+ // Change one item
1227
+ item.value = 999
1228
+ // deep reactivity should be in touch, not `set`
1229
+ expect(callCount).toBe(2)
1230
+
1231
+ stop()
1232
+ })
1233
+
1234
+ it('should handle non-reactive objects gracefully', () => {
1235
+ const plainObject = {
1236
+ user: {
1237
+ name: 'John',
1238
+ profile: { age: 30 },
1239
+ },
1240
+ }
1241
+ let callCount = 0
1242
+
1243
+ // This should not throw but also not trigger
1244
+ const stop = watch(
1245
+ plainObject as any,
1246
+ () => {
1247
+ callCount++
1248
+ },
1249
+ { deep: true }
1250
+ )
1251
+
1252
+ plainObject.user.name = 'Jane'
1253
+ expect(callCount).toBe(0) // Should not trigger for non-reactive objects
1254
+
1255
+ stop()
1256
+ })
1257
+ })
1258
+
1259
+ describe('minimal deep watch failure example', () => {
1260
+ it('MINIMAL: watch should detect array mutations', () => {
1261
+ const state = reactive({
1262
+ items: [1, 2, 3],
1263
+ })
1264
+
1265
+ let callCount = 0
1266
+
1267
+ // This is the minimal failing case
1268
+ const stopWatch = watch(
1269
+ state,
1270
+ () => {
1271
+ callCount++
1272
+ },
1273
+ { immediate: true, deep: true }
1274
+ )
1275
+
1276
+ expect(callCount).toBe(1) // Initial call
1277
+
1278
+ // This should trigger the watch but doesn't
1279
+ state.items.push(4)
1280
+ expect(callCount).toBe(2) // FAILS: Expected 2, Received 1
1281
+
1282
+ stopWatch()
1283
+ })
1284
+
1285
+ it('MINIMAL: deep watch should detect array mutations', () => {
1286
+ const state = reactive({
1287
+ items: [1, 2, 3],
1288
+ })
1289
+
1290
+ let callCount = 0
1291
+
1292
+ // Even with deep: true, this fails
1293
+ const stopWatch = watch(
1294
+ state,
1295
+ () => {
1296
+ callCount++
1297
+ },
1298
+ { immediate: true, deep: true }
1299
+ )
1300
+
1301
+ expect(callCount).toBe(1) // Initial call
1302
+
1303
+ // This should trigger the deep watch but doesn't
1304
+ state.items.push(4)
1305
+ expect(callCount).toBe(2) // FAILS: Expected 2, Received 1
1306
+
1307
+ stopWatch()
1308
+ })
1309
+
1310
+ it('COMPARISON: effect with length DOES detect array mutations', () => {
1311
+ const state = reactive({
1312
+ items: [1, 2, 3],
1313
+ })
1314
+
1315
+ let effectCount = 0
1316
+
1317
+ // This works - effect detects array mutations when we access length
1318
+ const stopEffect = effect(() => {
1319
+ effectCount++
1320
+ state.items.length // Access the array length
1321
+ })
1322
+
1323
+ expect(effectCount).toBe(1) // Initial call
1324
+
1325
+ // This DOES trigger the effect because push changes length
1326
+ state.items.push(4)
1327
+ expect(effectCount).toBe(2) // Should PASS
1328
+
1329
+ stopEffect()
1330
+ })
1331
+
1332
+ it('DEBUG: what happens with just array access', () => {
1333
+ const state = reactive({
1334
+ items: [1, 2, 3],
1335
+ })
1336
+
1337
+ let effectCount = 0
1338
+
1339
+ // What happens when we just access the array reference?
1340
+ const stopEffect = effect(() => {
1341
+ effectCount++
1342
+ state.items // Just access the array reference
1343
+ })
1344
+
1345
+ expect(effectCount).toBe(1) // Initial call
1346
+
1347
+ // Does this trigger? It shouldn't, because we didn't access any properties
1348
+ state.items.push(4)
1349
+ expect(effectCount).toBe(1) // Should stay 1
1350
+
1351
+ stopEffect()
1352
+ })
1353
+ })
1354
+
1355
+ describe('deep watching Sets and Maps', () => {
1356
+ it('should detect Set mutations with deep watch', () => {
1357
+ const state = reactive({
1358
+ mySet: new Set([1, 2, 3]),
1359
+ })
1360
+
1361
+ let callCount = 0
1362
+
1363
+ const stopWatch = watch(
1364
+ state,
1365
+ () => {
1366
+ callCount++
1367
+ },
1368
+ { immediate: true, deep: true }
1369
+ )
1370
+
1371
+ expect(callCount).toBe(1) // Initial call
1372
+
1373
+ // Test Set mutations
1374
+ state.mySet.add(4)
1375
+ expect(callCount).toBe(2) // Might fail
1376
+
1377
+ state.mySet.delete(1)
1378
+ expect(callCount).toBe(3) // Might fail
1379
+
1380
+ stopWatch()
1381
+ })
1382
+
1383
+ it('should detect Map mutations with deep watch', () => {
1384
+ const state = reactive({
1385
+ myMap: new Map([
1386
+ ['a', 1],
1387
+ ['b', 2],
1388
+ ]),
1389
+ })
1390
+
1391
+ let callCount = 0
1392
+
1393
+ const stopWatch = watch(
1394
+ state,
1395
+ () => {
1396
+ callCount++
1397
+ },
1398
+ { immediate: true, deep: true }
1399
+ )
1400
+
1401
+ expect(callCount).toBe(1) // Initial call
1402
+
1403
+ // Test Map mutations
1404
+ state.myMap.set('c', 3)
1405
+ expect(callCount).toBe(2) // Might fail
1406
+
1407
+ state.myMap.delete('a')
1408
+ expect(callCount).toBe(3) // Might fail
1409
+
1410
+ stopWatch()
1411
+ })
1412
+
1413
+ it('should detect Map value changes with deep watch', () => {
1414
+ const state = reactive({
1415
+ myMap: new Map([
1416
+ ['a', 1],
1417
+ ['b', 2],
1418
+ ]),
1419
+ })
1420
+
1421
+ let callCount = 0
1422
+
1423
+ const stopWatch = watch(
1424
+ state,
1425
+ () => {
1426
+ callCount++
1427
+ },
1428
+ { immediate: true, deep: true }
1429
+ )
1430
+
1431
+ expect(callCount).toBe(1) // Initial call
1432
+
1433
+ // Test Map value changes
1434
+ state.myMap.set('a', 10)
1435
+ expect(callCount).toBe(2) // Might fail
1436
+
1437
+ stopWatch()
1438
+ })
1439
+
1440
+ it('should detect nested Set/Map mutations with deep watch', () => {
1441
+ const state = reactive({
1442
+ container: {
1443
+ mySet: new Set([1, 2, 3]),
1444
+ myMap: new Map([
1445
+ ['x', 1],
1446
+ ['y', 2],
1447
+ ]),
1448
+ },
1449
+ })
1450
+
1451
+ let callCount = 0
1452
+
1453
+ const stopWatch = watch(
1454
+ state,
1455
+ () => {
1456
+ callCount++
1457
+ },
1458
+ { immediate: true, deep: true }
1459
+ )
1460
+
1461
+ expect(callCount).toBe(1) // Initial call
1462
+
1463
+ // Test nested Set mutations
1464
+ state.container.mySet.add(4)
1465
+ expect(callCount).toBe(2) // Might fail
1466
+
1467
+ // Test nested Map mutations
1468
+ state.container.myMap.set('z', 3)
1469
+ expect(callCount).toBe(3) // Might fail
1470
+
1471
+ stopWatch()
1472
+ })
1473
+
1474
+ // Note: WeakSet and WeakMap cannot be deeply reactive because they don't support iteration
1475
+ // They can only have shallow reactivity (tracking when the collection itself changes)
1476
+ })
1477
+ })