@esportsplus/reactivity 0.31.0 → 0.31.1
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/{readme.md → README.md} +14 -11
- package/build/reactive/array.js +3 -0
- package/package.json +1 -1
- package/src/reactive/array.ts +4 -0
- package/tests/array.ts +120 -0
- package/tests/async-computed.ts +44 -0
- package/tests/bench/array.ts +12 -0
- package/tests/bench/reactive-object.ts +7 -2
- package/tests/bench/system.ts +55 -1
- package/tests/objects.ts +84 -0
- package/tests/system.ts +150 -0
package/{readme.md → README.md}
RENAMED
|
@@ -135,7 +135,7 @@ The library requires a build-time transformer to convert `reactive()` calls into
|
|
|
135
135
|
```typescript
|
|
136
136
|
// vite.config.ts
|
|
137
137
|
import { defineConfig } from 'vite';
|
|
138
|
-
import reactivity from '@esportsplus/reactivity/
|
|
138
|
+
import reactivity from '@esportsplus/reactivity/compiler/vite';
|
|
139
139
|
|
|
140
140
|
export default defineConfig({
|
|
141
141
|
plugins: [
|
|
@@ -153,7 +153,7 @@ For direct TypeScript compilation using `ttsc` or `ts-patch`:
|
|
|
153
153
|
{
|
|
154
154
|
"compilerOptions": {
|
|
155
155
|
"plugins": [
|
|
156
|
-
{ "transform": "@esportsplus/reactivity/
|
|
156
|
+
{ "transform": "@esportsplus/reactivity/compiler/tsc" }
|
|
157
157
|
]
|
|
158
158
|
}
|
|
159
159
|
}
|
|
@@ -221,9 +221,9 @@ let user = new ReactiveObject_1();
|
|
|
221
221
|
| `reactive(() => expr)` | Creates a computed value (compile-time only) |
|
|
222
222
|
| `reactive({...})` | Creates a reactive object with signals and computeds |
|
|
223
223
|
| `reactive([...])` | Creates a reactive array |
|
|
224
|
-
| `effect(fn)` | Runs a function that re-executes when dependencies change |
|
|
225
|
-
| `root(fn)` | Creates an untracked scope
|
|
226
|
-
| `onCleanup(fn)` | Registers a cleanup function for the current effect |
|
|
224
|
+
| `effect(fn)` | Runs a function that re-executes when dependencies change. Returns a dispose function |
|
|
225
|
+
| `root(fn)` | Creates an untracked scope. If `fn` accepts an argument, a dispose function is provided |
|
|
226
|
+
| `onCleanup(fn)` | Registers a cleanup function for the current effect/computed |
|
|
227
227
|
|
|
228
228
|
### Low-Level Functions
|
|
229
229
|
|
|
@@ -235,6 +235,7 @@ These are typically only used by the transformer output:
|
|
|
235
235
|
| `computed(fn)` | Creates a raw computed |
|
|
236
236
|
| `read(node)` | Reads a signal or computed value |
|
|
237
237
|
| `write(signal, value)` | Sets a signal value |
|
|
238
|
+
| `asyncComputed(fn)` | Creates a signal that resolves an async computed. Initial value is `undefined` |
|
|
238
239
|
| `dispose(computed)` | Disposes a computed and its dependencies |
|
|
239
240
|
|
|
240
241
|
### Type Guards
|
|
@@ -271,7 +272,9 @@ Symbol constants for type identification:
|
|
|
271
272
|
|------|-------------|
|
|
272
273
|
| `Signal<T>` | Signal node type |
|
|
273
274
|
| `Computed<T>` | Computed node type |
|
|
275
|
+
| `Link` | Dependency graph link between nodes |
|
|
274
276
|
| `Reactive<T>` | Utility type for inferring reactive object/array types |
|
|
277
|
+
| `TransformResult` | Compiler transform output metadata |
|
|
275
278
|
|
|
276
279
|
## ReactiveArray
|
|
277
280
|
|
|
@@ -279,14 +282,14 @@ Symbol constants for type identification:
|
|
|
279
282
|
|
|
280
283
|
| Method | Description |
|
|
281
284
|
|--------|-------------|
|
|
282
|
-
|
|
|
283
|
-
|
|
|
284
|
-
| `
|
|
285
|
-
| `dispose()` | Disposes all nested reactive objects |
|
|
286
|
-
| `on(event, listener)` | Subscribes to an array event |
|
|
285
|
+
| `clear()` | Removes all items, disposes nested reactive objects, and dispatches `clear` event |
|
|
286
|
+
| `concat(...items)` | Appends items **in place** (mutating, returns `this`). Unlike `Array.prototype.concat` |
|
|
287
|
+
| `dispatch(event, value?)` | Manually dispatches an event to registered listeners |
|
|
288
|
+
| `dispose()` | Disposes all nested reactive objects and empties the array |
|
|
289
|
+
| `on(event, listener)` | Subscribes to an array event. Deduplicates by reference |
|
|
287
290
|
| `once(event, listener)` | Subscribes to an event once |
|
|
288
291
|
|
|
289
|
-
All standard array methods (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse
|
|
292
|
+
All standard array methods (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`, `reverse`) are overridden and trigger corresponding events. Empty calls to `push`, `unshift`, and `concat` are no-ops.
|
|
290
293
|
|
|
291
294
|
### Events
|
|
292
295
|
|
package/build/reactive/array.js
CHANGED
|
@@ -191,6 +191,9 @@ class ReactiveArray extends Array {
|
|
|
191
191
|
return removed;
|
|
192
192
|
}
|
|
193
193
|
unshift(...items) {
|
|
194
|
+
if (!items.length) {
|
|
195
|
+
return this.length;
|
|
196
|
+
}
|
|
194
197
|
let length = super.unshift(...items);
|
|
195
198
|
write(this._length, length);
|
|
196
199
|
this.dispatch('unshift', { items });
|
package/package.json
CHANGED
package/src/reactive/array.ts
CHANGED
package/tests/array.ts
CHANGED
|
@@ -97,6 +97,35 @@ describe('ReactiveArray', () => {
|
|
|
97
97
|
expect(arr[5]).toBe(99);
|
|
98
98
|
expect(lengths).toEqual([3]);
|
|
99
99
|
});
|
|
100
|
+
|
|
101
|
+
it('$set creates sparse array without updating reactive $length', async () => {
|
|
102
|
+
let arr = new ReactiveArray<number>(),
|
|
103
|
+
lengths: number[] = [];
|
|
104
|
+
|
|
105
|
+
effect(() => {
|
|
106
|
+
lengths.push(arr.$length);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
expect(lengths).toEqual([0]);
|
|
110
|
+
|
|
111
|
+
arr.$set(100, 42);
|
|
112
|
+
await Promise.resolve();
|
|
113
|
+
|
|
114
|
+
// Value is set at index 100
|
|
115
|
+
expect(arr[100]).toBe(42);
|
|
116
|
+
|
|
117
|
+
// Native length becomes 101 via Array behavior
|
|
118
|
+
expect(arr.length).toBe(101);
|
|
119
|
+
|
|
120
|
+
// Reactive $length NOT updated: this[100] = value sets native .length
|
|
121
|
+
// to 101 before the check, so 100 >= 101 is false
|
|
122
|
+
expect(lengths).toEqual([0]);
|
|
123
|
+
|
|
124
|
+
// Intermediate indices are empty (sparse)
|
|
125
|
+
expect(arr[0]).toBe(undefined);
|
|
126
|
+
expect(arr[50]).toBe(undefined);
|
|
127
|
+
expect(arr[99]).toBe(undefined);
|
|
128
|
+
});
|
|
100
129
|
});
|
|
101
130
|
|
|
102
131
|
|
|
@@ -321,6 +350,26 @@ describe('ReactiveArray', () => {
|
|
|
321
350
|
|
|
322
351
|
expect(events).toEqual([[1, 2]]);
|
|
323
352
|
});
|
|
353
|
+
|
|
354
|
+
it('no-op for empty unshift', async () => {
|
|
355
|
+
let arr = new ReactiveArray(1, 2),
|
|
356
|
+
dispatched = false,
|
|
357
|
+
lengths: number[] = [];
|
|
358
|
+
|
|
359
|
+
arr.on('unshift', () => { dispatched = true; });
|
|
360
|
+
|
|
361
|
+
effect(() => { lengths.push(arr.$length); });
|
|
362
|
+
|
|
363
|
+
expect(lengths).toEqual([2]);
|
|
364
|
+
|
|
365
|
+
let result = arr.unshift();
|
|
366
|
+
|
|
367
|
+
await Promise.resolve();
|
|
368
|
+
|
|
369
|
+
expect(result).toBe(2);
|
|
370
|
+
expect(dispatched).toBe(false);
|
|
371
|
+
expect(lengths).toEqual([2]);
|
|
372
|
+
});
|
|
324
373
|
});
|
|
325
374
|
|
|
326
375
|
|
|
@@ -369,6 +418,32 @@ describe('ReactiveArray', () => {
|
|
|
369
418
|
|
|
370
419
|
expect(dispatched).toBe(false);
|
|
371
420
|
});
|
|
421
|
+
|
|
422
|
+
it('splice with start beyond array length removes nothing', () => {
|
|
423
|
+
let arr = new ReactiveArray(1, 2, 3),
|
|
424
|
+
dispatched = false;
|
|
425
|
+
|
|
426
|
+
arr.on('splice', () => { dispatched = true; });
|
|
427
|
+
|
|
428
|
+
let removed = arr.splice(100, 1);
|
|
429
|
+
|
|
430
|
+
expect([...removed]).toEqual([]);
|
|
431
|
+
expect([...arr]).toEqual([1, 2, 3]);
|
|
432
|
+
expect(dispatched).toBe(false);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it('splice with negative start removes from end', () => {
|
|
436
|
+
let arr = new ReactiveArray(1, 2, 3, 4, 5),
|
|
437
|
+
events: { start: number; deleteCount: number; items: number[] }[] = [];
|
|
438
|
+
|
|
439
|
+
arr.on('splice', (e) => { events.push(e); });
|
|
440
|
+
|
|
441
|
+
let removed = arr.splice(-2, 1);
|
|
442
|
+
|
|
443
|
+
expect([...removed]).toEqual([4]);
|
|
444
|
+
expect([...arr]).toEqual([1, 2, 3, 5]);
|
|
445
|
+
expect(events).toEqual([{ start: -2, deleteCount: 1, items: [] }]);
|
|
446
|
+
});
|
|
372
447
|
});
|
|
373
448
|
|
|
374
449
|
|
|
@@ -493,6 +568,20 @@ describe('ReactiveArray', () => {
|
|
|
493
568
|
|
|
494
569
|
expect([...arr]).toEqual([1, 2, 2]);
|
|
495
570
|
});
|
|
571
|
+
|
|
572
|
+
it('sort preserves object references', () => {
|
|
573
|
+
let a = { id: 3 },
|
|
574
|
+
b = { id: 1 },
|
|
575
|
+
c = { id: 2 },
|
|
576
|
+
arr = new ReactiveArray(a, b, c);
|
|
577
|
+
|
|
578
|
+
arr.sort((x, y) => x.id - y.id);
|
|
579
|
+
|
|
580
|
+
// Sorted order: b(1), c(2), a(3) — same object references
|
|
581
|
+
expect(arr[0]).toBe(b);
|
|
582
|
+
expect(arr[1]).toBe(c);
|
|
583
|
+
expect(arr[2]).toBe(a);
|
|
584
|
+
});
|
|
496
585
|
});
|
|
497
586
|
|
|
498
587
|
|
|
@@ -654,6 +743,37 @@ describe('ReactiveArray', () => {
|
|
|
654
743
|
expect(arr.listeners['push']!.length).toBe(1);
|
|
655
744
|
expect(fn1).toHaveBeenCalledTimes(1);
|
|
656
745
|
});
|
|
746
|
+
|
|
747
|
+
it('on() inserts after trailing nulls cleaned from dispatch', () => {
|
|
748
|
+
let arr = new ReactiveArray<number>(),
|
|
749
|
+
fn1 = vi.fn(),
|
|
750
|
+
fn2 = () => { throw new Error('err2'); },
|
|
751
|
+
fn3 = () => { throw new Error('err3'); };
|
|
752
|
+
|
|
753
|
+
arr.on('push', fn1);
|
|
754
|
+
arr.on('push', fn2);
|
|
755
|
+
arr.on('push', fn3);
|
|
756
|
+
|
|
757
|
+
// Dispatch: fn2 and fn3 throw → nulled → trailing nulls cleaned
|
|
758
|
+
// listeners = [fn1, null, null] → cleanup → [fn1]
|
|
759
|
+
arr.push(1);
|
|
760
|
+
|
|
761
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
762
|
+
expect(arr.listeners['push']!.length).toBe(1);
|
|
763
|
+
|
|
764
|
+
// Register fn4 — should append at index 1 (no holes left)
|
|
765
|
+
let fn4 = vi.fn();
|
|
766
|
+
|
|
767
|
+
arr.on('push', fn4);
|
|
768
|
+
|
|
769
|
+
expect(arr.listeners['push']!.length).toBe(2);
|
|
770
|
+
|
|
771
|
+
// Both fn1 and fn4 called on next push
|
|
772
|
+
arr.push(2);
|
|
773
|
+
|
|
774
|
+
expect(fn1).toHaveBeenCalledTimes(2);
|
|
775
|
+
expect(fn4).toHaveBeenCalledTimes(1);
|
|
776
|
+
});
|
|
657
777
|
});
|
|
658
778
|
|
|
659
779
|
|
package/tests/async-computed.ts
CHANGED
|
@@ -160,6 +160,50 @@ describe('asyncComputed', () => {
|
|
|
160
160
|
expect(read(node)).toBe(1);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
|
+
it('nested asyncComputed — B depends on A', async () => {
|
|
164
|
+
let s = signal(5),
|
|
165
|
+
nodeA!: ReturnType<typeof asyncComputed<number>>,
|
|
166
|
+
nodeB!: ReturnType<typeof asyncComputed<number>>;
|
|
167
|
+
|
|
168
|
+
root(() => {
|
|
169
|
+
nodeA = asyncComputed(() => Promise.resolve(read(s) * 2));
|
|
170
|
+
|
|
171
|
+
nodeB = asyncComputed(() => {
|
|
172
|
+
let a = read(nodeA);
|
|
173
|
+
|
|
174
|
+
if (a === undefined) {
|
|
175
|
+
return Promise.resolve(0);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return Promise.resolve(a + 100);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Initially both undefined
|
|
183
|
+
expect(read(nodeA)).toBeUndefined();
|
|
184
|
+
expect(read(nodeB)).toBeUndefined();
|
|
185
|
+
|
|
186
|
+
// Wait for A to resolve
|
|
187
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
188
|
+
|
|
189
|
+
expect(read(nodeA)).toBe(10);
|
|
190
|
+
|
|
191
|
+
// Wait for B to react to A's resolved value
|
|
192
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
193
|
+
|
|
194
|
+
expect(read(nodeB)).toBe(110);
|
|
195
|
+
|
|
196
|
+
// Update source signal — A and B should both update
|
|
197
|
+
write(s, 20);
|
|
198
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
199
|
+
|
|
200
|
+
expect(read(nodeA)).toBe(40);
|
|
201
|
+
|
|
202
|
+
await new Promise((r) => setTimeout(r, 10));
|
|
203
|
+
|
|
204
|
+
expect(read(nodeB)).toBe(140);
|
|
205
|
+
});
|
|
206
|
+
|
|
163
207
|
it('rejected promise does not crash and retains previous value', async () => {
|
|
164
208
|
let node!: ReturnType<typeof asyncComputed<number>>,
|
|
165
209
|
s = signal(1);
|
package/tests/bench/array.ts
CHANGED
|
@@ -102,6 +102,18 @@ describe('ReactiveArray sort', () => {
|
|
|
102
102
|
|
|
103
103
|
arr.sort((a, b) => a - b);
|
|
104
104
|
});
|
|
105
|
+
|
|
106
|
+
bench('sort 1000 items', () => {
|
|
107
|
+
let items = [];
|
|
108
|
+
|
|
109
|
+
for (let i = 1000; i > 0; i--) {
|
|
110
|
+
items.push(i);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
let arr = new ReactiveArray(...items);
|
|
114
|
+
|
|
115
|
+
arr.sort((a, b) => a - b);
|
|
116
|
+
});
|
|
105
117
|
});
|
|
106
118
|
|
|
107
119
|
|
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import { bench, describe } from 'vitest';
|
|
2
|
-
import { computed, dispose, effect, read, root, signal, write } from '~/system';
|
|
3
|
-
import { ReactiveArray } from '~/reactive/array';
|
|
4
2
|
import { ReactiveObject } from '~/reactive/object';
|
|
5
3
|
|
|
6
4
|
|
|
@@ -16,6 +14,13 @@ describe('ReactiveObject creation', () => {
|
|
|
16
14
|
sum: () => 0
|
|
17
15
|
});
|
|
18
16
|
});
|
|
17
|
+
|
|
18
|
+
bench('create with async computed property', () => {
|
|
19
|
+
new ReactiveObject({
|
|
20
|
+
value: 1,
|
|
21
|
+
async: () => Promise.resolve(0)
|
|
22
|
+
});
|
|
23
|
+
});
|
|
19
24
|
});
|
|
20
25
|
|
|
21
26
|
|
package/tests/bench/system.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { bench, describe } from 'vitest';
|
|
2
|
-
import { computed, dispose, effect, onCleanup, read, root, signal, write } from '~/system';
|
|
2
|
+
import { asyncComputed, computed, dispose, effect, onCleanup, read, root, signal, write } from '~/system';
|
|
3
3
|
|
|
4
4
|
|
|
5
5
|
describe('signal', () => {
|
|
@@ -76,6 +76,19 @@ describe('computed', () => {
|
|
|
76
76
|
});
|
|
77
77
|
|
|
78
78
|
|
|
79
|
+
describe('asyncComputed', () => {
|
|
80
|
+
bench('create asyncComputed', () => {
|
|
81
|
+
asyncComputed(() => Promise.resolve(0));
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
bench('create asyncComputed from signal', () => {
|
|
85
|
+
let s = signal(0);
|
|
86
|
+
|
|
87
|
+
asyncComputed(() => Promise.resolve(read(s)));
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
|
|
79
92
|
describe('effect', () => {
|
|
80
93
|
bench('create + dispose effect', () => {
|
|
81
94
|
let stop = effect(() => {});
|
|
@@ -261,6 +274,26 @@ describe('deep propagation', () => {
|
|
|
261
274
|
|
|
262
275
|
write(s, ++i);
|
|
263
276
|
});
|
|
277
|
+
|
|
278
|
+
bench('deep chain (100 computeds)', () => {
|
|
279
|
+
let s = signal(0),
|
|
280
|
+
chain: ReturnType<typeof computed>[] = [],
|
|
281
|
+
i = 0;
|
|
282
|
+
|
|
283
|
+
chain[0] = computed(() => read(s) + 1);
|
|
284
|
+
|
|
285
|
+
for (let j = 1; j < 100; j++) {
|
|
286
|
+
let prev = chain[j - 1];
|
|
287
|
+
|
|
288
|
+
chain[j] = computed(() => read(prev) + 1);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
effect(() => {
|
|
292
|
+
read(chain[99]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
write(s, ++i);
|
|
296
|
+
});
|
|
264
297
|
});
|
|
265
298
|
|
|
266
299
|
|
|
@@ -284,6 +317,27 @@ describe('stabilization', () => {
|
|
|
284
317
|
|
|
285
318
|
write(a, ++i);
|
|
286
319
|
});
|
|
320
|
+
|
|
321
|
+
bench('read computed during stabilization', () => {
|
|
322
|
+
let a = signal(0),
|
|
323
|
+
b = signal(0),
|
|
324
|
+
c = computed(() => read(b) * 2),
|
|
325
|
+
i = 0;
|
|
326
|
+
|
|
327
|
+
effect(() => {
|
|
328
|
+
let val = read(a);
|
|
329
|
+
|
|
330
|
+
if (val > 0) {
|
|
331
|
+
write(b, val * 10);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
effect(() => {
|
|
336
|
+
read(c);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
write(a, ++i);
|
|
340
|
+
});
|
|
287
341
|
});
|
|
288
342
|
|
|
289
343
|
|
package/tests/objects.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { computed, effect, read, signal, write } from '~/system';
|
|
3
|
+
import { COMPUTED, REACTIVE_ARRAY } from '~/constants';
|
|
3
4
|
import { ReactiveObject } from '~/reactive/object';
|
|
4
5
|
import { ReactiveArray } from '~/reactive/array';
|
|
5
6
|
import reactive from '~/reactive/index';
|
|
@@ -19,6 +20,15 @@ describe('reactive object patterns', () => {
|
|
|
19
20
|
expect(obj.name).toBe('John');
|
|
20
21
|
});
|
|
21
22
|
|
|
23
|
+
it('empty object constructor', () => {
|
|
24
|
+
let obj = new ReactiveObject({}) as any;
|
|
25
|
+
|
|
26
|
+
// No reactive properties created — only internal 'disposers' field
|
|
27
|
+
expect(Object.keys(obj).filter((k: string) => k !== 'disposers')).toEqual([]);
|
|
28
|
+
|
|
29
|
+
obj.dispose();
|
|
30
|
+
});
|
|
31
|
+
|
|
22
32
|
it('updates multiple properties independently', async () => {
|
|
23
33
|
let obj = new ReactiveObject({ age: 25, name: 'John' }) as any,
|
|
24
34
|
ages: number[] = [],
|
|
@@ -160,6 +170,65 @@ describe('reactive object patterns', () => {
|
|
|
160
170
|
});
|
|
161
171
|
|
|
162
172
|
|
|
173
|
+
describe('subclass overrides', () => {
|
|
174
|
+
it('[COMPUTED] subclass creates computed field from external signal', async () => {
|
|
175
|
+
class Doubled extends ReactiveObject<Record<string, never>> {
|
|
176
|
+
private _doubled: ReturnType<typeof computed<number>>;
|
|
177
|
+
|
|
178
|
+
constructor(source: ReturnType<typeof signal<number>>) {
|
|
179
|
+
super(null);
|
|
180
|
+
this._doubled = this[COMPUTED](() => read(source) * 2);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
get doubled() {
|
|
184
|
+
return read(this._doubled);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
let s = signal(5),
|
|
189
|
+
obj = new Doubled(s),
|
|
190
|
+
values: number[] = [];
|
|
191
|
+
|
|
192
|
+
expect(obj.doubled).toBe(10);
|
|
193
|
+
|
|
194
|
+
effect(() => {
|
|
195
|
+
values.push(obj.doubled);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
expect(values).toEqual([10]);
|
|
199
|
+
|
|
200
|
+
write(s, 10);
|
|
201
|
+
await Promise.resolve();
|
|
202
|
+
|
|
203
|
+
expect(obj.doubled).toBe(20);
|
|
204
|
+
expect(values).toEqual([10, 20]);
|
|
205
|
+
|
|
206
|
+
obj.dispose();
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('[REACTIVE_ARRAY] subclass creates ReactiveArray field', () => {
|
|
210
|
+
class Collection extends ReactiveObject<Record<string, never>> {
|
|
211
|
+
items: ReactiveArray<number>;
|
|
212
|
+
|
|
213
|
+
constructor(values: number[]) {
|
|
214
|
+
super(null);
|
|
215
|
+
this.items = this[REACTIVE_ARRAY](values);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let obj = new Collection([10, 20, 30]);
|
|
220
|
+
|
|
221
|
+
expect(obj.items).toBeInstanceOf(ReactiveArray);
|
|
222
|
+
expect([...obj.items]).toEqual([10, 20, 30]);
|
|
223
|
+
|
|
224
|
+
obj.dispose();
|
|
225
|
+
|
|
226
|
+
// After dispose, array should be cleared
|
|
227
|
+
expect(obj.items.length).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
|
|
163
232
|
describe('dispose', () => {
|
|
164
233
|
it('disposes object with computed properties', () => {
|
|
165
234
|
let s = signal(42),
|
|
@@ -181,5 +250,20 @@ describe('reactive object patterns', () => {
|
|
|
181
250
|
|
|
182
251
|
obj.dispose();
|
|
183
252
|
});
|
|
253
|
+
|
|
254
|
+
it('calling dispose twice does not throw', () => {
|
|
255
|
+
let s = signal(1),
|
|
256
|
+
obj = new ReactiveObject({
|
|
257
|
+
computed: () => read(s) * 2,
|
|
258
|
+
items: [1, 2, 3]
|
|
259
|
+
}) as any;
|
|
260
|
+
|
|
261
|
+
expect(obj.computed).toBe(2);
|
|
262
|
+
expect([...obj.items]).toEqual([1, 2, 3]);
|
|
263
|
+
|
|
264
|
+
obj.dispose();
|
|
265
|
+
|
|
266
|
+
expect(() => obj.dispose()).not.toThrow();
|
|
267
|
+
});
|
|
184
268
|
});
|
|
185
269
|
});
|
package/tests/system.ts
CHANGED
|
@@ -423,6 +423,27 @@ describe('onCleanup', () => {
|
|
|
423
423
|
expect(returned).toBe(fn);
|
|
424
424
|
expect(fn).not.toHaveBeenCalled();
|
|
425
425
|
});
|
|
426
|
+
|
|
427
|
+
it('throwing cleanup skips subsequent cleanups in same array', () => {
|
|
428
|
+
let called: number[] = [],
|
|
429
|
+
c = computed((onCleanup) => {
|
|
430
|
+
onCleanup(() => { called.push(1); });
|
|
431
|
+
onCleanup(() => { called.push(2); throw new Error('cleanup boom'); });
|
|
432
|
+
onCleanup(() => { called.push(3); });
|
|
433
|
+
return 42;
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
expect(called).toEqual([]);
|
|
437
|
+
|
|
438
|
+
// dispose() calls cleanup() synchronously — no try/catch wraps it
|
|
439
|
+
// cleanup iterates the array: index 0 (push 1), index 1 (push 2, throws)
|
|
440
|
+
// index 2 (push 3) is never reached because no try/catch in cleanup()
|
|
441
|
+
expect(() => dispose(c)).toThrow('cleanup boom');
|
|
442
|
+
|
|
443
|
+
expect(called).toContain(1);
|
|
444
|
+
expect(called).toContain(2);
|
|
445
|
+
expect(called).not.toContain(3);
|
|
446
|
+
});
|
|
426
447
|
});
|
|
427
448
|
|
|
428
449
|
|
|
@@ -966,6 +987,135 @@ describe('edge cases', () => {
|
|
|
966
987
|
expect(result).toBe(99);
|
|
967
988
|
});
|
|
968
989
|
|
|
990
|
+
it('write during stabilization triggers reschedule and computed sees updated value', async () => {
|
|
991
|
+
let s1 = signal(1),
|
|
992
|
+
s2 = signal(0),
|
|
993
|
+
c = computed(() => read(s2) * 10),
|
|
994
|
+
cValues: number[] = [];
|
|
995
|
+
|
|
996
|
+
// Effect 1: writes to s2 when s1 changes — triggers during stabilization
|
|
997
|
+
effect(() => {
|
|
998
|
+
let val = read(s1);
|
|
999
|
+
|
|
1000
|
+
if (val > 1) {
|
|
1001
|
+
write(s2, val);
|
|
1002
|
+
}
|
|
1003
|
+
});
|
|
1004
|
+
|
|
1005
|
+
// Effect 2: reads computed c which depends on s2
|
|
1006
|
+
effect(() => {
|
|
1007
|
+
cValues.push(read(c));
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
expect(cValues).toEqual([0]);
|
|
1011
|
+
|
|
1012
|
+
// Write s1 → effect1 runs during stabilization → writes s2=5
|
|
1013
|
+
// → reschedule → c recomputes with s2=5 → effect2 sees 50
|
|
1014
|
+
write(s1, 5);
|
|
1015
|
+
await Promise.resolve();
|
|
1016
|
+
await Promise.resolve();
|
|
1017
|
+
|
|
1018
|
+
expect(cValues).toEqual([0, 50]);
|
|
1019
|
+
});
|
|
1020
|
+
|
|
1021
|
+
it('diamond with write during stabilization propagates through both branches', async () => {
|
|
1022
|
+
let source = signal(1),
|
|
1023
|
+
trigger = signal(0),
|
|
1024
|
+
left = computed(() => read(source) + 1),
|
|
1025
|
+
right = computed(() => read(source) * 2),
|
|
1026
|
+
join = computed(() => read(left) + read(right)),
|
|
1027
|
+
joinValues: number[] = [];
|
|
1028
|
+
|
|
1029
|
+
// Effect that writes source during stabilization
|
|
1030
|
+
effect(() => {
|
|
1031
|
+
let val = read(trigger);
|
|
1032
|
+
|
|
1033
|
+
if (val > 0) {
|
|
1034
|
+
write(source, val);
|
|
1035
|
+
}
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// Effect that reads the diamond join
|
|
1039
|
+
effect(() => {
|
|
1040
|
+
joinValues.push(read(join));
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
// Initial: source=1, left=2, right=2, join=4
|
|
1044
|
+
expect(joinValues).toEqual([4]);
|
|
1045
|
+
|
|
1046
|
+
// trigger=10 → effect writes source=10 → reschedule
|
|
1047
|
+
// left=11, right=20, join=31
|
|
1048
|
+
write(trigger, 10);
|
|
1049
|
+
await Promise.resolve();
|
|
1050
|
+
await Promise.resolve();
|
|
1051
|
+
|
|
1052
|
+
expect(joinValues).toEqual([4, 31]);
|
|
1053
|
+
});
|
|
1054
|
+
|
|
1055
|
+
it('disposed computed does not prevent new computed from reading same signal', async () => {
|
|
1056
|
+
let s = signal(10),
|
|
1057
|
+
c1 = computed(() => read(s) * 2),
|
|
1058
|
+
c2Values: number[] = [];
|
|
1059
|
+
|
|
1060
|
+
expect(read(c1)).toBe(20);
|
|
1061
|
+
|
|
1062
|
+
dispose(c1);
|
|
1063
|
+
|
|
1064
|
+
// After dispose, c1 retains its last computed value
|
|
1065
|
+
expect(c1.value).toBe(20);
|
|
1066
|
+
|
|
1067
|
+
// New computed can read the same signal
|
|
1068
|
+
let c2 = computed(() => read(s) + 5);
|
|
1069
|
+
|
|
1070
|
+
expect(read(c2)).toBe(15);
|
|
1071
|
+
|
|
1072
|
+
// Subscribe with an effect so c2 reacts to changes
|
|
1073
|
+
effect(() => {
|
|
1074
|
+
c2Values.push(read(c2));
|
|
1075
|
+
});
|
|
1076
|
+
|
|
1077
|
+
expect(c2Values).toEqual([15]);
|
|
1078
|
+
|
|
1079
|
+
// Signal still propagates to new computed
|
|
1080
|
+
write(s, 20);
|
|
1081
|
+
await Promise.resolve();
|
|
1082
|
+
|
|
1083
|
+
expect(c2Values).toEqual([15, 25]);
|
|
1084
|
+
|
|
1085
|
+
// Disposed computed value is stale — not updated
|
|
1086
|
+
expect(c1.value).toBe(20);
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
it('disposed computed retains last value and does not recompute', async () => {
|
|
1090
|
+
let s = signal(1),
|
|
1091
|
+
calls = 0,
|
|
1092
|
+
c = computed(() => {
|
|
1093
|
+
calls++;
|
|
1094
|
+
return read(s) * 3;
|
|
1095
|
+
}),
|
|
1096
|
+
result = -1;
|
|
1097
|
+
|
|
1098
|
+
// Subscribe so it's in the heap
|
|
1099
|
+
effect(() => {
|
|
1100
|
+
result = read(c);
|
|
1101
|
+
});
|
|
1102
|
+
|
|
1103
|
+
expect(result).toBe(3);
|
|
1104
|
+
expect(calls).toBe(1);
|
|
1105
|
+
|
|
1106
|
+
dispose(c);
|
|
1107
|
+
|
|
1108
|
+
// After disposal, value is retained
|
|
1109
|
+
expect(c.value).toBe(3);
|
|
1110
|
+
|
|
1111
|
+
write(s, 10);
|
|
1112
|
+
await Promise.resolve();
|
|
1113
|
+
|
|
1114
|
+
// Not recomputed after dispose
|
|
1115
|
+
expect(calls).toBe(1);
|
|
1116
|
+
expect(c.value).toBe(3);
|
|
1117
|
+
});
|
|
1118
|
+
|
|
969
1119
|
it('link pool handles >1000 dependencies with disposal and reuse', async () => {
|
|
970
1120
|
let signals: ReturnType<typeof signal>[] = [],
|
|
971
1121
|
stops: (() => void)[] = [];
|