@esportsplus/reactivity 0.31.0 → 0.31.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/{readme.md → README.md} +14 -11
- package/build/reactive/array.js +5 -1
- package/package.json +6 -6
- package/src/reactive/array.ts +7 -1
- package/tests/array.ts +146 -2
- 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/tests/tsconfig.json +1 -2
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
|
@@ -28,8 +28,9 @@ class ReactiveArray extends Array {
|
|
|
28
28
|
if (prev === value) {
|
|
29
29
|
return;
|
|
30
30
|
}
|
|
31
|
+
let length = this.length;
|
|
31
32
|
this[i] = value;
|
|
32
|
-
if (i >=
|
|
33
|
+
if (i >= length) {
|
|
33
34
|
write(this._length, i + 1);
|
|
34
35
|
}
|
|
35
36
|
this.dispatch('set', { index: i, item: value });
|
|
@@ -191,6 +192,9 @@ class ReactiveArray extends Array {
|
|
|
191
192
|
return removed;
|
|
192
193
|
}
|
|
193
194
|
unshift(...items) {
|
|
195
|
+
if (!items.length) {
|
|
196
|
+
return this.length;
|
|
197
|
+
}
|
|
194
198
|
let length = super.unshift(...items);
|
|
195
199
|
write(this._length, length);
|
|
196
200
|
this.dispatch('unshift', { items });
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "ICJR",
|
|
3
3
|
"dependencies": {
|
|
4
|
-
"@esportsplus/utilities": "^0.27.
|
|
4
|
+
"@esportsplus/utilities": "^0.27.3"
|
|
5
5
|
},
|
|
6
6
|
"devDependencies": {
|
|
7
|
-
"@esportsplus/typescript": "^0.29.
|
|
8
|
-
"@types/node": "^25.0
|
|
9
|
-
"vite": "^
|
|
10
|
-
"vitest": "^4.
|
|
7
|
+
"@esportsplus/typescript": "^0.29.1",
|
|
8
|
+
"@types/node": "^25.6.0",
|
|
9
|
+
"vite": "^8.0.8",
|
|
10
|
+
"vitest": "^4.1.4"
|
|
11
11
|
},
|
|
12
12
|
"exports": {
|
|
13
13
|
".": {
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"type": "module",
|
|
38
38
|
"types": "build/index.d.ts",
|
|
39
|
-
"version": "0.31.
|
|
39
|
+
"version": "0.31.2",
|
|
40
40
|
"scripts": {
|
|
41
41
|
"build": "tsc",
|
|
42
42
|
"build:test": "pnpm build && vite build --config test/vite.config.ts",
|
package/src/reactive/array.ts
CHANGED
|
@@ -84,9 +84,11 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
84
84
|
return;
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
+
let length = this.length;
|
|
88
|
+
|
|
87
89
|
this[i] = value;
|
|
88
90
|
|
|
89
|
-
if (i >=
|
|
91
|
+
if (i >= length) {
|
|
90
92
|
write(this._length, i + 1);
|
|
91
93
|
}
|
|
92
94
|
|
|
@@ -309,6 +311,10 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
309
311
|
}
|
|
310
312
|
|
|
311
313
|
unshift(...items: T[]) {
|
|
314
|
+
if (!items.length) {
|
|
315
|
+
return this.length;
|
|
316
|
+
}
|
|
317
|
+
|
|
312
318
|
let length = super.unshift(...items);
|
|
313
319
|
|
|
314
320
|
write(this._length, length);
|
package/tests/array.ts
CHANGED
|
@@ -91,11 +91,64 @@ describe('ReactiveArray', () => {
|
|
|
91
91
|
arr.$set(5, 99);
|
|
92
92
|
await Promise.resolve();
|
|
93
93
|
|
|
94
|
-
// Native .length is 6, but reactive _length check runs after
|
|
95
|
-
// this[i] = value so i >= this.length is false — _length not updated
|
|
96
94
|
expect(arr.length).toBe(6);
|
|
97
95
|
expect(arr[5]).toBe(99);
|
|
96
|
+
expect(lengths).toEqual([3, 6]);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('$set creates sparse array and updates reactive $length', async () => {
|
|
100
|
+
let arr = new ReactiveArray<number>(),
|
|
101
|
+
lengths: number[] = [];
|
|
102
|
+
|
|
103
|
+
effect(() => {
|
|
104
|
+
lengths.push(arr.$length);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(lengths).toEqual([0]);
|
|
108
|
+
|
|
109
|
+
arr.$set(100, 42);
|
|
110
|
+
await Promise.resolve();
|
|
111
|
+
|
|
112
|
+
// Value is set at index 100
|
|
113
|
+
expect(arr[100]).toBe(42);
|
|
114
|
+
|
|
115
|
+
// Native length becomes 101 via Array behavior
|
|
116
|
+
expect(arr.length).toBe(101);
|
|
117
|
+
|
|
118
|
+
// Reactive $length updated correctly
|
|
119
|
+
expect(lengths).toEqual([0, 101]);
|
|
120
|
+
|
|
121
|
+
// Intermediate indices are empty (sparse)
|
|
122
|
+
expect(arr[0]).toBe(undefined);
|
|
123
|
+
expect(arr[50]).toBe(undefined);
|
|
124
|
+
expect(arr[99]).toBe(undefined);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('$set with negative index does not affect length', () => {
|
|
128
|
+
let arr = new ReactiveArray(1, 2, 3);
|
|
129
|
+
|
|
130
|
+
arr.$set(-1 as any, 42);
|
|
131
|
+
|
|
132
|
+
expect(arr.length).toBe(3);
|
|
133
|
+
expect(arr.$length).toBe(3);
|
|
134
|
+
expect((arr as any)[-1]).toBe(42);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('$set at large index updates $length', async () => {
|
|
138
|
+
let arr = new ReactiveArray(1, 2, 3),
|
|
139
|
+
lengths: number[] = [];
|
|
140
|
+
|
|
141
|
+
effect(() => {
|
|
142
|
+
lengths.push(arr.$length);
|
|
143
|
+
});
|
|
144
|
+
|
|
98
145
|
expect(lengths).toEqual([3]);
|
|
146
|
+
|
|
147
|
+
arr.$set(10000, 99);
|
|
148
|
+
await Promise.resolve();
|
|
149
|
+
|
|
150
|
+
expect(arr.length).toBe(10001);
|
|
151
|
+
expect(lengths).toEqual([3, 10001]);
|
|
99
152
|
});
|
|
100
153
|
});
|
|
101
154
|
|
|
@@ -321,6 +374,26 @@ describe('ReactiveArray', () => {
|
|
|
321
374
|
|
|
322
375
|
expect(events).toEqual([[1, 2]]);
|
|
323
376
|
});
|
|
377
|
+
|
|
378
|
+
it('no-op for empty unshift', async () => {
|
|
379
|
+
let arr = new ReactiveArray(1, 2),
|
|
380
|
+
dispatched = false,
|
|
381
|
+
lengths: number[] = [];
|
|
382
|
+
|
|
383
|
+
arr.on('unshift', () => { dispatched = true; });
|
|
384
|
+
|
|
385
|
+
effect(() => { lengths.push(arr.$length); });
|
|
386
|
+
|
|
387
|
+
expect(lengths).toEqual([2]);
|
|
388
|
+
|
|
389
|
+
let result = arr.unshift();
|
|
390
|
+
|
|
391
|
+
await Promise.resolve();
|
|
392
|
+
|
|
393
|
+
expect(result).toBe(2);
|
|
394
|
+
expect(dispatched).toBe(false);
|
|
395
|
+
expect(lengths).toEqual([2]);
|
|
396
|
+
});
|
|
324
397
|
});
|
|
325
398
|
|
|
326
399
|
|
|
@@ -369,6 +442,32 @@ describe('ReactiveArray', () => {
|
|
|
369
442
|
|
|
370
443
|
expect(dispatched).toBe(false);
|
|
371
444
|
});
|
|
445
|
+
|
|
446
|
+
it('splice with start beyond array length removes nothing', () => {
|
|
447
|
+
let arr = new ReactiveArray(1, 2, 3),
|
|
448
|
+
dispatched = false;
|
|
449
|
+
|
|
450
|
+
arr.on('splice', () => { dispatched = true; });
|
|
451
|
+
|
|
452
|
+
let removed = arr.splice(100, 1);
|
|
453
|
+
|
|
454
|
+
expect([...removed]).toEqual([]);
|
|
455
|
+
expect([...arr]).toEqual([1, 2, 3]);
|
|
456
|
+
expect(dispatched).toBe(false);
|
|
457
|
+
});
|
|
458
|
+
|
|
459
|
+
it('splice with negative start removes from end', () => {
|
|
460
|
+
let arr = new ReactiveArray(1, 2, 3, 4, 5),
|
|
461
|
+
events: { start: number; deleteCount: number; items: number[] }[] = [];
|
|
462
|
+
|
|
463
|
+
arr.on('splice', (e) => { events.push(e); });
|
|
464
|
+
|
|
465
|
+
let removed = arr.splice(-2, 1);
|
|
466
|
+
|
|
467
|
+
expect([...removed]).toEqual([4]);
|
|
468
|
+
expect([...arr]).toEqual([1, 2, 3, 5]);
|
|
469
|
+
expect(events).toEqual([{ start: -2, deleteCount: 1, items: [] }]);
|
|
470
|
+
});
|
|
372
471
|
});
|
|
373
472
|
|
|
374
473
|
|
|
@@ -493,6 +592,20 @@ describe('ReactiveArray', () => {
|
|
|
493
592
|
|
|
494
593
|
expect([...arr]).toEqual([1, 2, 2]);
|
|
495
594
|
});
|
|
595
|
+
|
|
596
|
+
it('sort preserves object references', () => {
|
|
597
|
+
let a = { id: 3 },
|
|
598
|
+
b = { id: 1 },
|
|
599
|
+
c = { id: 2 },
|
|
600
|
+
arr = new ReactiveArray(a, b, c);
|
|
601
|
+
|
|
602
|
+
arr.sort((x, y) => x.id - y.id);
|
|
603
|
+
|
|
604
|
+
// Sorted order: b(1), c(2), a(3) — same object references
|
|
605
|
+
expect(arr[0]).toBe(b);
|
|
606
|
+
expect(arr[1]).toBe(c);
|
|
607
|
+
expect(arr[2]).toBe(a);
|
|
608
|
+
});
|
|
496
609
|
});
|
|
497
610
|
|
|
498
611
|
|
|
@@ -654,6 +767,37 @@ describe('ReactiveArray', () => {
|
|
|
654
767
|
expect(arr.listeners['push']!.length).toBe(1);
|
|
655
768
|
expect(fn1).toHaveBeenCalledTimes(1);
|
|
656
769
|
});
|
|
770
|
+
|
|
771
|
+
it('on() inserts after trailing nulls cleaned from dispatch', () => {
|
|
772
|
+
let arr = new ReactiveArray<number>(),
|
|
773
|
+
fn1 = vi.fn(),
|
|
774
|
+
fn2 = () => { throw new Error('err2'); },
|
|
775
|
+
fn3 = () => { throw new Error('err3'); };
|
|
776
|
+
|
|
777
|
+
arr.on('push', fn1);
|
|
778
|
+
arr.on('push', fn2);
|
|
779
|
+
arr.on('push', fn3);
|
|
780
|
+
|
|
781
|
+
// Dispatch: fn2 and fn3 throw → nulled → trailing nulls cleaned
|
|
782
|
+
// listeners = [fn1, null, null] → cleanup → [fn1]
|
|
783
|
+
arr.push(1);
|
|
784
|
+
|
|
785
|
+
expect(fn1).toHaveBeenCalledTimes(1);
|
|
786
|
+
expect(arr.listeners['push']!.length).toBe(1);
|
|
787
|
+
|
|
788
|
+
// Register fn4 — should append at index 1 (no holes left)
|
|
789
|
+
let fn4 = vi.fn();
|
|
790
|
+
|
|
791
|
+
arr.on('push', fn4);
|
|
792
|
+
|
|
793
|
+
expect(arr.listeners['push']!.length).toBe(2);
|
|
794
|
+
|
|
795
|
+
// Both fn1 and fn4 called on next push
|
|
796
|
+
arr.push(2);
|
|
797
|
+
|
|
798
|
+
expect(fn1).toHaveBeenCalledTimes(2);
|
|
799
|
+
expect(fn4).toHaveBeenCalledTimes(1);
|
|
800
|
+
});
|
|
657
801
|
});
|
|
658
802
|
|
|
659
803
|
|
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)[] = [];
|