zero-query 1.1.1 → 1.2.2

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 (156) 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 +442 -442
  5. package/cli/commands/build.js +253 -247
  6. package/cli/commands/bundle.js +1226 -1224
  7. package/cli/commands/create.js +178 -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 +441 -0
  73. package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
  74. package/cli/scaffold/webrtc/global.css +250 -0
  75. package/cli/scaffold/webrtc/index.html +21 -0
  76. package/cli/scaffold/webrtc/package.json +16 -0
  77. package/cli/scaffold/webrtc/server/index.js +198 -0
  78. package/cli/utils.js +305 -287
  79. package/dist/API.md +661 -0
  80. package/dist/zquery.dist.zip +0 -0
  81. package/dist/zquery.js +10454 -6614
  82. package/dist/zquery.min.js +8 -631
  83. package/index.d.ts +570 -371
  84. package/index.js +311 -240
  85. package/package.json +77 -70
  86. package/src/component.js +1758 -1691
  87. package/src/core.js +929 -921
  88. package/src/diff.js +497 -497
  89. package/src/errors.js +209 -209
  90. package/src/expression.js +929 -922
  91. package/src/http.js +242 -242
  92. package/src/package.json +1 -1
  93. package/src/reactive.js +271 -255
  94. package/src/router.js +878 -843
  95. package/src/ssr.js +421 -418
  96. package/src/store.js +325 -318
  97. package/src/utils.js +534 -515
  98. package/src/webrtc/e2ee.js +351 -0
  99. package/src/webrtc/errors.js +116 -0
  100. package/src/webrtc/ice.js +301 -0
  101. package/src/webrtc/index.js +131 -0
  102. package/src/webrtc/joinToken.js +119 -0
  103. package/src/webrtc/observe.js +172 -0
  104. package/src/webrtc/peer.js +351 -0
  105. package/src/webrtc/reactive.js +268 -0
  106. package/src/webrtc/room.js +625 -0
  107. package/src/webrtc/sdp.js +302 -0
  108. package/src/webrtc/sfu/index.js +43 -0
  109. package/src/webrtc/sfu/livekit.js +131 -0
  110. package/src/webrtc/sfu/mediasoup.js +150 -0
  111. package/src/webrtc/signaling.js +373 -0
  112. package/src/webrtc/turn.js +237 -0
  113. package/tests/_helpers/webrtcFakes.js +289 -0
  114. package/tests/audit.test.js +4158 -4158
  115. package/tests/bench/render.bench.js +77 -0
  116. package/tests/cli.test.js +1136 -1103
  117. package/tests/compare.test.js +497 -486
  118. package/tests/component.test.js +4076 -3938
  119. package/tests/core.test.js +1926 -1910
  120. package/tests/dev-server.test.js +489 -489
  121. package/tests/diff.test.js +1416 -1416
  122. package/tests/docs.test.js +1664 -1650
  123. package/tests/electron-features.test.js +864 -864
  124. package/tests/errors.test.js +619 -619
  125. package/tests/expression.test.js +1087 -1056
  126. package/tests/http.test.js +693 -648
  127. package/tests/reactive.test.js +844 -819
  128. package/tests/router.test.js +2423 -2327
  129. package/tests/ssr.test.js +916 -870
  130. package/tests/store.test.js +840 -830
  131. package/tests/test-minifier.js +153 -153
  132. package/tests/test-ssr.js +27 -27
  133. package/tests/utils.test.js +1411 -1377
  134. package/tests/webrtc/e2ee.test.js +283 -0
  135. package/tests/webrtc/ice.test.js +202 -0
  136. package/tests/webrtc/joinToken.test.js +89 -0
  137. package/tests/webrtc/observe.test.js +111 -0
  138. package/tests/webrtc/peer.test.js +373 -0
  139. package/tests/webrtc/reactive.test.js +235 -0
  140. package/tests/webrtc/room.test.js +406 -0
  141. package/tests/webrtc/sdp.test.js +151 -0
  142. package/tests/webrtc/sfu-livekit.test.js +119 -0
  143. package/tests/webrtc/sfu.test.js +160 -0
  144. package/tests/webrtc/signaling.test.js +251 -0
  145. package/tests/webrtc/turn.test.js +256 -0
  146. package/types/collection.d.ts +383 -383
  147. package/types/component.d.ts +186 -186
  148. package/types/errors.d.ts +135 -135
  149. package/types/http.d.ts +92 -92
  150. package/types/misc.d.ts +201 -201
  151. package/types/reactive.d.ts +98 -98
  152. package/types/router.d.ts +190 -190
  153. package/types/ssr.d.ts +102 -102
  154. package/types/store.d.ts +146 -146
  155. package/types/utils.d.ts +245 -245
  156. package/types/webrtc.d.ts +653 -0
