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.
- package/LICENSE +21 -21
- package/README.md +2 -0
- package/cli/args.js +33 -33
- package/cli/commands/build-api.js +443 -442
- package/cli/commands/build.js +254 -247
- package/cli/commands/bundle.js +1228 -1224
- package/cli/commands/create.js +137 -121
- package/cli/commands/dev/devtools/index.js +56 -56
- package/cli/commands/dev/devtools/js/components.js +49 -49
- package/cli/commands/dev/devtools/js/core.js +423 -423
- package/cli/commands/dev/devtools/js/elements.js +421 -421
- package/cli/commands/dev/devtools/js/network.js +166 -166
- package/cli/commands/dev/devtools/js/performance.js +73 -73
- package/cli/commands/dev/devtools/js/router.js +105 -105
- package/cli/commands/dev/devtools/js/source.js +132 -132
- package/cli/commands/dev/devtools/js/stats.js +35 -35
- package/cli/commands/dev/devtools/js/tabs.js +79 -79
- package/cli/commands/dev/devtools/panel.html +95 -95
- package/cli/commands/dev/devtools/styles.css +244 -244
- package/cli/commands/dev/index.js +107 -107
- package/cli/commands/dev/logger.js +75 -75
- package/cli/commands/dev/overlay.js +858 -858
- package/cli/commands/dev/server.js +220 -220
- package/cli/commands/dev/validator.js +94 -94
- package/cli/commands/dev/watcher.js +172 -172
- package/cli/help.js +114 -112
- package/cli/index.js +52 -52
- package/cli/scaffold/default/LICENSE +21 -21
- package/cli/scaffold/default/app/app.js +207 -207
- package/cli/scaffold/default/app/components/about.js +201 -201
- package/cli/scaffold/default/app/components/api-demo.js +143 -143
- package/cli/scaffold/default/app/components/contact-card.js +231 -231
- package/cli/scaffold/default/app/components/contacts/contacts.css +706 -706
- package/cli/scaffold/default/app/components/contacts/contacts.html +200 -200
- package/cli/scaffold/default/app/components/contacts/contacts.js +196 -196
- package/cli/scaffold/default/app/components/counter.js +127 -127
- package/cli/scaffold/default/app/components/home.js +249 -249
- package/cli/scaffold/default/app/components/not-found.js +16 -16
- package/cli/scaffold/default/app/components/playground/playground.css +115 -115
- package/cli/scaffold/default/app/components/playground/playground.html +161 -161
- package/cli/scaffold/default/app/components/playground/playground.js +116 -116
- package/cli/scaffold/default/app/components/todos.js +225 -225
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +97 -97
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +146 -146
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +280 -280
- package/cli/scaffold/default/app/routes.js +15 -15
- package/cli/scaffold/default/app/store.js +101 -101
- package/cli/scaffold/default/global.css +552 -552
- package/cli/scaffold/default/index.html +99 -99
- package/cli/scaffold/minimal/app/app.js +85 -85
- package/cli/scaffold/minimal/app/components/about.js +68 -68
- package/cli/scaffold/minimal/app/components/counter.js +122 -122
- package/cli/scaffold/minimal/app/components/home.js +68 -68
- package/cli/scaffold/minimal/app/components/not-found.js +16 -16
- package/cli/scaffold/minimal/app/routes.js +9 -9
- package/cli/scaffold/minimal/app/store.js +36 -36
- package/cli/scaffold/minimal/global.css +300 -300
- package/cli/scaffold/minimal/index.html +44 -44
- package/cli/scaffold/ssr/app/app.js +41 -41
- package/cli/scaffold/ssr/app/components/about.js +55 -55
- package/cli/scaffold/ssr/app/components/blog/index.js +65 -65
- package/cli/scaffold/ssr/app/components/blog/post.js +86 -86
- package/cli/scaffold/ssr/app/components/home.js +37 -37
- package/cli/scaffold/ssr/app/components/not-found.js +15 -15
- package/cli/scaffold/ssr/app/routes.js +8 -8
- package/cli/scaffold/ssr/global.css +228 -228
- package/cli/scaffold/ssr/index.html +37 -37
- package/cli/scaffold/ssr/package.json +8 -8
- package/cli/scaffold/ssr/server/data/posts.js +144 -144
- package/cli/scaffold/ssr/server/index.js +213 -213
- package/cli/scaffold/webrtc/app/app.js +11 -0
- package/cli/scaffold/webrtc/app/components/video-room.js +295 -0
- package/cli/scaffold/webrtc/app/lib/room.js +252 -0
- package/cli/scaffold/webrtc/assets/.gitkeep +0 -0
- package/cli/scaffold/webrtc/global.css +250 -0
- package/cli/scaffold/webrtc/index.html +21 -0
- package/cli/utils.js +305 -287
- package/dist/API.md +661 -0
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +10313 -6614
- package/dist/zquery.min.js +8 -631
- package/index.d.ts +570 -371
- package/index.js +311 -240
- package/package.json +76 -70
- package/src/component.js +1709 -1691
- package/src/core.js +921 -921
- package/src/diff.js +497 -497
- package/src/errors.js +209 -209
- package/src/expression.js +922 -922
- package/src/http.js +242 -242
- package/src/package.json +1 -1
- package/src/reactive.js +255 -255
- package/src/router.js +843 -843
- package/src/ssr.js +418 -418
- package/src/store.js +318 -318
- package/src/utils.js +515 -515
- package/src/webrtc/e2ee.js +351 -0
- package/src/webrtc/errors.js +116 -0
- package/src/webrtc/ice.js +301 -0
- package/src/webrtc/index.js +131 -0
- package/src/webrtc/joinToken.js +119 -0
- package/src/webrtc/observe.js +172 -0
- package/src/webrtc/peer.js +351 -0
- package/src/webrtc/reactive.js +268 -0
- package/src/webrtc/room.js +625 -0
- package/src/webrtc/sdp.js +302 -0
- package/src/webrtc/sfu/index.js +43 -0
- package/src/webrtc/sfu/livekit.js +131 -0
- package/src/webrtc/sfu/mediasoup.js +150 -0
- package/src/webrtc/signaling.js +373 -0
- package/src/webrtc/turn.js +237 -0
- package/tests/_helpers/webrtcFakes.js +289 -0
- package/tests/audit.test.js +4158 -4158
- package/tests/cli.test.js +1136 -1103
- package/tests/compare.test.js +497 -486
- package/tests/component.test.js +3969 -3938
- package/tests/core.test.js +1910 -1910
- package/tests/dev-server.test.js +489 -489
- package/tests/diff.test.js +1416 -1416
- package/tests/docs.test.js +1664 -1650
- package/tests/electron-features.test.js +864 -864
- package/tests/errors.test.js +619 -619
- package/tests/expression.test.js +1056 -1056
- package/tests/http.test.js +648 -648
- package/tests/reactive.test.js +819 -819
- package/tests/router.test.js +2327 -2327
- package/tests/ssr.test.js +870 -870
- package/tests/store.test.js +830 -830
- package/tests/test-minifier.js +153 -153
- package/tests/test-ssr.js +27 -27
- package/tests/utils.test.js +1377 -1377
- package/tests/webrtc/e2ee.test.js +283 -0
- package/tests/webrtc/ice.test.js +202 -0
- package/tests/webrtc/joinToken.test.js +89 -0
- package/tests/webrtc/observe.test.js +111 -0
- package/tests/webrtc/peer.test.js +373 -0
- package/tests/webrtc/reactive.test.js +235 -0
- package/tests/webrtc/room.test.js +406 -0
- package/tests/webrtc/sdp.test.js +151 -0
- package/tests/webrtc/sfu-livekit.test.js +119 -0
- package/tests/webrtc/sfu.test.js +160 -0
- package/tests/webrtc/signaling.test.js +251 -0
- package/tests/webrtc/turn.test.js +256 -0
- package/types/collection.d.ts +383 -383
- package/types/component.d.ts +186 -186
- package/types/errors.d.ts +135 -135
- package/types/http.d.ts +92 -92
- package/types/misc.d.ts +201 -201
- package/types/reactive.d.ts +98 -98
- package/types/router.d.ts +190 -190
- package/types/ssr.d.ts +102 -102
- package/types/store.d.ts +146 -146
- package/types/utils.d.ts +245 -245
- package/types/webrtc.d.ts +653 -0
package/tests/reactive.test.js
CHANGED
|
@@ -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
|
});
|