@esportsplus/reactivity 0.30.2 → 0.31.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/build/constants.d.ts +2 -1
- package/build/constants.js +2 -1
- package/build/reactive/array.js +24 -22
- package/build/reactive/object.js +10 -11
- package/build/system.d.ts +2 -1
- package/build/system.js +35 -23
- package/build/types.d.ts +2 -3
- package/package.json +3 -2
- package/src/constants.ts +3 -1
- package/src/reactive/array.ts +27 -26
- package/src/reactive/object.ts +17 -14
- package/src/system.ts +47 -31
- package/src/types.ts +2 -8
- package/tests/array.ts +906 -0
- package/tests/async-computed.ts +195 -0
- package/tests/bench/array.ts +227 -0
- package/tests/bench/reactive-object.ts +54 -0
- package/tests/bench/system.ts +317 -0
- package/tests/compiler.ts +326 -0
- package/tests/effects.ts +211 -0
- package/tests/nested.ts +293 -0
- package/tests/objects.ts +185 -0
- package/tests/primitives.ts +280 -0
- package/tests/reactive.ts +534 -0
- package/tests/system.ts +1014 -0
- package/tests/tsconfig.json +17 -0
- package/vitest.config.ts +18 -0
- package/test/arrays.ts +0 -146
- package/test/debug.ts +0 -7
- package/test/effects.ts +0 -168
- package/test/index.ts +0 -8
- package/test/nested.ts +0 -201
- package/test/objects.ts +0 -106
- package/test/primitives.ts +0 -87
- package/test/range.ts +0 -45
- package/test/vite.config.ts +0 -41
package/tests/system.ts
ADDED
|
@@ -0,0 +1,1014 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { computed, dispose, effect, isComputed, isSignal, onCleanup, read, root, signal, write } from '~/system';
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
describe('signal', () => {
|
|
6
|
+
it('creates a signal with initial value', () => {
|
|
7
|
+
let s = signal(42);
|
|
8
|
+
|
|
9
|
+
expect(s.value).toBe(42);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('creates a signal with undefined', () => {
|
|
13
|
+
let s = signal(undefined);
|
|
14
|
+
|
|
15
|
+
expect(s.value).toBe(undefined);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('creates a signal with null', () => {
|
|
19
|
+
let s = signal(null);
|
|
20
|
+
|
|
21
|
+
expect(s.value).toBe(null);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('creates a signal with object value', () => {
|
|
25
|
+
let obj = { a: 1, b: 2 };
|
|
26
|
+
let s = signal(obj);
|
|
27
|
+
|
|
28
|
+
expect(s.value).toBe(obj);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('creates a signal with string value', () => {
|
|
32
|
+
let s = signal('hello');
|
|
33
|
+
|
|
34
|
+
expect(s.value).toBe('hello');
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('initializes with null subs', () => {
|
|
38
|
+
let s = signal(1);
|
|
39
|
+
|
|
40
|
+
expect(s.subs).toBe(null);
|
|
41
|
+
expect(s.subsTail).toBe(null);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
describe('read', () => {
|
|
47
|
+
it('reads signal value', () => {
|
|
48
|
+
let s = signal(10);
|
|
49
|
+
|
|
50
|
+
expect(read(s)).toBe(10);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('reads computed value', () => {
|
|
54
|
+
let c = computed(() => 42);
|
|
55
|
+
|
|
56
|
+
expect(read(c)).toBe(42);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('tracks dependency when inside observer', async () => {
|
|
60
|
+
let s = signal(1),
|
|
61
|
+
calls = 0;
|
|
62
|
+
|
|
63
|
+
effect(() => {
|
|
64
|
+
read(s);
|
|
65
|
+
calls++;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(calls).toBe(1);
|
|
69
|
+
|
|
70
|
+
write(s, 2);
|
|
71
|
+
await Promise.resolve();
|
|
72
|
+
|
|
73
|
+
expect(calls).toBe(2);
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
describe('write', () => {
|
|
79
|
+
it('updates signal value', () => {
|
|
80
|
+
let s = signal(1);
|
|
81
|
+
|
|
82
|
+
write(s, 2);
|
|
83
|
+
|
|
84
|
+
expect(s.value).toBe(2);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('skips write when value is identical', async () => {
|
|
88
|
+
let s = signal(1),
|
|
89
|
+
calls = 0;
|
|
90
|
+
|
|
91
|
+
effect(() => {
|
|
92
|
+
read(s);
|
|
93
|
+
calls++;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
expect(calls).toBe(1);
|
|
97
|
+
|
|
98
|
+
write(s, 1);
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
|
|
101
|
+
expect(calls).toBe(1);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('triggers subscribers on write', async () => {
|
|
105
|
+
let s = signal(0),
|
|
106
|
+
observed = -1;
|
|
107
|
+
|
|
108
|
+
effect(() => {
|
|
109
|
+
observed = read(s);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
expect(observed).toBe(0);
|
|
113
|
+
|
|
114
|
+
write(s, 5);
|
|
115
|
+
await Promise.resolve();
|
|
116
|
+
|
|
117
|
+
expect(observed).toBe(5);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('batches multiple writes in a microtask', async () => {
|
|
121
|
+
let s = signal(0),
|
|
122
|
+
calls = 0;
|
|
123
|
+
|
|
124
|
+
effect(() => {
|
|
125
|
+
read(s);
|
|
126
|
+
calls++;
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(calls).toBe(1);
|
|
130
|
+
|
|
131
|
+
write(s, 1);
|
|
132
|
+
write(s, 2);
|
|
133
|
+
write(s, 3);
|
|
134
|
+
await Promise.resolve();
|
|
135
|
+
|
|
136
|
+
expect(calls).toBe(2);
|
|
137
|
+
expect(read(s)).toBe(3);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('handles NaN correctly (NaN !== NaN always triggers)', () => {
|
|
141
|
+
let s = signal(NaN);
|
|
142
|
+
|
|
143
|
+
write(s, NaN);
|
|
144
|
+
|
|
145
|
+
expect(s.value).toBeNaN();
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
describe('computed', () => {
|
|
151
|
+
it('creates a computed value', () => {
|
|
152
|
+
let c = computed(() => 42);
|
|
153
|
+
|
|
154
|
+
expect(read(c)).toBe(42);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it('derives from signal', async () => {
|
|
158
|
+
let s = signal(2),
|
|
159
|
+
c = computed(() => read(s) * 2);
|
|
160
|
+
|
|
161
|
+
expect(read(c)).toBe(4);
|
|
162
|
+
|
|
163
|
+
write(s, 3);
|
|
164
|
+
await Promise.resolve();
|
|
165
|
+
|
|
166
|
+
expect(read(c)).toBe(6);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it('derives from multiple signals', async () => {
|
|
170
|
+
let a = signal(1),
|
|
171
|
+
b = signal(2),
|
|
172
|
+
c = computed(() => read(a) + read(b));
|
|
173
|
+
|
|
174
|
+
expect(read(c)).toBe(3);
|
|
175
|
+
|
|
176
|
+
write(a, 10);
|
|
177
|
+
await Promise.resolve();
|
|
178
|
+
|
|
179
|
+
expect(read(c)).toBe(12);
|
|
180
|
+
|
|
181
|
+
write(b, 20);
|
|
182
|
+
await Promise.resolve();
|
|
183
|
+
|
|
184
|
+
expect(read(c)).toBe(30);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('chains computeds', async () => {
|
|
188
|
+
let s = signal(1),
|
|
189
|
+
c1 = computed(() => read(s) * 2),
|
|
190
|
+
c2 = computed(() => read(c1) + 10);
|
|
191
|
+
|
|
192
|
+
expect(read(c2)).toBe(12);
|
|
193
|
+
|
|
194
|
+
write(s, 5);
|
|
195
|
+
await Promise.resolve();
|
|
196
|
+
|
|
197
|
+
expect(read(c2)).toBe(20);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('diamond dependency graph', async () => {
|
|
201
|
+
let s = signal(1),
|
|
202
|
+
a = computed(() => read(s) + 1),
|
|
203
|
+
b = computed(() => read(s) * 2),
|
|
204
|
+
c = computed(() => read(a) + read(b)),
|
|
205
|
+
calls = 0;
|
|
206
|
+
|
|
207
|
+
effect(() => {
|
|
208
|
+
read(c);
|
|
209
|
+
calls++;
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(read(c)).toBe(4);
|
|
213
|
+
expect(calls).toBe(1);
|
|
214
|
+
|
|
215
|
+
write(s, 2);
|
|
216
|
+
await Promise.resolve();
|
|
217
|
+
|
|
218
|
+
expect(read(c)).toBe(7);
|
|
219
|
+
expect(calls).toBe(2);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it('memoizes — does not recompute if deps unchanged', async () => {
|
|
223
|
+
let s = signal(1),
|
|
224
|
+
computeCalls = 0,
|
|
225
|
+
c = computed(() => {
|
|
226
|
+
computeCalls++;
|
|
227
|
+
return read(s) * 2;
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
expect(read(c)).toBe(2);
|
|
231
|
+
expect(computeCalls).toBe(1);
|
|
232
|
+
|
|
233
|
+
read(c);
|
|
234
|
+
read(c);
|
|
235
|
+
|
|
236
|
+
expect(computeCalls).toBe(1);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it('handles computed returning same value', async () => {
|
|
240
|
+
let s = signal(1),
|
|
241
|
+
effectCalls = 0,
|
|
242
|
+
c = computed(() => read(s) > 0 ? 'positive' : 'non-positive');
|
|
243
|
+
|
|
244
|
+
effect(() => {
|
|
245
|
+
read(c);
|
|
246
|
+
effectCalls++;
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
expect(effectCalls).toBe(1);
|
|
250
|
+
|
|
251
|
+
write(s, 2);
|
|
252
|
+
await Promise.resolve();
|
|
253
|
+
|
|
254
|
+
// computed returns same 'positive', so effect should not re-run
|
|
255
|
+
expect(effectCalls).toBe(1);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('deeply nested computed chain', async () => {
|
|
259
|
+
let s = signal(0),
|
|
260
|
+
c1 = computed(() => read(s) + 1),
|
|
261
|
+
c2 = computed(() => read(c1) + 1),
|
|
262
|
+
c3 = computed(() => read(c2) + 1),
|
|
263
|
+
c4 = computed(() => read(c3) + 1),
|
|
264
|
+
c5 = computed(() => read(c4) + 1);
|
|
265
|
+
|
|
266
|
+
expect(read(c5)).toBe(5);
|
|
267
|
+
|
|
268
|
+
write(s, 10);
|
|
269
|
+
await Promise.resolve();
|
|
270
|
+
|
|
271
|
+
expect(read(c5)).toBe(15);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
describe('effect', () => {
|
|
277
|
+
it('runs immediately', () => {
|
|
278
|
+
let calls = 0;
|
|
279
|
+
|
|
280
|
+
effect(() => { calls++; });
|
|
281
|
+
|
|
282
|
+
expect(calls).toBe(1);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
it('re-runs when dependency changes', async () => {
|
|
286
|
+
let s = signal(0),
|
|
287
|
+
values: number[] = [];
|
|
288
|
+
|
|
289
|
+
effect(() => {
|
|
290
|
+
values.push(read(s));
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
expect(values).toEqual([0]);
|
|
294
|
+
|
|
295
|
+
write(s, 1);
|
|
296
|
+
await Promise.resolve();
|
|
297
|
+
|
|
298
|
+
expect(values).toEqual([0, 1]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('returns dispose function', async () => {
|
|
302
|
+
let s = signal(0),
|
|
303
|
+
calls = 0;
|
|
304
|
+
|
|
305
|
+
let stop = effect(() => {
|
|
306
|
+
read(s);
|
|
307
|
+
calls++;
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
expect(calls).toBe(1);
|
|
311
|
+
|
|
312
|
+
stop();
|
|
313
|
+
|
|
314
|
+
write(s, 1);
|
|
315
|
+
await Promise.resolve();
|
|
316
|
+
|
|
317
|
+
expect(calls).toBe(1);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('tracks dynamic dependencies', async () => {
|
|
321
|
+
let a = signal(true),
|
|
322
|
+
b = signal(1),
|
|
323
|
+
c = signal(2),
|
|
324
|
+
values: number[] = [];
|
|
325
|
+
|
|
326
|
+
effect(() => {
|
|
327
|
+
values.push(read(a) ? read(b) : read(c));
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
expect(values).toEqual([1]);
|
|
331
|
+
|
|
332
|
+
write(a, false);
|
|
333
|
+
await Promise.resolve();
|
|
334
|
+
|
|
335
|
+
expect(values).toEqual([1, 2]);
|
|
336
|
+
|
|
337
|
+
// b change should not trigger since a is false
|
|
338
|
+
write(b, 10);
|
|
339
|
+
await Promise.resolve();
|
|
340
|
+
|
|
341
|
+
expect(values).toEqual([1, 2]);
|
|
342
|
+
|
|
343
|
+
// c change should trigger since a is false
|
|
344
|
+
write(c, 20);
|
|
345
|
+
await Promise.resolve();
|
|
346
|
+
|
|
347
|
+
expect(values).toEqual([1, 2, 20]);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('handles multiple effects on same signal', async () => {
|
|
351
|
+
let s = signal(0),
|
|
352
|
+
a = 0,
|
|
353
|
+
b = 0;
|
|
354
|
+
|
|
355
|
+
effect(() => { a = read(s); });
|
|
356
|
+
effect(() => { b = read(s) * 2; });
|
|
357
|
+
|
|
358
|
+
expect(a).toBe(0);
|
|
359
|
+
expect(b).toBe(0);
|
|
360
|
+
|
|
361
|
+
write(s, 5);
|
|
362
|
+
await Promise.resolve();
|
|
363
|
+
|
|
364
|
+
expect(a).toBe(5);
|
|
365
|
+
expect(b).toBe(10);
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
describe('onCleanup', () => {
|
|
371
|
+
it('runs cleanup on recomputation', async () => {
|
|
372
|
+
let s = signal(0),
|
|
373
|
+
cleaned = false;
|
|
374
|
+
|
|
375
|
+
effect(() => {
|
|
376
|
+
read(s);
|
|
377
|
+
onCleanup(() => { cleaned = true; });
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
expect(cleaned).toBe(false);
|
|
381
|
+
|
|
382
|
+
write(s, 1);
|
|
383
|
+
await Promise.resolve();
|
|
384
|
+
|
|
385
|
+
expect(cleaned).toBe(true);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it('runs cleanup on dispose', () => {
|
|
389
|
+
let cleaned = false;
|
|
390
|
+
|
|
391
|
+
let stop = effect(() => {
|
|
392
|
+
onCleanup(() => { cleaned = true; });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
expect(cleaned).toBe(false);
|
|
396
|
+
|
|
397
|
+
stop();
|
|
398
|
+
|
|
399
|
+
expect(cleaned).toBe(true);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it('supports multiple cleanup functions', async () => {
|
|
403
|
+
let s = signal(0),
|
|
404
|
+
cleanups: number[] = [];
|
|
405
|
+
|
|
406
|
+
effect(() => {
|
|
407
|
+
read(s);
|
|
408
|
+
onCleanup(() => { cleanups.push(1); });
|
|
409
|
+
onCleanup(() => { cleanups.push(2); });
|
|
410
|
+
onCleanup(() => { cleanups.push(3); });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
write(s, 1);
|
|
414
|
+
await Promise.resolve();
|
|
415
|
+
|
|
416
|
+
expect(cleanups).toEqual([1, 2, 3]);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
it('does nothing outside observer', () => {
|
|
420
|
+
let fn = vi.fn();
|
|
421
|
+
let returned = onCleanup(fn);
|
|
422
|
+
|
|
423
|
+
expect(returned).toBe(fn);
|
|
424
|
+
expect(fn).not.toHaveBeenCalled();
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
describe('root', () => {
|
|
430
|
+
it('creates untracked scope', () => {
|
|
431
|
+
let result = root(() => 42);
|
|
432
|
+
|
|
433
|
+
expect(result).toBe(42);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
it('provides dispose function when fn.length > 0', () => {
|
|
437
|
+
let disposed = false;
|
|
438
|
+
|
|
439
|
+
root((dispose) => {
|
|
440
|
+
onCleanup(() => { disposed = true; });
|
|
441
|
+
dispose();
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
expect(disposed).toBe(true);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('does not track signals inside root', async () => {
|
|
448
|
+
let s = signal(0),
|
|
449
|
+
calls = 0;
|
|
450
|
+
|
|
451
|
+
effect(() => {
|
|
452
|
+
root(() => {
|
|
453
|
+
read(s);
|
|
454
|
+
});
|
|
455
|
+
calls++;
|
|
456
|
+
});
|
|
457
|
+
|
|
458
|
+
expect(calls).toBe(1);
|
|
459
|
+
|
|
460
|
+
write(s, 1);
|
|
461
|
+
await Promise.resolve();
|
|
462
|
+
|
|
463
|
+
// Should not re-run since signal was read inside root (untracked)
|
|
464
|
+
expect(calls).toBe(1);
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
it('supports nested roots', () => {
|
|
468
|
+
let outer = 0,
|
|
469
|
+
inner = 0;
|
|
470
|
+
|
|
471
|
+
root(() => {
|
|
472
|
+
outer++;
|
|
473
|
+
root(() => {
|
|
474
|
+
inner++;
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(outer).toBe(1);
|
|
479
|
+
expect(inner).toBe(1);
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
it('tracks disposables counter for unowned computeds', () => {
|
|
483
|
+
let before = root.disposables;
|
|
484
|
+
|
|
485
|
+
root(() => {
|
|
486
|
+
computed(() => 1);
|
|
487
|
+
computed(() => 2);
|
|
488
|
+
computed(() => 3);
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
// root restores disposables to outer value after execution
|
|
492
|
+
expect(root.disposables).toBe(before);
|
|
493
|
+
|
|
494
|
+
// Nested: inner root creates computeds, outer root creates computeds
|
|
495
|
+
root(() => {
|
|
496
|
+
computed(() => 10);
|
|
497
|
+
|
|
498
|
+
root(() => {
|
|
499
|
+
computed(() => 20);
|
|
500
|
+
computed(() => 30);
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
computed(() => 40);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
expect(root.disposables).toBe(before);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
describe('dispose', () => {
|
|
512
|
+
it('disposes computed and runs cleanup', () => {
|
|
513
|
+
let cleaned = false,
|
|
514
|
+
c = computed((onCleanup) => {
|
|
515
|
+
onCleanup(() => { cleaned = true; });
|
|
516
|
+
return 42;
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
expect(cleaned).toBe(false);
|
|
520
|
+
|
|
521
|
+
dispose(c);
|
|
522
|
+
|
|
523
|
+
expect(cleaned).toBe(true);
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
it('removes computed from dependency graph', async () => {
|
|
527
|
+
let s = signal(0),
|
|
528
|
+
calls = 0,
|
|
529
|
+
c = computed(() => {
|
|
530
|
+
calls++;
|
|
531
|
+
return read(s);
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
expect(read(c)).toBe(0);
|
|
535
|
+
expect(calls).toBe(1);
|
|
536
|
+
|
|
537
|
+
dispose(c);
|
|
538
|
+
|
|
539
|
+
write(s, 1);
|
|
540
|
+
await Promise.resolve();
|
|
541
|
+
|
|
542
|
+
expect(calls).toBe(1);
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
describe('isSignal', () => {
|
|
548
|
+
it('returns true for signals', () => {
|
|
549
|
+
expect(isSignal(signal(1))).toBe(true);
|
|
550
|
+
});
|
|
551
|
+
|
|
552
|
+
it('returns false for computed', () => {
|
|
553
|
+
expect(isSignal(computed(() => 1))).toBe(false);
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
it('returns false for primitives', () => {
|
|
557
|
+
expect(isSignal(1)).toBe(false);
|
|
558
|
+
expect(isSignal('a')).toBe(false);
|
|
559
|
+
expect(isSignal(null)).toBe(false);
|
|
560
|
+
expect(isSignal(undefined)).toBe(false);
|
|
561
|
+
});
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
describe('isComputed', () => {
|
|
566
|
+
it('returns true for computed', () => {
|
|
567
|
+
expect(isComputed(computed(() => 1))).toBe(true);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
it('returns false for signals', () => {
|
|
571
|
+
expect(isComputed(signal(1))).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
it('returns false for primitives', () => {
|
|
575
|
+
expect(isComputed(1)).toBe(false);
|
|
576
|
+
expect(isComputed(null)).toBe(false);
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('returns false for objects with state field but no STATE_COMPUTED bit', () => {
|
|
580
|
+
expect(isComputed({ state: 0 })).toBe(false);
|
|
581
|
+
expect(isComputed({ state: 1 })).toBe(false);
|
|
582
|
+
});
|
|
583
|
+
});
|
|
584
|
+
|
|
585
|
+
describe('computed object size', () => {
|
|
586
|
+
it('does not have a type field', () => {
|
|
587
|
+
let c = computed(() => 42);
|
|
588
|
+
|
|
589
|
+
expect('type' in c).toBe(false);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
it('has fewer own properties than 14 (old size)', () => {
|
|
593
|
+
let c = computed(() => 42);
|
|
594
|
+
|
|
595
|
+
expect(Object.keys(c).length).toBeLessThan(14);
|
|
596
|
+
});
|
|
597
|
+
});
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
describe('edge cases', () => {
|
|
601
|
+
it('diamond graph dedup — notify state mask prevents redundant recomputation', async () => {
|
|
602
|
+
let s = signal(1),
|
|
603
|
+
calls = 0,
|
|
604
|
+
left = computed(() => read(s) + 1),
|
|
605
|
+
right = computed(() => read(s) * 2),
|
|
606
|
+
join = computed(() => {
|
|
607
|
+
calls++;
|
|
608
|
+
return read(left) + read(right);
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
effect(() => {
|
|
612
|
+
read(join);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
expect(read(join)).toBe(4);
|
|
616
|
+
calls = 0;
|
|
617
|
+
|
|
618
|
+
write(s, 2);
|
|
619
|
+
await Promise.resolve();
|
|
620
|
+
|
|
621
|
+
expect(read(join)).toBe(7);
|
|
622
|
+
expect(calls).toBe(1);
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
it('dynamic height adjustment — correct ordering after switching deps', async () => {
|
|
626
|
+
let s = signal(1),
|
|
627
|
+
toggle = signal(true),
|
|
628
|
+
a = computed(() => read(s) + 1),
|
|
629
|
+
b = computed(() => read(a) + 1),
|
|
630
|
+
c = computed(() => read(b) + 1),
|
|
631
|
+
order: string[] = [],
|
|
632
|
+
d = computed(() => {
|
|
633
|
+
order.push('d');
|
|
634
|
+
|
|
635
|
+
if (read(toggle)) {
|
|
636
|
+
return read(a);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
return read(c);
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
effect(() => {
|
|
643
|
+
read(d);
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
expect(read(d)).toBe(2);
|
|
647
|
+
order.length = 0;
|
|
648
|
+
|
|
649
|
+
// Switch to reading `c` (height 3) instead of `a` (height 1)
|
|
650
|
+
write(toggle, false);
|
|
651
|
+
await Promise.resolve();
|
|
652
|
+
|
|
653
|
+
expect(read(d)).toBe(4);
|
|
654
|
+
|
|
655
|
+
order.length = 0;
|
|
656
|
+
|
|
657
|
+
// Write to source — d should recompute after c due to height adjustment
|
|
658
|
+
write(s, 10);
|
|
659
|
+
await Promise.resolve();
|
|
660
|
+
|
|
661
|
+
expect(read(d)).toBe(13);
|
|
662
|
+
expect(order).toEqual(['d']);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it('handles circular computed reads without infinite loop', () => {
|
|
666
|
+
let s = signal(0),
|
|
667
|
+
c1 = computed(() => read(s)),
|
|
668
|
+
c2 = computed(() => read(c1));
|
|
669
|
+
|
|
670
|
+
expect(read(c2)).toBe(0);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('handles computed that throws', () => {
|
|
674
|
+
let s = signal(0),
|
|
675
|
+
c = computed(() => {
|
|
676
|
+
if (read(s) > 0) {
|
|
677
|
+
throw new Error('test');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
return read(s);
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
expect(read(c)).toBe(0);
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
it('handles rapid writes', async () => {
|
|
687
|
+
let s = signal(0),
|
|
688
|
+
values: number[] = [];
|
|
689
|
+
|
|
690
|
+
effect(() => {
|
|
691
|
+
values.push(read(s));
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
for (let i = 1; i <= 100; i++) {
|
|
695
|
+
write(s, i);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
await Promise.resolve();
|
|
699
|
+
|
|
700
|
+
// Should only run effect twice: initial + final batched
|
|
701
|
+
expect(values.length).toBe(2);
|
|
702
|
+
expect(values[values.length - 1]).toBe(100);
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it('effect can create new signals and computeds', async () => {
|
|
706
|
+
let s = signal(0),
|
|
707
|
+
inner = -1;
|
|
708
|
+
|
|
709
|
+
effect(() => {
|
|
710
|
+
let v = read(s);
|
|
711
|
+
let innerSig = signal(v * 10);
|
|
712
|
+
inner = read(innerSig);
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
expect(inner).toBe(0);
|
|
716
|
+
|
|
717
|
+
write(s, 5);
|
|
718
|
+
await Promise.resolve();
|
|
719
|
+
|
|
720
|
+
expect(inner).toBe(50);
|
|
721
|
+
});
|
|
722
|
+
|
|
723
|
+
it('write during stabilization', async () => {
|
|
724
|
+
let a = signal(0),
|
|
725
|
+
b = signal(0),
|
|
726
|
+
result = 0;
|
|
727
|
+
|
|
728
|
+
effect(() => {
|
|
729
|
+
let val = read(a);
|
|
730
|
+
|
|
731
|
+
if (val > 0) {
|
|
732
|
+
write(b, val * 10);
|
|
733
|
+
}
|
|
734
|
+
});
|
|
735
|
+
|
|
736
|
+
effect(() => {
|
|
737
|
+
result = read(b);
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
write(a, 1);
|
|
741
|
+
await Promise.resolve();
|
|
742
|
+
await Promise.resolve();
|
|
743
|
+
|
|
744
|
+
expect(result).toBe(10);
|
|
745
|
+
});
|
|
746
|
+
|
|
747
|
+
it('effect disposes inner resources on cleanup', () => {
|
|
748
|
+
let innerDisposed = false;
|
|
749
|
+
|
|
750
|
+
let stop = effect((onCleanup) => {
|
|
751
|
+
let inner = computed(() => 42);
|
|
752
|
+
|
|
753
|
+
onCleanup(() => {
|
|
754
|
+
dispose(inner);
|
|
755
|
+
innerDisposed = true;
|
|
756
|
+
});
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
expect(innerDisposed).toBe(false);
|
|
760
|
+
|
|
761
|
+
stop();
|
|
762
|
+
|
|
763
|
+
expect(innerDisposed).toBe(true);
|
|
764
|
+
});
|
|
765
|
+
|
|
766
|
+
it('stabilizer re-schedules when effect writes to signal during stabilization', async () => {
|
|
767
|
+
let a = signal(0),
|
|
768
|
+
b = signal(0),
|
|
769
|
+
bValues: number[] = [];
|
|
770
|
+
|
|
771
|
+
effect(() => {
|
|
772
|
+
let val = read(a);
|
|
773
|
+
|
|
774
|
+
if (val > 0) {
|
|
775
|
+
write(b, val * 100);
|
|
776
|
+
}
|
|
777
|
+
});
|
|
778
|
+
|
|
779
|
+
effect(() => {
|
|
780
|
+
bValues.push(read(b));
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
write(a, 3);
|
|
784
|
+
await Promise.resolve();
|
|
785
|
+
await Promise.resolve();
|
|
786
|
+
|
|
787
|
+
expect(bValues).toEqual([0, 300]);
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it('stabilizer re-schedules with nested write chain A → B → C', async () => {
|
|
791
|
+
let a = signal(0),
|
|
792
|
+
b = signal(0),
|
|
793
|
+
c = signal(0),
|
|
794
|
+
cValues: number[] = [];
|
|
795
|
+
|
|
796
|
+
effect(() => {
|
|
797
|
+
let val = read(a);
|
|
798
|
+
|
|
799
|
+
if (val > 0) {
|
|
800
|
+
write(b, val * 2);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
effect(() => {
|
|
805
|
+
let val = read(b);
|
|
806
|
+
|
|
807
|
+
if (val > 0) {
|
|
808
|
+
write(c, val * 3);
|
|
809
|
+
}
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
effect(() => {
|
|
813
|
+
cValues.push(read(c));
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
write(a, 5);
|
|
817
|
+
await Promise.resolve();
|
|
818
|
+
await Promise.resolve();
|
|
819
|
+
await Promise.resolve();
|
|
820
|
+
|
|
821
|
+
expect(cValues).toEqual([0, 30]);
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
it('computed that throws on update retains previous value', async () => {
|
|
825
|
+
let s = signal(0),
|
|
826
|
+
effectValues: number[] = [],
|
|
827
|
+
c = computed(() => {
|
|
828
|
+
let val = read(s);
|
|
829
|
+
|
|
830
|
+
if (val === 2) {
|
|
831
|
+
throw new Error('boom');
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return val * 10;
|
|
835
|
+
});
|
|
836
|
+
|
|
837
|
+
effect(() => {
|
|
838
|
+
effectValues.push(read(c));
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
expect(effectValues).toEqual([0]);
|
|
842
|
+
|
|
843
|
+
write(s, 1);
|
|
844
|
+
await Promise.resolve();
|
|
845
|
+
|
|
846
|
+
expect(effectValues).toEqual([0, 10]);
|
|
847
|
+
expect(read(c)).toBe(10);
|
|
848
|
+
|
|
849
|
+
write(s, 2);
|
|
850
|
+
await Promise.resolve();
|
|
851
|
+
|
|
852
|
+
// Value should remain 10 since throw prevented update
|
|
853
|
+
expect(read(c)).toBe(10);
|
|
854
|
+
expect(effectValues).toEqual([0, 10]);
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
it('computed alternates between throwing and succeeding', async () => {
|
|
858
|
+
let s = signal(0),
|
|
859
|
+
effectValues: number[] = [],
|
|
860
|
+
c = computed(() => {
|
|
861
|
+
let val = read(s);
|
|
862
|
+
|
|
863
|
+
if (val % 2 !== 0) {
|
|
864
|
+
throw new Error('odd');
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
return val;
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
effect(() => {
|
|
871
|
+
effectValues.push(read(c));
|
|
872
|
+
});
|
|
873
|
+
|
|
874
|
+
expect(effectValues).toEqual([0]);
|
|
875
|
+
|
|
876
|
+
write(s, 1);
|
|
877
|
+
await Promise.resolve();
|
|
878
|
+
|
|
879
|
+
// Threw on odd, value stays 0
|
|
880
|
+
expect(read(c)).toBe(0);
|
|
881
|
+
expect(effectValues).toEqual([0]);
|
|
882
|
+
|
|
883
|
+
write(s, 2);
|
|
884
|
+
await Promise.resolve();
|
|
885
|
+
|
|
886
|
+
// Succeeds on even, value updates
|
|
887
|
+
expect(read(c)).toBe(2);
|
|
888
|
+
expect(effectValues).toEqual([0, 2]);
|
|
889
|
+
|
|
890
|
+
write(s, 3);
|
|
891
|
+
await Promise.resolve();
|
|
892
|
+
|
|
893
|
+
// Threw on odd again, value stays 2
|
|
894
|
+
expect(read(c)).toBe(2);
|
|
895
|
+
expect(effectValues).toEqual([0, 2]);
|
|
896
|
+
|
|
897
|
+
write(s, 4);
|
|
898
|
+
await Promise.resolve();
|
|
899
|
+
|
|
900
|
+
// Succeeds again
|
|
901
|
+
expect(read(c)).toBe(4);
|
|
902
|
+
expect(effectValues).toEqual([0, 2, 4]);
|
|
903
|
+
});
|
|
904
|
+
|
|
905
|
+
it('heap auto-resizes for computed chain deeper than 64', async () => {
|
|
906
|
+
let s = signal(0),
|
|
907
|
+
chain: ReturnType<typeof computed>[] = [computed(() => read(s) + 1)];
|
|
908
|
+
|
|
909
|
+
for (let i = 1; i < 80; i++) {
|
|
910
|
+
let prev = chain[i - 1];
|
|
911
|
+
|
|
912
|
+
chain.push(computed(() => read(prev) + 1));
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
let tail = chain[chain.length - 1],
|
|
916
|
+
result = -1;
|
|
917
|
+
|
|
918
|
+
effect(() => {
|
|
919
|
+
result = read(tail);
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
expect(result).toBe(80);
|
|
923
|
+
|
|
924
|
+
write(s, 10);
|
|
925
|
+
await Promise.resolve();
|
|
926
|
+
|
|
927
|
+
expect(result).toBe(90);
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('system remains functional under high effect churn', async () => {
|
|
931
|
+
let s = signal(0),
|
|
932
|
+
stops: (() => void)[] = [];
|
|
933
|
+
|
|
934
|
+
for (let i = 0; i < 200; i++) {
|
|
935
|
+
stops.push(effect(() => { read(s); }));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
for (let i = 0, n = stops.length; i < n; i++) {
|
|
939
|
+
stops[i]();
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
stops.length = 0;
|
|
943
|
+
|
|
944
|
+
let result = -1;
|
|
945
|
+
|
|
946
|
+
effect(() => {
|
|
947
|
+
result = read(s);
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
write(s, 42);
|
|
951
|
+
await Promise.resolve();
|
|
952
|
+
|
|
953
|
+
expect(result).toBe(42);
|
|
954
|
+
|
|
955
|
+
for (let i = 0; i < 200; i++) {
|
|
956
|
+
stops.push(effect(() => { read(s); }));
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
for (let i = 0, n = stops.length; i < n; i++) {
|
|
960
|
+
stops[i]();
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
write(s, 99);
|
|
964
|
+
await Promise.resolve();
|
|
965
|
+
|
|
966
|
+
expect(result).toBe(99);
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
it('link pool handles >1000 dependencies with disposal and reuse', async () => {
|
|
970
|
+
let signals: ReturnType<typeof signal>[] = [],
|
|
971
|
+
stops: (() => void)[] = [];
|
|
972
|
+
|
|
973
|
+
for (let i = 0; i < 1100; i++) {
|
|
974
|
+
signals.push(signal(i));
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
// Create effect reading all 1100 signals
|
|
978
|
+
stops.push(effect(() => {
|
|
979
|
+
for (let i = 0, n = signals.length; i < n; i++) {
|
|
980
|
+
read(signals[i]);
|
|
981
|
+
}
|
|
982
|
+
}));
|
|
983
|
+
|
|
984
|
+
// Dispose to return links to pool
|
|
985
|
+
stops[0]();
|
|
986
|
+
stops.length = 0;
|
|
987
|
+
|
|
988
|
+
// Create new effects reusing pooled links
|
|
989
|
+
let sum = -1;
|
|
990
|
+
|
|
991
|
+
stops.push(effect(() => {
|
|
992
|
+
let total = 0;
|
|
993
|
+
|
|
994
|
+
for (let i = 0; i < 50; i++) {
|
|
995
|
+
total += read(signals[i]);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
sum = total;
|
|
999
|
+
}));
|
|
1000
|
+
|
|
1001
|
+
// sum of 0..49 = 1225
|
|
1002
|
+
expect(sum).toBe(1225);
|
|
1003
|
+
|
|
1004
|
+
write(signals[0], 100);
|
|
1005
|
+
await Promise.resolve();
|
|
1006
|
+
|
|
1007
|
+
// 1225 - 0 + 100 = 1325
|
|
1008
|
+
expect(sum).toBe(1325);
|
|
1009
|
+
|
|
1010
|
+
for (let i = 0, n = stops.length; i < n; i++) {
|
|
1011
|
+
stops[i]();
|
|
1012
|
+
}
|
|
1013
|
+
});
|
|
1014
|
+
});
|