@@ -1,820 +1,845 @@
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
+ it('host/class instances are passed through unchanged', () => {
489
+ // Plain objects and arrays are proxied; class instances (host objects
490
+ // like MediaStream, RTCPeerConnection, Map, Date, Blob) are not -
491
+ // wrapping them in a Proxy breaks internal-slot method calls with
492
+ // "Illegal invocation".
493
+ class Host {
494
+ constructor() { this.value = 1; }
495
+ method() { return this.value; }
496
+ }
497
+ const host = new Host();
498
+ const r = reactive({ host }, () => {});
499
+ expect(r.host).toBe(host);
500
+ expect(r.host.method()).toBe(1);
501
+ });
502
+
503
+ it('Map / Set / Date are not wrapped', () => {
504
+ const m = new Map();
505
+ const s = new Set();
506
+ const d = new Date();
507
+ const r = reactive({ m, s, d }, () => {});
508
+ expect(r.m).toBe(m);
509
+ expect(r.s).toBe(s);
510
+ expect(r.d).toBe(d);
511
+ });
512
+ });
513
+
514
+
515
+ // ===========================================================================
516
+ // Signal - advanced
517
+ // ===========================================================================
518
+
519
+ describe('Signal - advanced', () => {
520
+ it('peek() does not trigger tracking', () => {
521
+ const s = signal(1);
522
+ const fn = vi.fn(() => { s.peek(); });
523
+ effect(fn);
524
+ fn.mockClear();
525
+ s.value = 2;
526
+ // fn should NOT re-run because peek() didn't track
527
+ expect(fn).not.toHaveBeenCalled();
528
+ });
529
+
530
+ it('toString() returns string representation', () => {
531
+ const s = signal(42);
532
+ expect(s.toString()).toBe('42');
533
+ expect(`${s}`).toBe('42');
534
+ });
535
+
536
+ it('subscribe returns unsubscribe function', () => {
537
+ const s = signal(0);
538
+ const fn = vi.fn();
539
+ const unsub = s.subscribe(fn);
540
+ s.value = 1;
541
+ expect(fn).toHaveBeenCalledTimes(1);
542
+ unsub();
543
+ s.value = 2;
544
+ expect(fn).toHaveBeenCalledTimes(1);
545
+ });
546
+
547
+ it('same-value write is a no-op', () => {
548
+ const s = signal(10);
549
+ const fn = vi.fn();
550
+ s.subscribe(fn);
551
+ s.value = 10;
552
+ expect(fn).not.toHaveBeenCalled();
553
+ });
554
+
555
+ it('signal with object value notifies on reference change', () => {
556
+ const s = signal({ x: 1 });
557
+ const fn = vi.fn();
558
+ s.subscribe(fn);
559
+ s.value = { x: 2 };
560
+ expect(fn).toHaveBeenCalledTimes(1);
561
+ });
562
+
563
+ it('subscriber error does not stop others', () => {
564
+ const s = signal(0);
565
+ const first = vi.fn(() => { throw new Error('oops'); });
566
+ const second = vi.fn();
567
+ s.subscribe(first);
568
+ s.subscribe(second);
569
+ s.value = 1;
570
+ expect(first).toHaveBeenCalled();
571
+ expect(second).toHaveBeenCalled();
572
+ });
573
+ });
574
+
575
+
576
+ // ===========================================================================
577
+ // effect() - advanced
578
+ // ===========================================================================
579
+
580
+ describe('effect - advanced', () => {
581
+ it('returns dispose function', () => {
582
+ const s = signal(0);
583
+ const fn = vi.fn(() => s.value);
584
+ const dispose = effect(fn);
585
+ expect(typeof dispose).toBe('function');
586
+ fn.mockClear();
587
+ dispose();
588
+ s.value = 1;
589
+ expect(fn).not.toHaveBeenCalled();
590
+ });
591
+
592
+ it('cleans up stale dependencies on re-run', () => {
593
+ const a = signal(true);
594
+ const b = signal('B');
595
+ const c = signal('C');
596
+ const results = [];
597
+
598
+ effect(() => {
599
+ if (a.value) {
600
+ results.push(b.value);
601
+ } else {
602
+ results.push(c.value);
603
+ }
604
+ });
605
+
606
+ expect(results).toEqual(['B']);
607
+
608
+ a.value = false;
609
+ expect(results).toEqual(['B', 'C']);
610
+
611
+ // Changing b should NOT trigger the effect now (stale dep)
612
+ b.value = 'B2';
613
+ expect(results).toEqual(['B', 'C']);
614
+ });
615
+
616
+ it('effect that throws still cleans up', () => {
617
+ const s = signal(0);
618
+ let callCount = 0;
619
+ effect(() => {
620
+ s.value; // track
621
+ callCount++;
622
+ if (callCount > 1) throw new Error('boom');
623
+ });
624
+ expect(callCount).toBe(1);
625
+ s.value = 1; // triggers re-run which throws
626
+ expect(callCount).toBe(2);
627
+ // Should still be reactive
628
+ s.value = 2;
629
+ expect(callCount).toBe(3);
630
+ });
631
+
632
+ it('nested effects work independently', () => {
633
+ const a = signal(0);
634
+ const b = signal(0);
635
+ const outerFn = vi.fn();
636
+ const innerFn = vi.fn();
637
+
638
+ effect(() => {
639
+ outerFn(a.value);
640
+ effect(() => { innerFn(b.value); });
641
+ });
642
+
643
+ expect(outerFn).toHaveBeenCalledWith(0);
644
+ expect(innerFn).toHaveBeenCalledWith(0);
645
+
646
+ b.value = 1;
647
+ expect(innerFn).toHaveBeenCalledWith(1);
648
+ });
649
+ });
650
+
651
+
652
+ // ===========================================================================
653
+ // computed() - advanced
654
+ // ===========================================================================
655
+
656
+ describe('computed - advanced', () => {
657
+ it('computed does not notify when value unchanged', () => {
658
+ const s = signal(5);
659
+ const c = computed(() => s.value > 3);
660
+ const fn = vi.fn();
661
+ c.subscribe(fn);
662
+
663
+ s.value = 10; // c still true - no change
664
+ expect(fn).not.toHaveBeenCalled();
665
+ });
666
+
667
+ it('computed chains', () => {
668
+ const a = signal(2);
669
+ const doubled = computed(() => a.value * 2);
670
+ const quadrupled = computed(() => doubled.value * 2);
671
+ expect(quadrupled.value).toBe(8);
672
+ a.value = 3;
673
+ expect(quadrupled.value).toBe(12);
674
+ });
675
+
676
+ it('computed with multiple signals', () => {
677
+ const first = signal('John');
678
+ const last = signal('Doe');
679
+ const full = computed(() => `${first.value} ${last.value}`);
680
+ expect(full.value).toBe('John Doe');
681
+ first.value = 'Jane';
682
+ expect(full.value).toBe('Jane Doe');
683
+ });
684
+ });
685
+
686
+
687
+ // ===========================================================================
688
+ // batch()
689
+ // ===========================================================================
690
+
691
+ describe('batch()', () => {
692
+ it('defers effect execution until batch completes', () => {
693
+ const a = signal(1);
694
+ const b = signal(2);
695
+ const fn = vi.fn();
696
+
697
+ effect(() => {
698
+ fn(a.value + b.value);
699
+ });
700
+ fn.mockClear(); // clear initial run
701
+
702
+ batch(() => {
703
+ a.value = 10;
704
+ b.value = 20;
705
+ });
706
+
707
+ // Effect should run once after the batch, not twice
708
+ expect(fn).toHaveBeenCalledTimes(1);
709
+ expect(fn).toHaveBeenCalledWith(30);
710
+ });
711
+
712
+ it('subscribers see the final value, not intermediate', () => {
713
+ const s = signal(0);
714
+ const values = [];
715
+ s.subscribe(() => values.push(s.value));
716
+
717
+ batch(() => {
718
+ s.value = 1;
719
+ s.value = 2;
720
+ s.value = 3;
721
+ });
722
+
723
+ expect(values).toEqual([3]);
724
+ });
725
+
726
+ it('nested batch runs inner immediately, flushes at outer', () => {
727
+ const s = signal(0);
728
+ const fn = vi.fn();
729
+ s.subscribe(fn);
730
+
731
+ batch(() => {
732
+ s.value = 1;
733
+ batch(() => {
734
+ s.value = 2;
735
+ });
736
+ // inner batch should not have flushed
737
+ expect(fn).not.toHaveBeenCalled();
738
+ s.value = 3;
739
+ });
740
+
741
+ // Outer batch flushes once
742
+ expect(fn).toHaveBeenCalledTimes(1);
743
+ expect(s.value).toBe(3);
744
+ });
745
+
746
+ it('computed values update correctly after batch', () => {
747
+ const a = signal(1);
748
+ const b = signal(2);
749
+ const sum = computed(() => a.value + b.value);
750
+
751
+ batch(() => {
752
+ a.value = 10;
753
+ b.value = 20;
754
+ });
755
+
756
+ expect(sum.value).toBe(30);
757
+ });
758
+
759
+ it('effects still run if batch throws', () => {
760
+ const s = signal(0);
761
+ const fn = vi.fn();
762
+ s.subscribe(() => fn(s.value));
763
+
764
+ try {
765
+ batch(() => {
766
+ s.value = 42;
767
+ throw new Error('oops');
768
+ });
769
+ } catch {}
770
+
771
+ // Batch should still flush on error via finally
772
+ expect(fn).toHaveBeenCalledWith(42);
773
+ });
774
+ });
775
+
776
+
777
+ // ===========================================================================
778
+ // untracked()
779
+ // ===========================================================================
780
+
781
+ describe('untracked()', () => {
782
+ it('reads signals without creating dependencies', () => {
783
+ const a = signal(1);
784
+ const b = signal(10);
785
+ const fn = vi.fn();
786
+
787
+ effect(() => {
788
+ const aVal = a.value; // tracked
789
+ const bVal = untracked(() => b.value); // not tracked
790
+ fn(aVal + bVal);
791
+ });
792
+ fn.mockClear();
793
+
794
+ // Changing b should NOT re-run the effect
795
+ b.value = 20;
796
+ expect(fn).not.toHaveBeenCalled();
797
+
798
+ // Changing a should re-run and pick up new b
799
+ a.value = 2;
800
+ expect(fn).toHaveBeenCalledTimes(1);
801
+ expect(fn).toHaveBeenCalledWith(22); // a=2 + b=20
802
+ });
803
+
804
+ it('returns the value from the callback', () => {
805
+ const s = signal(42);
806
+ const result = untracked(() => s.value);
807
+ expect(result).toBe(42);
808
+ });
809
+
810
+ it('does not break tracking for outer effect', () => {
811
+ const tracked = signal('hello');
812
+ const notTracked = signal('world');
813
+ const runs = [];
814
+
815
+ effect(() => {
816
+ const t = tracked.value;
817
+ const u = untracked(() => notTracked.value);
818
+ runs.push(`${t} ${u}`);
819
+ });
820
+
821
+ expect(runs).toEqual(['hello world']);
822
+
823
+ tracked.value = 'hi';
824
+ expect(runs).toEqual(['hello world', 'hi world']);
825
+
826
+ notTracked.value = 'earth';
827
+ expect(runs).toEqual(['hello world', 'hi world']); // no re-run
828
+ });
829
+
830
+ it('works inside computed', () => {
831
+ const a = signal(5);
832
+ const b = signal(10);
833
+ const c = computed(() => a.value + untracked(() => b.value));
834
+
835
+ expect(c.value).toBe(15);
836
+
837
+ b.value = 100;
838
+ // computed shouldn't re-evaluate from b change
839
+ expect(c.value).toBe(15);
840
+
841
+ a.value = 1;
842
+ // Now recomputes, picks up new b
843
+ expect(c.value).toBe(101);
844
+ });
820
845
  });