@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.
@@ -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/plugins/vite';
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/plugins/tsc" }
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 for effects |
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
- | `$length()` | Returns the reactive length (tracks reads) |
283
- | `$set(index, value)` | Sets an item at index reactively |
284
- | `clear()` | Removes all items and disposes nested reactive objects |
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`, `concat`) are supported and trigger corresponding events.
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
 
@@ -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
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "type": "module",
38
38
  "types": "build/index.d.ts",
39
- "version": "0.31.0",
39
+ "version": "0.31.1",
40
40
  "scripts": {
41
41
  "build": "tsc",
42
42
  "build:test": "pnpm build && vite build --config test/vite.config.ts",
@@ -309,6 +309,10 @@ class ReactiveArray<T> extends Array<T> {
309
309
  }
310
310
 
311
311
  unshift(...items: T[]) {
312
+ if (!items.length) {
313
+ return this.length;
314
+ }
315
+
312
316
  let length = super.unshift(...items);
313
317
 
314
318
  write(this._length, length);
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
 
@@ -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);
@@ -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
 
@@ -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)[] = [];