zero-query 1.1.1 → 1.2.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 (154) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +2 -0
  3. package/cli/args.js +33 -33
  4. package/cli/commands/build-api.js +443 -442
  5. package/cli/commands/build.js +254 -247
  6. package/cli/commands/bundle.js +1228 -1224
  7. package/cli/commands/create.js +137 -121
  8. package/cli/commands/dev/devtools/index.js +56 -56
  9. package/cli/commands/dev/devtools/js/components.js +49 -49
  10. package/cli/commands/dev/devtools/js/core.js +423 -423
  11. package/cli/commands/dev/devtools/js/elements.js +421 -421
  12. package/cli/commands/dev/devtools/js/network.js +166 -166
  13. package/cli/commands/dev/devtools/js/performance.js +73 -73
  14. package/cli/commands/dev/devtools/js/router.js +105 -105
  15. package/cli/commands/dev/devtools/js/source.js +132 -132
  16. package/cli/commands/dev/devtools/js/stats.js +35 -35
  17. package/cli/commands/dev/devtools/js/tabs.js +79 -79
  18. package/cli/commands/dev/devtools/panel.html +95 -95
  19. package/cli/commands/dev/devtools/styles.css +244 -244
  20. package/cli/commands/dev/index.js +107 -107
  21. package/cli/commands/dev/logger.js +75 -75
  22. package/cli/commands/dev/overlay.js +858 -858
  23. package/cli/commands/dev/server.js +220 -220
  24. package/cli/commands/dev/validator.js +94 -94
  25. package/cli/commands/dev/watcher.js +172 -172
  26. package/cli/help.js +114 -112
  27. package/cli/index.js +52 -52
  28. package/cli/scaffold/default/LICENSE +21 -21
  29. package/cli/scaffold/default/app/app.js +207 -207
  30. package/cli/scaffold/default/app/components/about.js +201 -201
  31. package/cli/scaffold/default/app/components/api-demo.js +143 -143
  32. package/cli/scaffold/default/app/components/contact-card.js +231 -231
  33. package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
  34. package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
  35. package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
  36. package/cli/scaffold/default/app/components/counter.js +127 -127
  37. package/cli/scaffold/default/app/components/home.js +249 -249
  38. package/cli/scaffold/default/app/components/not-found.js +16 -16
  39. package/cli/scaffold/default/app/components/playground/playground.css +115 -115
  40. package/cli/scaffold/default/app/components/playground/playground.html +161 -161
  41. package/cli/scaffold/default/app/components/playground/playground.js +116 -116
  42. package/cli/scaffold/default/app/components/todos.js +225 -225
  43. package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
  44. package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
  45. package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
  46. package/cli/scaffold/default/app/routes.js +15 -15
  47. package/cli/scaffold/default/app/store.js +101 -101
  48. package/cli/scaffold/default/global.css +552 -552
  49. package/cli/scaffold/default/index.html +99 -99
  50. package/cli/scaffold/minimal/app/app.js +85 -85
  51. package/cli/scaffold/minimal/app/components/about.js +68 -68
  52. package/cli/scaffold/minimal/app/components/counter.js +122 -122
  53. package/cli/scaffold/minimal/app/components/home.js +68 -68
  54. package/cli/scaffold/minimal/app/components/not-found.js +16 -16
  55. package/cli/scaffold/minimal/app/routes.js +9 -9
  56. package/cli/scaffold/minimal/app/store.js +36 -36
  57. package/cli/scaffold/minimal/global.css +300 -300
  58. package/cli/scaffold/minimal/index.html +44 -44
  59. package/cli/scaffold/ssr/app/app.js +41 -41
  60. package/cli/scaffold/ssr/app/components/about.js +55 -55
  61. package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
  62. package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
  63. package/cli/scaffold/ssr/app/components/home.js +37 -37
  64. package/cli/scaffold/ssr/app/components/not-found.js +15 -15
  65. package/cli/scaffold/ssr/app/routes.js +8 -8
  66. package/cli/scaffold/ssr/global.css +228 -228
  67. package/cli/scaffold/ssr/index.html +37 -37
  68. package/cli/scaffold/ssr/package.json +8 -8
  69. package/cli/scaffold/ssr/server/data/posts.js +144 -144
  70. package/cli/scaffold/ssr/server/index.js +213 -213
  71. package/cli/scaffold/webrtc/app/app.js +11 -0
  72. package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
  73. package/cli/scaffold/webrtc/app/lib/room.js +252 -0
  74. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  75. package/cli/scaffold/webrtc/global.css +250 -0
  76. package/cli/scaffold/webrtc/index.html +21 -0
  77. package/cli/utils.js +305 -287
  78. package/dist/API.md +661 -0
  79. package/dist/zquery.dist.zip +0 -0
  80. package/dist/zquery.js +10313 -6614
  81. package/dist/zquery.min.js +8 -631
  82. package/index.d.ts +570 -371
  83. package/index.js +311 -240
  84. package/package.json +76 -70
  85. package/src/component.js +1709 -1691
  86. package/src/core.js +921 -921
  87. package/src/diff.js +497 -497
  88. package/src/errors.js +209 -209
  89. package/src/expression.js +922 -922
  90. package/src/http.js +242 -242
  91. package/src/package.json +1 -1
  92. package/src/reactive.js +255 -255
  93. package/src/router.js +843 -843
  94. package/src/ssr.js +418 -418
  95. package/src/store.js +318 -318
  96. package/src/utils.js +515 -515
  97. package/src/webrtc/e2ee.js +351 -0
  98. package/src/webrtc/errors.js +116 -0
  99. package/src/webrtc/ice.js +301 -0
  100. package/src/webrtc/index.js +131 -0
  101. package/src/webrtc/joinToken.js +119 -0
  102. package/src/webrtc/observe.js +172 -0
  103. package/src/webrtc/peer.js +351 -0
  104. package/src/webrtc/reactive.js +268 -0
  105. package/src/webrtc/room.js +625 -0
  106. package/src/webrtc/sdp.js +302 -0
  107. package/src/webrtc/sfu/index.js +43 -0
  108. package/src/webrtc/sfu/livekit.js +131 -0
  109. package/src/webrtc/sfu/mediasoup.js +150 -0
  110. package/src/webrtc/signaling.js +373 -0
  111. package/src/webrtc/turn.js +237 -0
  112. package/tests/_helpers/webrtcFakes.js +289 -0
  113. package/tests/audit.test.js +4158 -4158
  114. package/tests/cli.test.js +1136 -1103
  115. package/tests/compare.test.js +497 -486
  116. package/tests/component.test.js +3969 -3938
  117. package/tests/core.test.js +1910 -1910
  118. package/tests/dev-server.test.js +489 -489
  119. package/tests/diff.test.js +1416 -1416
  120. package/tests/docs.test.js +1664 -1650
  121. package/tests/electron-features.test.js +864 -864
  122. package/tests/errors.test.js +619 -619
  123. package/tests/expression.test.js +1056 -1056
  124. package/tests/http.test.js +648 -648
  125. package/tests/reactive.test.js +819 -819
  126. package/tests/router.test.js +2327 -2327
  127. package/tests/ssr.test.js +870 -870
  128. package/tests/store.test.js +830 -830
  129. package/tests/test-minifier.js +153 -153
  130. package/tests/test-ssr.js +27 -27
  131. package/tests/utils.test.js +1377 -1377
  132. package/tests/webrtc/e2ee.test.js +283 -0
  133. package/tests/webrtc/ice.test.js +202 -0
  134. package/tests/webrtc/joinToken.test.js +89 -0
  135. package/tests/webrtc/observe.test.js +111 -0
  136. package/tests/webrtc/peer.test.js +373 -0
  137. package/tests/webrtc/reactive.test.js +235 -0
  138. package/tests/webrtc/room.test.js +406 -0
  139. package/tests/webrtc/sdp.test.js +151 -0
  140. package/tests/webrtc/sfu-livekit.test.js +119 -0
  141. package/tests/webrtc/sfu.test.js +160 -0
  142. package/tests/webrtc/signaling.test.js +251 -0
  143. package/tests/webrtc/turn.test.js +256 -0
  144. package/types/collection.d.ts +383 -383
  145. package/types/component.d.ts +186 -186
  146. package/types/errors.d.ts +135 -135
  147. package/types/http.d.ts +92 -92
  148. package/types/misc.d.ts +201 -201
  149. package/types/reactive.d.ts +98 -98
  150. package/types/router.d.ts +190 -190
  151. package/types/ssr.d.ts +102 -102
  152. package/types/store.d.ts +146 -146
  153. package/types/utils.d.ts +245 -245
  154. package/types/webrtc.d.ts +653 -0
@@ -1,820 +1,820 @@
1
- import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { reactive, Signal, signal, computed, effect, batch, untracked } from '../src/reactive.js';
3
-
4
-
5
- // ---------------------------------------------------------------------------
6
- // reactive()
7
- // ---------------------------------------------------------------------------
8
-
9
- describe('reactive', () => {
10
- it('triggers onChange when a property is set', () => {
11
- const fn = vi.fn();
12
- const obj = reactive({ count: 0 }, fn);
13
- obj.count = 5;
14
- expect(fn).toHaveBeenCalledWith('count', 5, 0);
15
- });
16
-
17
- it('does not trigger onChange when value is the same', () => {
18
- const fn = vi.fn();
19
- const obj = reactive({ count: 0 }, fn);
20
- obj.count = 0;
21
- expect(fn).not.toHaveBeenCalled();
22
- });
23
-
24
- it('returns non-objects as-is', () => {
25
- expect(reactive(42, vi.fn())).toBe(42);
26
- expect(reactive('hello', vi.fn())).toBe('hello');
27
- expect(reactive(null, vi.fn())).toBe(null);
28
- });
29
-
30
- it('supports deep nested property access', () => {
31
- const fn = vi.fn();
32
- const obj = reactive({ user: { name: 'Tony' } }, fn);
33
- obj.user.name = 'Sam';
34
- expect(fn).toHaveBeenCalledWith('name', 'Sam', 'Tony');
35
- });
36
-
37
- it('tracks __isReactive and __raw', () => {
38
- const fn = vi.fn();
39
- const raw = { x: 1 };
40
- const obj = reactive(raw, fn);
41
- expect(obj.__isReactive).toBe(true);
42
- expect(obj.__raw).toBe(raw);
43
- });
44
-
45
- it('triggers onChange on deleteProperty', () => {
46
- const fn = vi.fn();
47
- const obj = reactive({ x: 1 }, fn);
48
- delete obj.x;
49
- expect(fn).toHaveBeenCalledWith('x', undefined, 1);
50
- });
51
-
52
- it('caches child proxies (same reference on repeated access)', () => {
53
- const fn = vi.fn();
54
- const obj = reactive({ nested: { a: 1 } }, fn);
55
- const first = obj.nested;
56
- const second = obj.nested;
57
- expect(first).toBe(second);
58
- });
59
-
60
- it('handles onChange gracefully when onChange is not a function', () => {
61
- // Should not throw - error is reported and a no-op is used
62
- expect(() => {
63
- const obj = reactive({ x: 1 }, 'not a function');
64
- obj.x = 2;
65
- }).not.toThrow();
66
- });
67
-
68
- it('does not crash when onChange callback throws', () => {
69
- const bad = vi.fn(() => { throw new Error('boom'); });
70
- const obj = reactive({ x: 1 }, bad);
71
- expect(() => { obj.x = 2; }).not.toThrow();
72
- expect(bad).toHaveBeenCalled();
73
- });
74
- });
75
-
76
-
77
- // ---------------------------------------------------------------------------
78
- // Signal
79
- // ---------------------------------------------------------------------------
80
-
81
- describe('Signal', () => {
82
- it('stores and retrieves a value', () => {
83
- const s = new Signal(10);
84
- expect(s.value).toBe(10);
85
- });
86
-
87
- it('notifies subscribers on value change', () => {
88
- const s = new Signal(0);
89
- const fn = vi.fn();
90
- s.subscribe(fn);
91
- s.value = 1;
92
- expect(fn).toHaveBeenCalledOnce();
93
- });
94
-
95
- it('does not notify when value is the same', () => {
96
- const s = new Signal(5);
97
- const fn = vi.fn();
98
- s.subscribe(fn);
99
- s.value = 5;
100
- expect(fn).not.toHaveBeenCalled();
101
- });
102
-
103
- it('subscribe returns an unsubscribe function', () => {
104
- const s = new Signal(0);
105
- const fn = vi.fn();
106
- const unsub = s.subscribe(fn);
107
- unsub();
108
- s.value = 1;
109
- expect(fn).not.toHaveBeenCalled();
110
- });
111
-
112
- it('peek() returns value without tracking', () => {
113
- const s = new Signal(42);
114
- expect(s.peek()).toBe(42);
115
- });
116
-
117
- it('toString() returns string representation of value', () => {
118
- const s = new Signal(123);
119
- expect(s.toString()).toBe('123');
120
- });
121
-
122
- it('does not crash when a subscriber throws', () => {
123
- const s = new Signal(0);
124
- s.subscribe(() => { throw new Error('oops'); });
125
- expect(() => { s.value = 1; }).not.toThrow();
126
- });
127
- });
128
-
129
-
130
- // ---------------------------------------------------------------------------
131
- // signal() factory
132
- // ---------------------------------------------------------------------------
133
-
134
- describe('signal()', () => {
135
- it('returns a Signal instance', () => {
136
- const s = signal(0);
137
- expect(s).toBeInstanceOf(Signal);
138
- expect(s.value).toBe(0);
139
- });
140
- });
141
-
142
-
143
- // ---------------------------------------------------------------------------
144
- // computed()
145
- // ---------------------------------------------------------------------------
146
-
147
- describe('computed()', () => {
148
- it('derives value from other signals', () => {
149
- const count = signal(2);
150
- const doubled = computed(() => count.value * 2);
151
- expect(doubled.value).toBe(4);
152
- });
153
-
154
- it('updates when dependency changes', () => {
155
- const a = signal(1);
156
- const b = signal(2);
157
- const sum = computed(() => a.value + b.value);
158
- expect(sum.value).toBe(3);
159
- a.value = 10;
160
- expect(sum.value).toBe(12);
161
- });
162
- });
163
-
164
-
165
- // ---------------------------------------------------------------------------
166
- // effect()
167
- // ---------------------------------------------------------------------------
168
-
169
- describe('effect()', () => {
170
- it('runs the effect function immediately', () => {
171
- const fn = vi.fn();
172
- effect(fn);
173
- expect(fn).toHaveBeenCalledOnce();
174
- });
175
-
176
- it('re-runs when a tracked signal changes', () => {
177
- const s = signal(0);
178
- const log = vi.fn();
179
- effect(() => { log(s.value); });
180
- expect(log).toHaveBeenCalledWith(0);
181
- s.value = 1;
182
- expect(log).toHaveBeenCalledWith(1);
183
- expect(log).toHaveBeenCalledTimes(2);
184
- });
185
-
186
- it('does not crash when effect function throws', () => {
187
- expect(() => {
188
- effect(() => { throw new Error('fail'); });
189
- }).not.toThrow();
190
- });
191
-
192
- it('dispose stops re-running on signal change', () => {
193
- const s = signal(0);
194
- const log = vi.fn();
195
- const dispose = effect(() => { log(s.value); });
196
- expect(log).toHaveBeenCalledTimes(1);
197
- dispose();
198
- s.value = 1;
199
- expect(log).toHaveBeenCalledTimes(1); // no additional call
200
- });
201
-
202
- it('dispose removes effect from signal subscribers', () => {
203
- const s = signal(0);
204
- const log = vi.fn();
205
- const dispose = effect(() => { log(s.value); });
206
- dispose();
207
- // After disposing, the signal should not hold a reference to the effect
208
- s.value = 99;
209
- expect(log).toHaveBeenCalledTimes(1); // only the initial run
210
- });
211
-
212
- it('tracks multiple signals', () => {
213
- const a = signal(1);
214
- const b = signal(2);
215
- const log = vi.fn();
216
- effect(() => { log(a.value + b.value); });
217
- expect(log).toHaveBeenCalledWith(3);
218
- a.value = 10;
219
- expect(log).toHaveBeenCalledWith(12);
220
- b.value = 20;
221
- expect(log).toHaveBeenCalledWith(30);
222
- expect(log).toHaveBeenCalledTimes(3);
223
- });
224
-
225
- it('handles conditional dependency tracking', () => {
226
- const toggle = signal(true);
227
- const a = signal('A');
228
- const b = signal('B');
229
- const log = vi.fn();
230
- effect(() => {
231
- log(toggle.value ? a.value : b.value);
232
- });
233
- expect(log).toHaveBeenCalledWith('A');
234
- // Change b - should NOT re-run because b is not tracked when toggle=true
235
- b.value = 'B2';
236
- // After toggle switches, b becomes tracked
237
- toggle.value = false;
238
- expect(log).toHaveBeenCalledWith('B2');
239
- });
240
- });
241
-
242
-
243
- // ---------------------------------------------------------------------------
244
- // reactive - array mutations
245
- // ---------------------------------------------------------------------------
246
-
247
- describe('reactive - arrays', () => {
248
- it('detects push on a reactive array', () => {
249
- const fn = vi.fn();
250
- const obj = reactive({ items: [1, 2, 3] }, fn);
251
- obj.items.push(4);
252
- expect(fn).toHaveBeenCalled();
253
- });
254
-
255
- it('detects index assignment', () => {
256
- const fn = vi.fn();
257
- const obj = reactive({ items: ['a', 'b'] }, fn);
258
- obj.items[0] = 'z';
259
- expect(fn).toHaveBeenCalledWith('0', 'z', 'a');
260
- });
261
- });
262
-
263
-
264
- // ---------------------------------------------------------------------------
265
- // computed - advanced
266
- // ---------------------------------------------------------------------------
267
-
268
- describe('computed - advanced', () => {
269
- it('chains computed signals', () => {
270
- const count = signal(2);
271
- const doubled = computed(() => count.value * 2);
272
- const quadrupled = computed(() => doubled.value * 2);
273
- expect(quadrupled.value).toBe(8);
274
- count.value = 3;
275
- expect(quadrupled.value).toBe(12);
276
- });
277
-
278
- it('does not recompute when dependencies unchanged (diamond)', () => {
279
- const s = signal(1);
280
- const a = computed(() => s.value + 1);
281
- const b = computed(() => s.value + 2);
282
- const spy = vi.fn(() => a.value + b.value);
283
- const c = computed(spy);
284
- expect(c.value).toBe(5); // (1+1)+(1+2)
285
- spy.mockClear();
286
- s.value = 10;
287
- expect(c.value).toBe(23); // (10+1)+(10+2)
288
- });
289
-
290
- it('peek does not create dependency', () => {
291
- const s = signal(0);
292
- const log = vi.fn();
293
- effect(() => {
294
- log(s.peek());
295
- });
296
- expect(log).toHaveBeenCalledWith(0);
297
- s.value = 1;
298
- // peek doesn't track, so effect should NOT re-run
299
- expect(log).toHaveBeenCalledTimes(1);
300
- });
301
- });
302
-
303
-
304
- // ---------------------------------------------------------------------------
305
- // Signal - batch behavior
306
- // ---------------------------------------------------------------------------
307
-
308
- describe('Signal - multiple subscribers', () => {
309
- it('notifies all subscribers', () => {
310
- const s = signal(0);
311
- const fn1 = vi.fn();
312
- const fn2 = vi.fn();
313
- s.subscribe(fn1);
314
- s.subscribe(fn2);
315
- s.value = 1;
316
- expect(fn1).toHaveBeenCalledOnce();
317
- expect(fn2).toHaveBeenCalledOnce();
318
- });
319
-
320
- it('unsubscribing one does not affect others', () => {
321
- const s = signal(0);
322
- const fn1 = vi.fn();
323
- const fn2 = vi.fn();
324
- const unsub1 = s.subscribe(fn1);
325
- s.subscribe(fn2);
326
- unsub1();
327
- s.value = 1;
328
- expect(fn1).not.toHaveBeenCalled();
329
- expect(fn2).toHaveBeenCalledOnce();
330
- });
331
-
332
- it('handles rapid sequential updates', () => {
333
- const s = signal(0);
334
- const log = vi.fn();
335
- s.subscribe(log);
336
- for (let i = 1; i <= 10; i++) s.value = i;
337
- expect(log).toHaveBeenCalledTimes(10);
338
- });
339
- });
340
-
341
-
342
- // ---------------------------------------------------------------------------
343
- // BUG FIX: effect() dispose must not corrupt _activeEffect
344
- // ---------------------------------------------------------------------------
345
-
346
- describe('effect - dispose safety', () => {
347
- it('disposing inside another effect does not break tracking', () => {
348
- const a = signal(1);
349
- const b = signal(2);
350
-
351
- // Create an inner effect that tracks `a`
352
- const disposeInner = effect(() => { a.value; });
353
-
354
- const log = vi.fn();
355
- // Outer effect tracks `b`, then disposes inner, then reads `a`
356
- effect(() => {
357
- b.value; // should be tracked
358
- disposeInner();
359
- log(a.value); // should also be tracked
360
- });
361
-
362
- log.mockClear();
363
- // Changing `a` should re-run outer effect since it reads a.value
364
- a.value = 10;
365
- expect(log).toHaveBeenCalledWith(10);
366
-
367
- log.mockClear();
368
- // Changing `b` should also re-run outer effect
369
- b.value = 20;
370
- expect(log).toHaveBeenCalled();
371
- });
372
- });
373
-
374
-
375
- // ---------------------------------------------------------------------------
376
- // PERF FIX: computed() should not notify when value unchanged
377
- // ---------------------------------------------------------------------------
378
-
379
- describe('computed - skip notification on same value', () => {
380
- it('does not notify subscribers when computed result is the same', () => {
381
- const s = signal(5);
382
- // Computed that clamps to a range - returns same value if within bounds
383
- const clamped = computed(() => Math.min(Math.max(s.value, 0), 10));
384
- expect(clamped.value).toBe(5);
385
-
386
- const subscriber = vi.fn();
387
- clamped.subscribe(subscriber);
388
-
389
- // Changing s from 5 to 7 changes clamped: 5→7, should notify
390
- s.value = 7;
391
- expect(clamped.value).toBe(7);
392
- expect(subscriber).toHaveBeenCalledTimes(1);
393
-
394
- subscriber.mockClear();
395
- // Changing s from 7 to 15 - clamped stays at 10
396
- s.value = 15;
397
- expect(clamped.value).toBe(10);
398
- s.value = 20; // clamped still 10 - should NOT notify again
399
- expect(subscriber).toHaveBeenCalledTimes(1); // only the 7→10 change
400
- });
401
- });
402
-
403
- // ===========================================================================
404
- // reactive() - advanced edge cases
405
- // ===========================================================================
406
-
407
- describe('reactive - edge cases', () => {
408
- it('returns primitive as-is', () => {
409
- expect(reactive(42, () => {})).toBe(42);
410
- expect(reactive('hello', () => {})).toBe('hello');
411
- expect(reactive(null, () => {})).toBeNull();
412
- });
413
-
414
- it('__isReactive flag returns true', () => {
415
- const r = reactive({ a: 1 }, () => {});
416
- expect(r.__isReactive).toBe(true);
417
- });
418
-
419
- it('__raw returns underlying target', () => {
420
- const original = { a: 1 };
421
- const r = reactive(original, () => {});
422
- expect(r.__raw).toBe(original);
423
- });
424
-
425
- it('proxy cache returns same child proxy', () => {
426
- const child = { x: 1 };
427
- const r = reactive({ child }, () => {});
428
- const first = r.child;
429
- const second = r.child;
430
- expect(first).toBe(second);
431
- });
432
-
433
- it('proxy cache invalidated on set', () => {
434
- const onChange = vi.fn();
435
- const r = reactive({ nested: { x: 1 } }, onChange);
436
- const old = r.nested;
437
- r.nested = { x: 2 };
438
- const fresh = r.nested;
439
- expect(fresh).not.toBe(old);
440
- });
441
-
442
- it('deleteProperty triggers onChange', () => {
443
- const onChange = vi.fn();
444
- const r = reactive({ a: 1, b: 2 }, onChange);
445
- delete r.b;
446
- expect(onChange).toHaveBeenCalledWith('b', undefined, 2);
447
- expect(r.__raw).not.toHaveProperty('b');
448
- });
449
-
450
- it('deleteProperty invalidates proxy cache for object value', () => {
451
- const onChange = vi.fn();
452
- const nested = { x: 1 };
453
- const r = reactive({ nested }, onChange);
454
- r.nested; // populate cache
455
- delete r.nested;
456
- expect(onChange).toHaveBeenCalled();
457
- });
458
-
459
- it('same-value set is ignored', () => {
460
- const onChange = vi.fn();
461
- const r = reactive({ a: 5 }, onChange);
462
- r.a = 5;
463
- expect(onChange).not.toHaveBeenCalled();
464
- });
465
-
466
- it('reactive with array target', () => {
467
- const onChange = vi.fn();
468
- const r = reactive([1, 2, 3], onChange);
469
- r.push(4);
470
- expect(onChange).toHaveBeenCalled();
471
- expect(r.__raw).toContain(4);
472
- });
473
-
474
- it('onChange throwing does not prevent set', () => {
475
- const r = reactive({ a: 1 }, () => { throw new Error('boom'); });
476
- // Should not throw externally - error is reported via reportError
477
- r.a = 2;
478
- expect(r.__raw.a).toBe(2);
479
- });
480
-
481
- it('non-function onChange gets replaced with noop', () => {
482
- const r = reactive({ a: 1 }, 'not a function');
483
- // Should not throw on set
484
- r.a = 2;
485
- expect(r.__raw.a).toBe(2);
486
- });
487
- });
488
-
489
-
490
- // ===========================================================================
491
- // Signal - advanced
492
- // ===========================================================================
493
-
494
- describe('Signal - advanced', () => {
495
- it('peek() does not trigger tracking', () => {
496
- const s = signal(1);
497
- const fn = vi.fn(() => { s.peek(); });
498
- effect(fn);
499
- fn.mockClear();
500
- s.value = 2;
501
- // fn should NOT re-run because peek() didn't track
502
- expect(fn).not.toHaveBeenCalled();
503
- });
504
-
505
- it('toString() returns string representation', () => {
506
- const s = signal(42);
507
- expect(s.toString()).toBe('42');
508
- expect(`${s}`).toBe('42');
509
- });
510
-
511
- it('subscribe returns unsubscribe function', () => {
512
- const s = signal(0);
513
- const fn = vi.fn();
514
- const unsub = s.subscribe(fn);
515
- s.value = 1;
516
- expect(fn).toHaveBeenCalledTimes(1);
517
- unsub();
518
- s.value = 2;
519
- expect(fn).toHaveBeenCalledTimes(1);
520
- });
521
-
522
- it('same-value write is a no-op', () => {
523
- const s = signal(10);
524
- const fn = vi.fn();
525
- s.subscribe(fn);
526
- s.value = 10;
527
- expect(fn).not.toHaveBeenCalled();
528
- });
529
-
530
- it('signal with object value notifies on reference change', () => {
531
- const s = signal({ x: 1 });
532
- const fn = vi.fn();
533
- s.subscribe(fn);
534
- s.value = { x: 2 };
535
- expect(fn).toHaveBeenCalledTimes(1);
536
- });
537
-
538
- it('subscriber error does not stop others', () => {
539
- const s = signal(0);
540
- const first = vi.fn(() => { throw new Error('oops'); });
541
- const second = vi.fn();
542
- s.subscribe(first);
543
- s.subscribe(second);
544
- s.value = 1;
545
- expect(first).toHaveBeenCalled();
546
- expect(second).toHaveBeenCalled();
547
- });
548
- });
549
-
550
-
551
- // ===========================================================================
552
- // effect() - advanced
553
- // ===========================================================================
554
-
555
- describe('effect - advanced', () => {
556
- it('returns dispose function', () => {
557
- const s = signal(0);
558
- const fn = vi.fn(() => s.value);
559
- const dispose = effect(fn);
560
- expect(typeof dispose).toBe('function');
561
- fn.mockClear();
562
- dispose();
563
- s.value = 1;
564
- expect(fn).not.toHaveBeenCalled();
565
- });
566
-
567
- it('cleans up stale dependencies on re-run', () => {
568
- const a = signal(true);
569
- const b = signal('B');
570
- const c = signal('C');
571
- const results = [];
572
-
573
- effect(() => {
574
- if (a.value) {
575
- results.push(b.value);
576
- } else {
577
- results.push(c.value);
578
- }
579
- });
580
-
581
- expect(results).toEqual(['B']);
582
-
583
- a.value = false;
584
- expect(results).toEqual(['B', 'C']);
585
-
586
- // Changing b should NOT trigger the effect now (stale dep)
587
- b.value = 'B2';
588
- expect(results).toEqual(['B', 'C']);
589
- });
590
-
591
- it('effect that throws still cleans up', () => {
592
- const s = signal(0);
593
- let callCount = 0;
594
- effect(() => {
595
- s.value; // track
596
- callCount++;
597
- if (callCount > 1) throw new Error('boom');
598
- });
599
- expect(callCount).toBe(1);
600
- s.value = 1; // triggers re-run which throws
601
- expect(callCount).toBe(2);
602
- // Should still be reactive
603
- s.value = 2;
604
- expect(callCount).toBe(3);
605
- });
606
-
607
- it('nested effects work independently', () => {
608
- const a = signal(0);
609
- const b = signal(0);
610
- const outerFn = vi.fn();
611
- const innerFn = vi.fn();
612
-
613
- effect(() => {
614
- outerFn(a.value);
615
- effect(() => { innerFn(b.value); });
616
- });
617
-
618
- expect(outerFn).toHaveBeenCalledWith(0);
619
- expect(innerFn).toHaveBeenCalledWith(0);
620
-
621
- b.value = 1;
622
- expect(innerFn).toHaveBeenCalledWith(1);
623
- });
624
- });
625
-
626
-
627
- // ===========================================================================
628
- // computed() - advanced
629
- // ===========================================================================
630
-
631
- describe('computed - advanced', () => {
632
- it('computed does not notify when value unchanged', () => {
633
- const s = signal(5);
634
- const c = computed(() => s.value > 3);
635
- const fn = vi.fn();
636
- c.subscribe(fn);
637
-
638
- s.value = 10; // c still true - no change
639
- expect(fn).not.toHaveBeenCalled();
640
- });
641
-
642
- it('computed chains', () => {
643
- const a = signal(2);
644
- const doubled = computed(() => a.value * 2);
645
- const quadrupled = computed(() => doubled.value * 2);
646
- expect(quadrupled.value).toBe(8);
647
- a.value = 3;
648
- expect(quadrupled.value).toBe(12);
649
- });
650
-
651
- it('computed with multiple signals', () => {
652
- const first = signal('John');
653
- const last = signal('Doe');
654
- const full = computed(() => `${first.value} ${last.value}`);
655
- expect(full.value).toBe('John Doe');
656
- first.value = 'Jane';
657
- expect(full.value).toBe('Jane Doe');
658
- });
659
- });
660
-
661
-
662
- // ===========================================================================
663
- // batch()
664
- // ===========================================================================
665
-
666
- describe('batch()', () => {
667
- it('defers effect execution until batch completes', () => {
668
- const a = signal(1);
669
- const b = signal(2);
670
- const fn = vi.fn();
671
-
672
- effect(() => {
673
- fn(a.value + b.value);
674
- });
675
- fn.mockClear(); // clear initial run
676
-
677
- batch(() => {
678
- a.value = 10;
679
- b.value = 20;
680
- });
681
-
682
- // Effect should run once after the batch, not twice
683
- expect(fn).toHaveBeenCalledTimes(1);
684
- expect(fn).toHaveBeenCalledWith(30);
685
- });
686
-
687
- it('subscribers see the final value, not intermediate', () => {
688
- const s = signal(0);
689
- const values = [];
690
- s.subscribe(() => values.push(s.value));
691
-
692
- batch(() => {
693
- s.value = 1;
694
- s.value = 2;
695
- s.value = 3;
696
- });
697
-
698
- expect(values).toEqual([3]);
699
- });
700
-
701
- it('nested batch runs inner immediately, flushes at outer', () => {
702
- const s = signal(0);
703
- const fn = vi.fn();
704
- s.subscribe(fn);
705
-
706
- batch(() => {
707
- s.value = 1;
708
- batch(() => {
709
- s.value = 2;
710
- });
711
- // inner batch should not have flushed
712
- expect(fn).not.toHaveBeenCalled();
713
- s.value = 3;
714
- });
715
-
716
- // Outer batch flushes once
717
- expect(fn).toHaveBeenCalledTimes(1);
718
- expect(s.value).toBe(3);
719
- });
720
-
721
- it('computed values update correctly after batch', () => {
722
- const a = signal(1);
723
- const b = signal(2);
724
- const sum = computed(() => a.value + b.value);
725
-
726
- batch(() => {
727
- a.value = 10;
728
- b.value = 20;
729
- });
730
-
731
- expect(sum.value).toBe(30);
732
- });
733
-
734
- it('effects still run if batch throws', () => {
735
- const s = signal(0);
736
- const fn = vi.fn();
737
- s.subscribe(() => fn(s.value));
738
-
739
- try {
740
- batch(() => {
741
- s.value = 42;
742
- throw new Error('oops');
743
- });
744
- } catch {}
745
-
746
- // Batch should still flush on error via finally
747
- expect(fn).toHaveBeenCalledWith(42);
748
- });
749
- });
750
-
751
-
752
- // ===========================================================================
753
- // untracked()
754
- // ===========================================================================
755
-
756
- describe('untracked()', () => {
757
- it('reads signals without creating dependencies', () => {
758
- const a = signal(1);
759
- const b = signal(10);
760
- const fn = vi.fn();
761
-
762
- effect(() => {
763
- const aVal = a.value; // tracked
764
- const bVal = untracked(() => b.value); // not tracked
765
- fn(aVal + bVal);
766
- });
767
- fn.mockClear();
768
-
769
- // Changing b should NOT re-run the effect
770
- b.value = 20;
771
- expect(fn).not.toHaveBeenCalled();
772
-
773
- // Changing a should re-run and pick up new b
774
- a.value = 2;
775
- expect(fn).toHaveBeenCalledTimes(1);
776
- expect(fn).toHaveBeenCalledWith(22); // a=2 + b=20
777
- });
778
-
779
- it('returns the value from the callback', () => {
780
- const s = signal(42);
781
- const result = untracked(() => s.value);
782
- expect(result).toBe(42);
783
- });
784
-
785
- it('does not break tracking for outer effect', () => {
786
- const tracked = signal('hello');
787
- const notTracked = signal('world');
788
- const runs = [];
789
-
790
- effect(() => {
791
- const t = tracked.value;
792
- const u = untracked(() => notTracked.value);
793
- runs.push(`${t} ${u}`);
794
- });
795
-
796
- expect(runs).toEqual(['hello world']);
797
-
798
- tracked.value = 'hi';
799
- expect(runs).toEqual(['hello world', 'hi world']);
800
-
801
- notTracked.value = 'earth';
802
- expect(runs).toEqual(['hello world', 'hi world']); // no re-run
803
- });
804
-
805
- it('works inside computed', () => {
806
- const a = signal(5);
807
- const b = signal(10);
808
- const c = computed(() => a.value + untracked(() => b.value));
809
-
810
- expect(c.value).toBe(15);
811
-
812
- b.value = 100;
813
- // computed shouldn't re-evaluate from b change
814
- expect(c.value).toBe(15);
815
-
816
- a.value = 1;
817
- // Now recomputes, picks up new b
818
- expect(c.value).toBe(101);
819
- });
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { reactive, Signal, signal, computed, effect, batch, untracked } from '../src/reactive.js';
3
+
4
+
5
+ // ---------------------------------------------------------------------------
6
+ // reactive()
7
+ // ---------------------------------------------------------------------------
8
+
9
+ describe('reactive', () => {
10
+ it('triggers onChange when a property is set', () => {
11
+ const fn = vi.fn();
12
+ const obj = reactive({ count: 0 }, fn);
13
+ obj.count = 5;
14
+ expect(fn).toHaveBeenCalledWith('count', 5, 0);
15
+ });
16
+
17
+ it('does not trigger onChange when value is the same', () => {
18
+ const fn = vi.fn();
19
+ const obj = reactive({ count: 0 }, fn);
20
+ obj.count = 0;
21
+ expect(fn).not.toHaveBeenCalled();
22
+ });
23
+
24
+ it('returns non-objects as-is', () => {
25
+ expect(reactive(42, vi.fn())).toBe(42);
26
+ expect(reactive('hello', vi.fn())).toBe('hello');
27
+ expect(reactive(null, vi.fn())).toBe(null);
28
+ });
29
+
30
+ it('supports deep nested property access', () => {
31
+ const fn = vi.fn();
32
+ const obj = reactive({ user: { name: 'Tony' } }, fn);
33
+ obj.user.name = 'Sam';
34
+ expect(fn).toHaveBeenCalledWith('name', 'Sam', 'Tony');
35
+ });
36
+
37
+ it('tracks __isReactive and __raw', () => {
38
+ const fn = vi.fn();
39
+ const raw = { x: 1 };
40
+ const obj = reactive(raw, fn);
41
+ expect(obj.__isReactive).toBe(true);
42
+ expect(obj.__raw).toBe(raw);
43
+ });
44
+
45
+ it('triggers onChange on deleteProperty', () => {
46
+ const fn = vi.fn();
47
+ const obj = reactive({ x: 1 }, fn);
48
+ delete obj.x;
49
+ expect(fn).toHaveBeenCalledWith('x', undefined, 1);
50
+ });
51
+
52
+ it('caches child proxies (same reference on repeated access)', () => {
53
+ const fn = vi.fn();
54
+ const obj = reactive({ nested: { a: 1 } }, fn);
55
+ const first = obj.nested;
56
+ const second = obj.nested;
57
+ expect(first).toBe(second);
58
+ });
59
+
60
+ it('handles onChange gracefully when onChange is not a function', () => {
61
+ // Should not throw - error is reported and a no-op is used
62
+ expect(() => {
63
+ const obj = reactive({ x: 1 }, 'not a function');
64
+ obj.x = 2;
65
+ }).not.toThrow();
66
+ });
67
+
68
+ it('does not crash when onChange callback throws', () => {
69
+ const bad = vi.fn(() => { throw new Error('boom'); });
70
+ const obj = reactive({ x: 1 }, bad);
71
+ expect(() => { obj.x = 2; }).not.toThrow();
72
+ expect(bad).toHaveBeenCalled();
73
+ });
74
+ });
75
+
76
+
77
+ // ---------------------------------------------------------------------------
78
+ // Signal
79
+ // ---------------------------------------------------------------------------
80
+
81
+ describe('Signal', () => {
82
+ it('stores and retrieves a value', () => {
83
+ const s = new Signal(10);
84
+ expect(s.value).toBe(10);
85
+ });
86
+
87
+ it('notifies subscribers on value change', () => {
88
+ const s = new Signal(0);
89
+ const fn = vi.fn();
90
+ s.subscribe(fn);
91
+ s.value = 1;
92
+ expect(fn).toHaveBeenCalledOnce();
93
+ });
94
+
95
+ it('does not notify when value is the same', () => {
96
+ const s = new Signal(5);
97
+ const fn = vi.fn();
98
+ s.subscribe(fn);
99
+ s.value = 5;
100
+ expect(fn).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('subscribe returns an unsubscribe function', () => {
104
+ const s = new Signal(0);
105
+ const fn = vi.fn();
106
+ const unsub = s.subscribe(fn);
107
+ unsub();
108
+ s.value = 1;
109
+ expect(fn).not.toHaveBeenCalled();
110
+ });
111
+
112
+ it('peek() returns value without tracking', () => {
113
+ const s = new Signal(42);
114
+ expect(s.peek()).toBe(42);
115
+ });
116
+
117
+ it('toString() returns string representation of value', () => {
118
+ const s = new Signal(123);
119
+ expect(s.toString()).toBe('123');
120
+ });
121
+
122
+ it('does not crash when a subscriber throws', () => {
123
+ const s = new Signal(0);
124
+ s.subscribe(() => { throw new Error('oops'); });
125
+ expect(() => { s.value = 1; }).not.toThrow();
126
+ });
127
+ });
128
+
129
+
130
+ // ---------------------------------------------------------------------------
131
+ // signal() factory
132
+ // ---------------------------------------------------------------------------
133
+
134
+ describe('signal()', () => {
135
+ it('returns a Signal instance', () => {
136
+ const s = signal(0);
137
+ expect(s).toBeInstanceOf(Signal);
138
+ expect(s.value).toBe(0);
139
+ });
140
+ });
141
+
142
+
143
+ // ---------------------------------------------------------------------------
144
+ // computed()
145
+ // ---------------------------------------------------------------------------
146
+
147
+ describe('computed()', () => {
148
+ it('derives value from other signals', () => {
149
+ const count = signal(2);
150
+ const doubled = computed(() => count.value * 2);
151
+ expect(doubled.value).toBe(4);
152
+ });
153
+
154
+ it('updates when dependency changes', () => {
155
+ const a = signal(1);
156
+ const b = signal(2);
157
+ const sum = computed(() => a.value + b.value);
158
+ expect(sum.value).toBe(3);
159
+ a.value = 10;
160
+ expect(sum.value).toBe(12);
161
+ });
162
+ });
163
+
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // effect()
167
+ // ---------------------------------------------------------------------------
168
+
169
+ describe('effect()', () => {
170
+ it('runs the effect function immediately', () => {
171
+ const fn = vi.fn();
172
+ effect(fn);
173
+ expect(fn).toHaveBeenCalledOnce();
174
+ });
175
+
176
+ it('re-runs when a tracked signal changes', () => {
177
+ const s = signal(0);
178
+ const log = vi.fn();
179
+ effect(() => { log(s.value); });
180
+ expect(log).toHaveBeenCalledWith(0);
181
+ s.value = 1;
182
+ expect(log).toHaveBeenCalledWith(1);
183
+ expect(log).toHaveBeenCalledTimes(2);
184
+ });
185
+
186
+ it('does not crash when effect function throws', () => {
187
+ expect(() => {
188
+ effect(() => { throw new Error('fail'); });
189
+ }).not.toThrow();
190
+ });
191
+
192
+ it('dispose stops re-running on signal change', () => {
193
+ const s = signal(0);
194
+ const log = vi.fn();
195
+ const dispose = effect(() => { log(s.value); });
196
+ expect(log).toHaveBeenCalledTimes(1);
197
+ dispose();
198
+ s.value = 1;
199
+ expect(log).toHaveBeenCalledTimes(1); // no additional call
200
+ });
201
+
202
+ it('dispose removes effect from signal subscribers', () => {
203
+ const s = signal(0);
204
+ const log = vi.fn();
205
+ const dispose = effect(() => { log(s.value); });
206
+ dispose();
207
+ // After disposing, the signal should not hold a reference to the effect
208
+ s.value = 99;
209
+ expect(log).toHaveBeenCalledTimes(1); // only the initial run
210
+ });
211
+
212
+ it('tracks multiple signals', () => {
213
+ const a = signal(1);
214
+ const b = signal(2);
215
+ const log = vi.fn();
216
+ effect(() => { log(a.value + b.value); });
217
+ expect(log).toHaveBeenCalledWith(3);
218
+ a.value = 10;
219
+ expect(log).toHaveBeenCalledWith(12);
220
+ b.value = 20;
221
+ expect(log).toHaveBeenCalledWith(30);
222
+ expect(log).toHaveBeenCalledTimes(3);
223
+ });
224
+
225
+ it('handles conditional dependency tracking', () => {
226
+ const toggle = signal(true);
227
+ const a = signal('A');
228
+ const b = signal('B');
229
+ const log = vi.fn();
230
+ effect(() => {
231
+ log(toggle.value ? a.value : b.value);
232
+ });
233
+ expect(log).toHaveBeenCalledWith('A');
234
+ // Change b - should NOT re-run because b is not tracked when toggle=true
235
+ b.value = 'B2';
236
+ // After toggle switches, b becomes tracked
237
+ toggle.value = false;
238
+ expect(log).toHaveBeenCalledWith('B2');
239
+ });
240
+ });
241
+
242
+
243
+ // ---------------------------------------------------------------------------
244
+ // reactive - array mutations
245
+ // ---------------------------------------------------------------------------
246
+
247
+ describe('reactive - arrays', () => {
248
+ it('detects push on a reactive array', () => {
249
+ const fn = vi.fn();
250
+ const obj = reactive({ items: [1, 2, 3] }, fn);
251
+ obj.items.push(4);
252
+ expect(fn).toHaveBeenCalled();
253
+ });
254
+
255
+ it('detects index assignment', () => {
256
+ const fn = vi.fn();
257
+ const obj = reactive({ items: ['a', 'b'] }, fn);
258
+ obj.items[0] = 'z';
259
+ expect(fn).toHaveBeenCalledWith('0', 'z', 'a');
260
+ });
261
+ });
262
+
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // computed - advanced
266
+ // ---------------------------------------------------------------------------
267
+
268
+ describe('computed - advanced', () => {
269
+ it('chains computed signals', () => {
270
+ const count = signal(2);
271
+ const doubled = computed(() => count.value * 2);
272
+ const quadrupled = computed(() => doubled.value * 2);
273
+ expect(quadrupled.value).toBe(8);
274
+ count.value = 3;
275
+ expect(quadrupled.value).toBe(12);
276
+ });
277
+
278
+ it('does not recompute when dependencies unchanged (diamond)', () => {
279
+ const s = signal(1);
280
+ const a = computed(() => s.value + 1);
281
+ const b = computed(() => s.value + 2);
282
+ const spy = vi.fn(() => a.value + b.value);
283
+ const c = computed(spy);
284
+ expect(c.value).toBe(5); // (1+1)+(1+2)
285
+ spy.mockClear();
286
+ s.value = 10;
287
+ expect(c.value).toBe(23); // (10+1)+(10+2)
288
+ });
289
+
290
+ it('peek does not create dependency', () => {
291
+ const s = signal(0);
292
+ const log = vi.fn();
293
+ effect(() => {
294
+ log(s.peek());
295
+ });
296
+ expect(log).toHaveBeenCalledWith(0);
297
+ s.value = 1;
298
+ // peek doesn't track, so effect should NOT re-run
299
+ expect(log).toHaveBeenCalledTimes(1);
300
+ });
301
+ });
302
+
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Signal - batch behavior
306
+ // ---------------------------------------------------------------------------
307
+
308
+ describe('Signal - multiple subscribers', () => {
309
+ it('notifies all subscribers', () => {
310
+ const s = signal(0);
311
+ const fn1 = vi.fn();
312
+ const fn2 = vi.fn();
313
+ s.subscribe(fn1);
314
+ s.subscribe(fn2);
315
+ s.value = 1;
316
+ expect(fn1).toHaveBeenCalledOnce();
317
+ expect(fn2).toHaveBeenCalledOnce();
318
+ });
319
+
320
+ it('unsubscribing one does not affect others', () => {
321
+ const s = signal(0);
322
+ const fn1 = vi.fn();
323
+ const fn2 = vi.fn();
324
+ const unsub1 = s.subscribe(fn1);
325
+ s.subscribe(fn2);
326
+ unsub1();
327
+ s.value = 1;
328
+ expect(fn1).not.toHaveBeenCalled();
329
+ expect(fn2).toHaveBeenCalledOnce();
330
+ });
331
+
332
+ it('handles rapid sequential updates', () => {
333
+ const s = signal(0);
334
+ const log = vi.fn();
335
+ s.subscribe(log);
336
+ for (let i = 1; i <= 10; i++) s.value = i;
337
+ expect(log).toHaveBeenCalledTimes(10);
338
+ });
339
+ });
340
+
341
+
342
+ // ---------------------------------------------------------------------------
343
+ // BUG FIX: effect() dispose must not corrupt _activeEffect
344
+ // ---------------------------------------------------------------------------
345
+
346
+ describe('effect - dispose safety', () => {
347
+ it('disposing inside another effect does not break tracking', () => {
348
+ const a = signal(1);
349
+ const b = signal(2);
350
+
351
+ // Create an inner effect that tracks `a`
352
+ const disposeInner = effect(() => { a.value; });
353
+
354
+ const log = vi.fn();
355
+ // Outer effect tracks `b`, then disposes inner, then reads `a`
356
+ effect(() => {
357
+ b.value; // should be tracked
358
+ disposeInner();
359
+ log(a.value); // should also be tracked
360
+ });
361
+
362
+ log.mockClear();
363
+ // Changing `a` should re-run outer effect since it reads a.value
364
+ a.value = 10;
365
+ expect(log).toHaveBeenCalledWith(10);
366
+
367
+ log.mockClear();
368
+ // Changing `b` should also re-run outer effect
369
+ b.value = 20;
370
+ expect(log).toHaveBeenCalled();
371
+ });
372
+ });
373
+
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // PERF FIX: computed() should not notify when value unchanged
377
+ // ---------------------------------------------------------------------------
378
+
379
+ describe('computed - skip notification on same value', () => {
380
+ it('does not notify subscribers when computed result is the same', () => {
381
+ const s = signal(5);
382
+ // Computed that clamps to a range - returns same value if within bounds
383
+ const clamped = computed(() => Math.min(Math.max(s.value, 0), 10));
384
+ expect(clamped.value).toBe(5);
385
+
386
+ const subscriber = vi.fn();
387
+ clamped.subscribe(subscriber);
388
+
389
+ // Changing s from 5 to 7 changes clamped: 5→7, should notify
390
+ s.value = 7;
391
+ expect(clamped.value).toBe(7);
392
+ expect(subscriber).toHaveBeenCalledTimes(1);
393
+
394
+ subscriber.mockClear();
395
+ // Changing s from 7 to 15 - clamped stays at 10
396
+ s.value = 15;
397
+ expect(clamped.value).toBe(10);
398
+ s.value = 20; // clamped still 10 - should NOT notify again
399
+ expect(subscriber).toHaveBeenCalledTimes(1); // only the 7→10 change
400
+ });
401
+ });
402
+
403
+ // ===========================================================================
404
+ // reactive() - advanced edge cases
405
+ // ===========================================================================
406
+
407
+ describe('reactive - edge cases', () => {
408
+ it('returns primitive as-is', () => {
409
+ expect(reactive(42, () => {})).toBe(42);
410
+ expect(reactive('hello', () => {})).toBe('hello');
411
+ expect(reactive(null, () => {})).toBeNull();
412
+ });
413
+
414
+ it('__isReactive flag returns true', () => {
415
+ const r = reactive({ a: 1 }, () => {});
416
+ expect(r.__isReactive).toBe(true);
417
+ });
418
+
419
+ it('__raw returns underlying target', () => {
420
+ const original = { a: 1 };
421
+ const r = reactive(original, () => {});
422
+ expect(r.__raw).toBe(original);
423
+ });
424
+
425
+ it('proxy cache returns same child proxy', () => {
426
+ const child = { x: 1 };
427
+ const r = reactive({ child }, () => {});
428
+ const first = r.child;
429
+ const second = r.child;
430
+ expect(first).toBe(second);
431
+ });
432
+
433
+ it('proxy cache invalidated on set', () => {
434
+ const onChange = vi.fn();
435
+ const r = reactive({ nested: { x: 1 } }, onChange);
436
+ const old = r.nested;
437
+ r.nested = { x: 2 };
438
+ const fresh = r.nested;
439
+ expect(fresh).not.toBe(old);
440
+ });
441
+
442
+ it('deleteProperty triggers onChange', () => {
443
+ const onChange = vi.fn();
444
+ const r = reactive({ a: 1, b: 2 }, onChange);
445
+ delete r.b;
446
+ expect(onChange).toHaveBeenCalledWith('b', undefined, 2);
447
+ expect(r.__raw).not.toHaveProperty('b');
448
+ });
449
+
450
+ it('deleteProperty invalidates proxy cache for object value', () => {
451
+ const onChange = vi.fn();
452
+ const nested = { x: 1 };
453
+ const r = reactive({ nested }, onChange);
454
+ r.nested; // populate cache
455
+ delete r.nested;
456
+ expect(onChange).toHaveBeenCalled();
457
+ });
458
+
459
+ it('same-value set is ignored', () => {
460
+ const onChange = vi.fn();
461
+ const r = reactive({ a: 5 }, onChange);
462
+ r.a = 5;
463
+ expect(onChange).not.toHaveBeenCalled();
464
+ });
465
+
466
+ it('reactive with array target', () => {
467
+ const onChange = vi.fn();
468
+ const r = reactive([1, 2, 3], onChange);
469
+ r.push(4);
470
+ expect(onChange).toHaveBeenCalled();
471
+ expect(r.__raw).toContain(4);
472
+ });
473
+
474
+ it('onChange throwing does not prevent set', () => {
475
+ const r = reactive({ a: 1 }, () => { throw new Error('boom'); });
476
+ // Should not throw externally - error is reported via reportError
477
+ r.a = 2;
478
+ expect(r.__raw.a).toBe(2);
479
+ });
480
+
481
+ it('non-function onChange gets replaced with noop', () => {
482
+ const r = reactive({ a: 1 }, 'not a function');
483
+ // Should not throw on set
484
+ r.a = 2;
485
+ expect(r.__raw.a).toBe(2);
486
+ });
487
+ });
488
+
489
+
490
+ // ===========================================================================
491
+ // Signal - advanced
492
+ // ===========================================================================
493
+
494
+ describe('Signal - advanced', () => {
495
+ it('peek() does not trigger tracking', () => {
496
+ const s = signal(1);
497
+ const fn = vi.fn(() => { s.peek(); });
498
+ effect(fn);
499
+ fn.mockClear();
500
+ s.value = 2;
501
+ // fn should NOT re-run because peek() didn't track
502
+ expect(fn).not.toHaveBeenCalled();
503
+ });
504
+
505
+ it('toString() returns string representation', () => {
506
+ const s = signal(42);
507
+ expect(s.toString()).toBe('42');
508
+ expect(`${s}`).toBe('42');
509
+ });
510
+
511
+ it('subscribe returns unsubscribe function', () => {
512
+ const s = signal(0);
513
+ const fn = vi.fn();
514
+ const unsub = s.subscribe(fn);
515
+ s.value = 1;
516
+ expect(fn).toHaveBeenCalledTimes(1);
517
+ unsub();
518
+ s.value = 2;
519
+ expect(fn).toHaveBeenCalledTimes(1);
520
+ });
521
+
522
+ it('same-value write is a no-op', () => {
523
+ const s = signal(10);
524
+ const fn = vi.fn();
525
+ s.subscribe(fn);
526
+ s.value = 10;
527
+ expect(fn).not.toHaveBeenCalled();
528
+ });
529
+
530
+ it('signal with object value notifies on reference change', () => {
531
+ const s = signal({ x: 1 });
532
+ const fn = vi.fn();
533
+ s.subscribe(fn);
534
+ s.value = { x: 2 };
535
+ expect(fn).toHaveBeenCalledTimes(1);
536
+ });
537
+
538
+ it('subscriber error does not stop others', () => {
539
+ const s = signal(0);
540
+ const first = vi.fn(() => { throw new Error('oops'); });
541
+ const second = vi.fn();
542
+ s.subscribe(first);
543
+ s.subscribe(second);
544
+ s.value = 1;
545
+ expect(first).toHaveBeenCalled();
546
+ expect(second).toHaveBeenCalled();
547
+ });
548
+ });
549
+
550
+
551
+ // ===========================================================================
552
+ // effect() - advanced
553
+ // ===========================================================================
554
+
555
+ describe('effect - advanced', () => {
556
+ it('returns dispose function', () => {
557
+ const s = signal(0);
558
+ const fn = vi.fn(() => s.value);
559
+ const dispose = effect(fn);
560
+ expect(typeof dispose).toBe('function');
561
+ fn.mockClear();
562
+ dispose();
563
+ s.value = 1;
564
+ expect(fn).not.toHaveBeenCalled();
565
+ });
566
+
567
+ it('cleans up stale dependencies on re-run', () => {
568
+ const a = signal(true);
569
+ const b = signal('B');
570
+ const c = signal('C');
571
+ const results = [];
572
+
573
+ effect(() => {
574
+ if (a.value) {
575
+ results.push(b.value);
576
+ } else {
577
+ results.push(c.value);
578
+ }
579
+ });
580
+
581
+ expect(results).toEqual(['B']);
582
+
583
+ a.value = false;
584
+ expect(results).toEqual(['B', 'C']);
585
+
586
+ // Changing b should NOT trigger the effect now (stale dep)
587
+ b.value = 'B2';
588
+ expect(results).toEqual(['B', 'C']);
589
+ });
590
+
591
+ it('effect that throws still cleans up', () => {
592
+ const s = signal(0);
593
+ let callCount = 0;
594
+ effect(() => {
595
+ s.value; // track
596
+ callCount++;
597
+ if (callCount > 1) throw new Error('boom');
598
+ });
599
+ expect(callCount).toBe(1);
600
+ s.value = 1; // triggers re-run which throws
601
+ expect(callCount).toBe(2);
602
+ // Should still be reactive
603
+ s.value = 2;
604
+ expect(callCount).toBe(3);
605
+ });
606
+
607
+ it('nested effects work independently', () => {
608
+ const a = signal(0);
609
+ const b = signal(0);
610
+ const outerFn = vi.fn();
611
+ const innerFn = vi.fn();
612
+
613
+ effect(() => {
614
+ outerFn(a.value);
615
+ effect(() => { innerFn(b.value); });
616
+ });
617
+
618
+ expect(outerFn).toHaveBeenCalledWith(0);
619
+ expect(innerFn).toHaveBeenCalledWith(0);
620
+
621
+ b.value = 1;
622
+ expect(innerFn).toHaveBeenCalledWith(1);
623
+ });
624
+ });
625
+
626
+
627
+ // ===========================================================================
628
+ // computed() - advanced
629
+ // ===========================================================================
630
+
631
+ describe('computed - advanced', () => {
632
+ it('computed does not notify when value unchanged', () => {
633
+ const s = signal(5);
634
+ const c = computed(() => s.value > 3);
635
+ const fn = vi.fn();
636
+ c.subscribe(fn);
637
+
638
+ s.value = 10; // c still true - no change
639
+ expect(fn).not.toHaveBeenCalled();
640
+ });
641
+
642
+ it('computed chains', () => {
643
+ const a = signal(2);
644
+ const doubled = computed(() => a.value * 2);
645
+ const quadrupled = computed(() => doubled.value * 2);
646
+ expect(quadrupled.value).toBe(8);
647
+ a.value = 3;
648
+ expect(quadrupled.value).toBe(12);
649
+ });
650
+
651
+ it('computed with multiple signals', () => {
652
+ const first = signal('John');
653
+ const last = signal('Doe');
654
+ const full = computed(() => `${first.value} ${last.value}`);
655
+ expect(full.value).toBe('John Doe');
656
+ first.value = 'Jane';
657
+ expect(full.value).toBe('Jane Doe');
658
+ });
659
+ });
660
+
661
+
662
+ // ===========================================================================
663
+ // batch()
664
+ // ===========================================================================
665
+
666
+ describe('batch()', () => {
667
+ it('defers effect execution until batch completes', () => {
668
+ const a = signal(1);
669
+ const b = signal(2);
670
+ const fn = vi.fn();
671
+
672
+ effect(() => {
673
+ fn(a.value + b.value);
674
+ });
675
+ fn.mockClear(); // clear initial run
676
+
677
+ batch(() => {
678
+ a.value = 10;
679
+ b.value = 20;
680
+ });
681
+
682
+ // Effect should run once after the batch, not twice
683
+ expect(fn).toHaveBeenCalledTimes(1);
684
+ expect(fn).toHaveBeenCalledWith(30);
685
+ });
686
+
687
+ it('subscribers see the final value, not intermediate', () => {
688
+ const s = signal(0);
689
+ const values = [];
690
+ s.subscribe(() => values.push(s.value));
691
+
692
+ batch(() => {
693
+ s.value = 1;
694
+ s.value = 2;
695
+ s.value = 3;
696
+ });
697
+
698
+ expect(values).toEqual([3]);
699
+ });
700
+
701
+ it('nested batch runs inner immediately, flushes at outer', () => {
702
+ const s = signal(0);
703
+ const fn = vi.fn();
704
+ s.subscribe(fn);
705
+
706
+ batch(() => {
707
+ s.value = 1;
708
+ batch(() => {
709
+ s.value = 2;
710
+ });
711
+ // inner batch should not have flushed
712
+ expect(fn).not.toHaveBeenCalled();
713
+ s.value = 3;
714
+ });
715
+
716
+ // Outer batch flushes once
717
+ expect(fn).toHaveBeenCalledTimes(1);
718
+ expect(s.value).toBe(3);
719
+ });
720
+
721
+ it('computed values update correctly after batch', () => {
722
+ const a = signal(1);
723
+ const b = signal(2);
724
+ const sum = computed(() => a.value + b.value);
725
+
726
+ batch(() => {
727
+ a.value = 10;
728
+ b.value = 20;
729
+ });
730
+
731
+ expect(sum.value).toBe(30);
732
+ });
733
+
734
+ it('effects still run if batch throws', () => {
735
+ const s = signal(0);
736
+ const fn = vi.fn();
737
+ s.subscribe(() => fn(s.value));
738
+
739
+ try {
740
+ batch(() => {
741
+ s.value = 42;
742
+ throw new Error('oops');
743
+ });
744
+ } catch {}
745
+
746
+ // Batch should still flush on error via finally
747
+ expect(fn).toHaveBeenCalledWith(42);
748
+ });
749
+ });
750
+
751
+
752
+ // ===========================================================================
753
+ // untracked()
754
+ // ===========================================================================
755
+
756
+ describe('untracked()', () => {
757
+ it('reads signals without creating dependencies', () => {
758
+ const a = signal(1);
759
+ const b = signal(10);
760
+ const fn = vi.fn();
761
+
762
+ effect(() => {
763
+ const aVal = a.value; // tracked
764
+ const bVal = untracked(() => b.value); // not tracked
765
+ fn(aVal + bVal);
766
+ });
767
+ fn.mockClear();
768
+
769
+ // Changing b should NOT re-run the effect
770
+ b.value = 20;
771
+ expect(fn).not.toHaveBeenCalled();
772
+
773
+ // Changing a should re-run and pick up new b
774
+ a.value = 2;
775
+ expect(fn).toHaveBeenCalledTimes(1);
776
+ expect(fn).toHaveBeenCalledWith(22); // a=2 + b=20
777
+ });
778
+
779
+ it('returns the value from the callback', () => {
780
+ const s = signal(42);
781
+ const result = untracked(() => s.value);
782
+ expect(result).toBe(42);
783
+ });
784
+
785
+ it('does not break tracking for outer effect', () => {
786
+ const tracked = signal('hello');
787
+ const notTracked = signal('world');
788
+ const runs = [];
789
+
790
+ effect(() => {
791
+ const t = tracked.value;
792
+ const u = untracked(() => notTracked.value);
793
+ runs.push(`${t} ${u}`);
794
+ });
795
+
796
+ expect(runs).toEqual(['hello world']);
797
+
798
+ tracked.value = 'hi';
799
+ expect(runs).toEqual(['hello world', 'hi world']);
800
+
801
+ notTracked.value = 'earth';
802
+ expect(runs).toEqual(['hello world', 'hi world']); // no re-run
803
+ });
804
+
805
+ it('works inside computed', () => {
806
+ const a = signal(5);
807
+ const b = signal(10);
808
+ const c = computed(() => a.value + untracked(() => b.value));
809
+
810
+ expect(c.value).toBe(15);
811
+
812
+ b.value = 100;
813
+ // computed shouldn't re-evaluate from b change
814
+ expect(c.value).toBe(15);
815
+
816
+ a.value = 1;
817
+ // Now recomputes, picks up new b
818
+ expect(c.value).toBe(101);
819
+ });
820
820
  });