@esportsplus/reactivity 0.32.0 → 0.33.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/README.md +30 -0
- package/build/reactive/array.d.ts +3 -2
- package/build/reactive/array.js +36 -28
- package/build/reactive/object.d.ts +1 -1
- package/build/system.d.ts +3 -3
- package/build/system.js +26 -9
- package/build/types.d.ts +7 -1
- package/package.json +6 -6
- package/src/reactive/array.ts +51 -39
- package/src/system.ts +35 -12
- package/src/types.ts +9 -0
- package/test/async-computed.test.ts +87 -1
- package/test/effects.test.ts +62 -0
- package/test/errors.test.ts +6 -6
- package/test/system.test.ts +10 -10
- package/.claude/CHANGELOG.md +0 -43
package/README.md
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# @esportsplus/reactivity
|
|
2
2
|
|
|
3
|
+
Project doc bundle: [docs/index.md](./docs/index.md) — rejected dead-ends, skip reasons, completed-work index.
|
|
4
|
+
|
|
3
5
|
A fine-grained reactivity system with compile-time transformations. Write reactive code with natural JavaScript syntax while the compiler generates optimized signal-based code.
|
|
4
6
|
|
|
5
7
|
## Installation
|
|
@@ -313,6 +315,34 @@ All standard array methods (`push`, `pop`, `shift`, `unshift`, `splice`, `sort`,
|
|
|
313
315
|
| `splice` | `{ start, deleteCount, items }` | Array was spliced |
|
|
314
316
|
| `unshift` | `{ items: T[] }` | Items were unshifted |
|
|
315
317
|
|
|
318
|
+
## Architecture
|
|
319
|
+
|
|
320
|
+
- `src/system.ts` — the core signal/computed/effect engine: height-bucketed heap scheduler,
|
|
321
|
+
generation-version dependency dedup (read-version dedup), and iterative (non-recursive)
|
|
322
|
+
notify/update/dispose walks. Also supports async (Promise/AsyncIterable) computeds and
|
|
323
|
+
per-key selector signals (`signal.selector`).
|
|
324
|
+
- `src/constants.ts` — shared state bitmasks and symbol constants consumed by every other module.
|
|
325
|
+
- `src/types.ts` — shared `Signal`/`Computed`/`Link`/`Reactive` types.
|
|
326
|
+
- `src/reactive/index.ts`, `src/reactive/array.ts`, `src/reactive/object.ts` — the runtime
|
|
327
|
+
`reactive()` dispatch and the `ReactiveObject`/`ReactiveArray` classes it builds on.
|
|
328
|
+
- `src/compiler/*` — the build-time transform pipeline (`constants.ts`, `primitives.ts`,
|
|
329
|
+
`object.ts`, `array.ts`, `index.ts`) plus the `plugins/tsc.ts` and `plugins/vite.ts`
|
|
330
|
+
entrypoints that wire it into a build (see Transformer Plugins above).
|
|
331
|
+
|
|
332
|
+
## Development
|
|
333
|
+
|
|
334
|
+
```bash
|
|
335
|
+
pnpm install
|
|
336
|
+
pnpm build # tsc
|
|
337
|
+
pnpm test # vitest run
|
|
338
|
+
pnpm bench # vitest bench --run
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
`agent:test` (`tsc --noEmit && vitest run`) and `agent:bench` (`vitest bench --run`) are the
|
|
342
|
+
CI-facing aliases of the same build/test/bench commands.
|
|
343
|
+
|
|
316
344
|
## License
|
|
317
345
|
|
|
318
346
|
MIT
|
|
347
|
+
|
|
348
|
+
<!-- claude-code:readme-source-hash: 189167a17e9be8c6 -->
|
|
@@ -41,6 +41,9 @@ declare class ReactiveArray<T> extends Array<T> {
|
|
|
41
41
|
private _length;
|
|
42
42
|
listeners: Listeners<T>;
|
|
43
43
|
constructor(...items: T[]);
|
|
44
|
+
static get [Symbol.species](): ArrayConstructor;
|
|
45
|
+
get $length(): number;
|
|
46
|
+
set $length(value: number);
|
|
44
47
|
$set(i: number, value: T): void;
|
|
45
48
|
clear(): void;
|
|
46
49
|
concat(...items: ConcatArray<T>[]): ReactiveArray<T>;
|
|
@@ -56,7 +59,5 @@ declare class ReactiveArray<T> extends Array<T> {
|
|
|
56
59
|
sort(fn?: (a: T, b: T) => number): this;
|
|
57
60
|
splice(start: number, deleteCount?: number, ...items: T[]): T[];
|
|
58
61
|
unshift(...items: T[]): number;
|
|
59
|
-
get $length(): number;
|
|
60
|
-
set $length(value: number);
|
|
61
62
|
}
|
|
62
63
|
export { ReactiveArray };
|
package/build/reactive/array.js
CHANGED
|
@@ -14,6 +14,18 @@ class ReactiveArray extends Array {
|
|
|
14
14
|
super(...items);
|
|
15
15
|
this._length = signal(items.length);
|
|
16
16
|
}
|
|
17
|
+
static get [Symbol.species]() {
|
|
18
|
+
return Array;
|
|
19
|
+
}
|
|
20
|
+
get $length() {
|
|
21
|
+
return read(this._length);
|
|
22
|
+
}
|
|
23
|
+
set $length(value) {
|
|
24
|
+
if (value > this.length) {
|
|
25
|
+
throw Error(`@esportsplus/reactivity: cannot set length to a value larger than the current length, use splice instead.`);
|
|
26
|
+
}
|
|
27
|
+
this.splice(value, this.length);
|
|
28
|
+
}
|
|
17
29
|
$set(i, value) {
|
|
18
30
|
let prev = this[i];
|
|
19
31
|
if (prev === value) {
|
|
@@ -146,29 +158,34 @@ class ReactiveArray extends Array {
|
|
|
146
158
|
return item;
|
|
147
159
|
}
|
|
148
160
|
sort(fn) {
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
let value = before[i], list = buckets.get(value);
|
|
157
|
-
if (!list) {
|
|
158
|
-
buckets.set(value, [i]);
|
|
161
|
+
if (this.listeners.sort === undefined) {
|
|
162
|
+
super.sort(fn);
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
let n = this.length, before = new Array(n);
|
|
166
|
+
for (let i = 0; i < n; i++) {
|
|
167
|
+
before[i] = this[i];
|
|
159
168
|
}
|
|
160
|
-
|
|
161
|
-
|
|
169
|
+
super.sort(fn);
|
|
170
|
+
let buckets = new Map(), order = new Array(n);
|
|
171
|
+
for (let i = 0; i < n; i++) {
|
|
172
|
+
let value = before[i], list = buckets.get(value);
|
|
173
|
+
if (!list) {
|
|
174
|
+
buckets.set(value, [i]);
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
list.push(i);
|
|
178
|
+
}
|
|
162
179
|
}
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
180
|
+
for (let i = 0; i < n; i++) {
|
|
181
|
+
let list = buckets.get(this[i]);
|
|
182
|
+
order[i] = list.length === 1 ? list[0] : list[list.length - 1];
|
|
183
|
+
if (list.length > 1) {
|
|
184
|
+
list.pop();
|
|
185
|
+
}
|
|
169
186
|
}
|
|
187
|
+
this.dispatch('sort', { order });
|
|
170
188
|
}
|
|
171
|
-
this.dispatch('sort', { order });
|
|
172
189
|
return this;
|
|
173
190
|
}
|
|
174
191
|
splice(start, deleteCount = this.length, ...items) {
|
|
@@ -191,15 +208,6 @@ class ReactiveArray extends Array {
|
|
|
191
208
|
this.dispatch('unshift', { items });
|
|
192
209
|
return length;
|
|
193
210
|
}
|
|
194
|
-
get $length() {
|
|
195
|
-
return read(this._length);
|
|
196
|
-
}
|
|
197
|
-
set $length(value) {
|
|
198
|
-
if (value > this.length) {
|
|
199
|
-
throw Error(`@esportsplus/reactivity: cannot set length to a value larger than the current length, use splice instead.`);
|
|
200
|
-
}
|
|
201
|
-
this.splice(value, this.length);
|
|
202
|
-
}
|
|
203
211
|
}
|
|
204
212
|
Object.defineProperty(ReactiveArray.prototype, REACTIVE_ARRAY, { value: true });
|
|
205
213
|
export { ReactiveArray };
|
|
@@ -4,7 +4,7 @@ import { ReactiveArray } from './array.js';
|
|
|
4
4
|
declare class ReactiveObject<T extends Record<PropertyKey, unknown>> {
|
|
5
5
|
protected disposers: VoidFunction[] | null;
|
|
6
6
|
constructor(data: T | null);
|
|
7
|
-
protected [COMPUTED]<T extends Computed<ReturnType<T>>['fn']>(value: T):
|
|
7
|
+
protected [COMPUTED]<T extends Computed<ReturnType<T>>['fn']>(value: T): import("../types.js").ComputedResult<ReturnType<T>>;
|
|
8
8
|
protected [REACTIVE_ARRAY]<U>(value: U[]): ReactiveArray<U>;
|
|
9
9
|
protected [SIGNAL]<T>(value: T): import("../types.js").Signal<T>;
|
|
10
10
|
dispose(): void;
|
package/build/system.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import { Computed, Settled, Signal } from './types.js';
|
|
1
|
+
import { Computed, ComputedResult, Settled, Signal } from './types.js';
|
|
2
2
|
declare const batch: <T>(fn: () => T) => T;
|
|
3
3
|
declare const computed: {
|
|
4
|
-
<T>(fn: Computed<T>["fn"], equals?: ((a: Settled<T>, b: Settled<T>) => boolean) | null):
|
|
4
|
+
<T>(fn: Computed<T>["fn"], equals?: ((a: Settled<T>, b: Settled<T>) => boolean) | null): ComputedResult<T>;
|
|
5
5
|
invalidate<T>(c: Computed<T>): void;
|
|
6
6
|
};
|
|
7
7
|
declare const dispose: <T>(computed: Computed<T>) => void;
|
|
8
|
-
declare const effect: <T>(fn: Computed<T>["fn"],
|
|
8
|
+
declare const effect: <T>(fn: Computed<T>["fn"], apply?: (value: T, prev: T | undefined) => void) => () => void;
|
|
9
9
|
declare const flush: () => void;
|
|
10
10
|
declare const isComputed: (value: unknown) => value is Computed<unknown>;
|
|
11
11
|
declare const isSignal: (value: unknown) => value is Signal<unknown>;
|
package/build/system.js
CHANGED
|
@@ -412,18 +412,23 @@ function update(root) {
|
|
|
412
412
|
}
|
|
413
413
|
}
|
|
414
414
|
function makeAsyncComputed(factory) {
|
|
415
|
-
let error = signal(undefined), node = signal(undefined), v = 0;
|
|
415
|
+
let error = signal(undefined), node = signal(undefined), pending = signal(false), v = 0;
|
|
416
416
|
let stop = effect(() => {
|
|
417
417
|
let fail = (e) => {
|
|
418
418
|
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
419
419
|
write(error, e === undefined ? new Error('reactivity: async computed rejected with undefined') : e);
|
|
420
|
+
write(pending, false);
|
|
420
421
|
}
|
|
421
422
|
}, id = ++v, result = read(factory);
|
|
422
423
|
if (isPromise(result)) {
|
|
424
|
+
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
425
|
+
write(pending, true);
|
|
426
|
+
}
|
|
423
427
|
result.then((value) => {
|
|
424
428
|
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
425
429
|
write(error, undefined);
|
|
426
430
|
write(node, value);
|
|
431
|
+
write(pending, false);
|
|
427
432
|
}
|
|
428
433
|
}, fail);
|
|
429
434
|
}
|
|
@@ -439,14 +444,22 @@ function makeAsyncComputed(factory) {
|
|
|
439
444
|
if (!r.done) {
|
|
440
445
|
write(error, undefined);
|
|
441
446
|
write(node, r.value);
|
|
447
|
+
write(pending, false);
|
|
442
448
|
it.next().then(step, fail);
|
|
443
449
|
}
|
|
450
|
+
else {
|
|
451
|
+
write(pending, false);
|
|
452
|
+
}
|
|
444
453
|
};
|
|
454
|
+
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
455
|
+
write(pending, true);
|
|
456
|
+
}
|
|
445
457
|
untrack(() => it.next()).then(step, fail);
|
|
446
458
|
}
|
|
447
459
|
else {
|
|
448
460
|
write(error, undefined);
|
|
449
461
|
write(node, result);
|
|
462
|
+
write(pending, false);
|
|
450
463
|
}
|
|
451
464
|
});
|
|
452
465
|
let wrapper = makeComputed(() => {
|
|
@@ -456,6 +469,7 @@ function makeAsyncComputed(factory) {
|
|
|
456
469
|
}
|
|
457
470
|
return read(node);
|
|
458
471
|
});
|
|
472
|
+
wrapper.pending = pending;
|
|
459
473
|
asyncMeta.set(wrapper, { factory: factory });
|
|
460
474
|
wrapper.disposal = stop;
|
|
461
475
|
return wrapper;
|
|
@@ -574,15 +588,14 @@ const dispose = (computed) => {
|
|
|
574
588
|
draining = false;
|
|
575
589
|
}
|
|
576
590
|
};
|
|
577
|
-
const effect = (fn,
|
|
578
|
-
let
|
|
591
|
+
const effect = (fn, apply) => {
|
|
592
|
+
let prev;
|
|
593
|
+
let c = makeComputed(apply
|
|
579
594
|
? (o) => {
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
onError(e);
|
|
585
|
-
}
|
|
595
|
+
let v = fn(o);
|
|
596
|
+
untrack(() => apply(v, prev));
|
|
597
|
+
prev = v;
|
|
598
|
+
return v;
|
|
586
599
|
}
|
|
587
600
|
: fn);
|
|
588
601
|
c.state |= STATE_EFFECT;
|
|
@@ -681,9 +694,12 @@ root.disposables = 0;
|
|
|
681
694
|
const signal = (value, equals = null) => {
|
|
682
695
|
return {
|
|
683
696
|
equals: equals,
|
|
697
|
+
key: undefined,
|
|
684
698
|
keys: null,
|
|
685
699
|
nextPending: null,
|
|
700
|
+
parent: undefined,
|
|
686
701
|
rv: 0,
|
|
702
|
+
state: 0,
|
|
687
703
|
subs: null,
|
|
688
704
|
subsTail: null,
|
|
689
705
|
type: SIGNAL,
|
|
@@ -703,6 +719,7 @@ signal.selector = (node, key) => {
|
|
|
703
719
|
nextPending: null,
|
|
704
720
|
parent: node,
|
|
705
721
|
rv: 0,
|
|
722
|
+
state: 0,
|
|
706
723
|
subs: null,
|
|
707
724
|
subsTail: null,
|
|
708
725
|
type: SIGNAL,
|
package/build/types.d.ts
CHANGED
|
@@ -19,6 +19,9 @@ interface Computed<T> {
|
|
|
19
19
|
subsTail: Link | null;
|
|
20
20
|
value: T;
|
|
21
21
|
}
|
|
22
|
+
type ComputedResult<T> = T extends Promise<any> | AsyncIterable<any> ? Computed<Settled<T>> & {
|
|
23
|
+
pending: Signal<boolean>;
|
|
24
|
+
} : Computed<Settled<T>>;
|
|
22
25
|
interface Link {
|
|
23
26
|
dep: Signal<unknown> | Computed<unknown>;
|
|
24
27
|
nextDep: Link | null;
|
|
@@ -42,9 +45,12 @@ type SelectorSignal<T> = Signal<boolean> & {
|
|
|
42
45
|
type Settled<T> = T extends Promise<infer U> ? Awaited<U> | undefined : T extends AsyncIterable<infer U> ? U | undefined : T;
|
|
43
46
|
type Signal<T> = {
|
|
44
47
|
equals: ((a: unknown, b: unknown) => boolean) | null;
|
|
48
|
+
key: unknown;
|
|
45
49
|
keys: Map<T, SelectorSignal<T>> | null;
|
|
46
50
|
nextPending: Signal<unknown> | null;
|
|
51
|
+
parent: Signal<unknown> | undefined;
|
|
47
52
|
rv: number;
|
|
53
|
+
state: number;
|
|
48
54
|
subs: Link | null;
|
|
49
55
|
subsTail: Link | null;
|
|
50
56
|
type: typeof SIGNAL;
|
|
@@ -55,4 +61,4 @@ interface TransformResult {
|
|
|
55
61
|
code: string;
|
|
56
62
|
sourceFile: ts.SourceFile;
|
|
57
63
|
}
|
|
58
|
-
export type { Computed, Link, Reactive, SelectorSignal, Settled, Signal, TransformResult };
|
|
64
|
+
export type { Computed, ComputedResult, Link, Reactive, SelectorSignal, Settled, Signal, TransformResult };
|
package/package.json
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"author": "ICJR",
|
|
3
3
|
"dependencies": {
|
|
4
|
-
"@esportsplus/utilities": "^0.
|
|
4
|
+
"@esportsplus/utilities": "^0.28.0"
|
|
5
5
|
},
|
|
6
6
|
"devDependencies": {
|
|
7
|
-
"@esportsplus/typescript": "^0.29.
|
|
8
|
-
"@types/node": "^
|
|
9
|
-
"vite": "^8.
|
|
10
|
-
"vitest": "^4.1.
|
|
7
|
+
"@esportsplus/typescript": "^0.29.5",
|
|
8
|
+
"@types/node": "^26.1.1",
|
|
9
|
+
"vite": "^8.1.5",
|
|
10
|
+
"vitest": "^4.1.10"
|
|
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.
|
|
39
|
+
"version": "0.33.0",
|
|
40
40
|
"scripts": {
|
|
41
41
|
"agent:bench": "vitest bench --run",
|
|
42
42
|
"agent:test": "tsc --noEmit && vitest run",
|
package/src/reactive/array.ts
CHANGED
|
@@ -54,7 +54,10 @@ function dispose(value: unknown) {
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
|
|
57
|
+
// Derived arrays (splice removals, map/filter/slice) must be plain Arrays: species-creating a
|
|
58
|
+
// ReactiveArray runs the reactive constructor per call and seeds _length from the length argument.
|
|
57
59
|
class ReactiveArray<T> extends Array<T> {
|
|
60
|
+
|
|
58
61
|
private _length: Signal<number>;
|
|
59
62
|
|
|
60
63
|
listeners: Listeners<T> = {};
|
|
@@ -66,6 +69,24 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
66
69
|
}
|
|
67
70
|
|
|
68
71
|
|
|
72
|
+
static get [Symbol.species]() {
|
|
73
|
+
return Array;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
get $length() {
|
|
78
|
+
return read(this._length);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
set $length(value: number) {
|
|
82
|
+
if (value > this.length) {
|
|
83
|
+
throw Error(`@esportsplus/reactivity: cannot set length to a value larger than the current length, use splice instead.`);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
this.splice(value, this.length);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
|
|
69
90
|
$set(i: number, value: T) {
|
|
70
91
|
let prev = this[i];
|
|
71
92
|
|
|
@@ -204,7 +225,6 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
204
225
|
if (item !== undefined) {
|
|
205
226
|
dispose(item);
|
|
206
227
|
write(this._length, this.length);
|
|
207
|
-
|
|
208
228
|
this.dispatch('pop', { item });
|
|
209
229
|
}
|
|
210
230
|
|
|
@@ -219,6 +239,7 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
219
239
|
let length = super.push(...items);
|
|
220
240
|
|
|
221
241
|
write(this._length, length);
|
|
242
|
+
|
|
222
243
|
this.dispatch('push', { items });
|
|
223
244
|
|
|
224
245
|
return length;
|
|
@@ -237,7 +258,6 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
237
258
|
if (item !== undefined) {
|
|
238
259
|
dispose(item);
|
|
239
260
|
write(this._length, this.length);
|
|
240
|
-
|
|
241
261
|
this.dispatch('shift', { item });
|
|
242
262
|
}
|
|
243
263
|
|
|
@@ -245,41 +265,46 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
245
265
|
}
|
|
246
266
|
|
|
247
267
|
sort(fn?: (a: T, b: T) => number) {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
for (let i = 0; i < n; i++) {
|
|
252
|
-
before[i] = this[i];
|
|
268
|
+
if (this.listeners.sort === undefined) {
|
|
269
|
+
super.sort(fn);
|
|
253
270
|
}
|
|
271
|
+
else {
|
|
272
|
+
let n = this.length,
|
|
273
|
+
before = new Array(n) as T[];
|
|
254
274
|
|
|
255
|
-
|
|
275
|
+
for (let i = 0; i < n; i++) {
|
|
276
|
+
before[i] = this[i];
|
|
277
|
+
}
|
|
256
278
|
|
|
257
|
-
|
|
258
|
-
order = new Array(n);
|
|
279
|
+
super.sort(fn);
|
|
259
280
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
list = buckets.get(value);
|
|
281
|
+
let buckets = new Map<T, number[]>(),
|
|
282
|
+
order = new Array(n);
|
|
263
283
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
list
|
|
284
|
+
for (let i = 0; i < n; i++) {
|
|
285
|
+
let value = before[i],
|
|
286
|
+
list = buckets.get(value);
|
|
287
|
+
|
|
288
|
+
if (!list) {
|
|
289
|
+
buckets.set(value, [i]);
|
|
290
|
+
}
|
|
291
|
+
else {
|
|
292
|
+
list.push(i);
|
|
293
|
+
}
|
|
269
294
|
}
|
|
270
|
-
}
|
|
271
295
|
|
|
272
|
-
|
|
273
|
-
|
|
296
|
+
for (let i = 0; i < n; i++) {
|
|
297
|
+
let list = buckets.get(this[i])!;
|
|
274
298
|
|
|
275
|
-
|
|
299
|
+
order[i] = list.length === 1 ? list[0] : list[list.length - 1];
|
|
276
300
|
|
|
277
|
-
|
|
278
|
-
|
|
301
|
+
if (list.length > 1) {
|
|
302
|
+
list.pop();
|
|
303
|
+
}
|
|
279
304
|
}
|
|
280
|
-
}
|
|
281
305
|
|
|
282
|
-
|
|
306
|
+
this.dispatch('sort', { order });
|
|
307
|
+
}
|
|
283
308
|
|
|
284
309
|
return this;
|
|
285
310
|
}
|
|
@@ -312,19 +337,6 @@ class ReactiveArray<T> extends Array<T> {
|
|
|
312
337
|
|
|
313
338
|
return length;
|
|
314
339
|
}
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
get $length() {
|
|
318
|
-
return read(this._length);
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
set $length(value: number) {
|
|
322
|
-
if (value > this.length) {
|
|
323
|
-
throw Error(`@esportsplus/reactivity: cannot set length to a value larger than the current length, use splice instead.`);
|
|
324
|
-
}
|
|
325
|
-
|
|
326
|
-
this.splice(value, this.length);
|
|
327
|
-
}
|
|
328
340
|
}
|
|
329
341
|
|
|
330
342
|
Object.defineProperty(ReactiveArray.prototype, REACTIVE_ARRAY, { value: true });
|
package/src/system.ts
CHANGED
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
|
|
5
5
|
STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
|
|
6
6
|
} from './constants';
|
|
7
|
-
import { Computed, Link, SelectorSignal, Settled, Signal } from './types';
|
|
7
|
+
import { Computed, ComputedResult, Link, SelectorSignal, Settled, Signal } from './types';
|
|
8
8
|
import { isObject, isPromise } from '@esportsplus/utilities';
|
|
9
9
|
|
|
10
10
|
|
|
@@ -613,12 +613,14 @@ function update<T>(root: Computed<T>): void {
|
|
|
613
613
|
function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> | T>): Computed<T | undefined> {
|
|
614
614
|
let error = signal<unknown>(undefined),
|
|
615
615
|
node = signal<T | undefined>(undefined),
|
|
616
|
+
pending = signal(false),
|
|
616
617
|
v = 0;
|
|
617
618
|
|
|
618
619
|
let stop = effect(() => {
|
|
619
620
|
let fail = (e: unknown) => {
|
|
620
621
|
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
621
622
|
write(error, e === undefined ? new Error('reactivity: async computed rejected with undefined') : e);
|
|
623
|
+
write(pending, false);
|
|
622
624
|
}
|
|
623
625
|
},
|
|
624
626
|
id = ++v,
|
|
@@ -626,11 +628,16 @@ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> |
|
|
|
626
628
|
result = read(factory);
|
|
627
629
|
|
|
628
630
|
if (isPromise(result)) {
|
|
631
|
+
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
632
|
+
write(pending, true);
|
|
633
|
+
}
|
|
634
|
+
|
|
629
635
|
(result as Promise<T>).then(
|
|
630
636
|
(value) => {
|
|
631
637
|
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
632
638
|
write(error, undefined);
|
|
633
639
|
write(node, value);
|
|
640
|
+
write(pending, false);
|
|
634
641
|
}
|
|
635
642
|
},
|
|
636
643
|
fail
|
|
@@ -651,15 +658,24 @@ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> |
|
|
|
651
658
|
if (!r.done) {
|
|
652
659
|
write(error, undefined);
|
|
653
660
|
write(node, r.value);
|
|
661
|
+
write(pending, false);
|
|
654
662
|
it.next().then(step, fail);
|
|
655
663
|
}
|
|
664
|
+
else {
|
|
665
|
+
write(pending, false);
|
|
666
|
+
}
|
|
656
667
|
};
|
|
657
668
|
|
|
669
|
+
if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
|
|
670
|
+
write(pending, true);
|
|
671
|
+
}
|
|
672
|
+
|
|
658
673
|
untrack(() => it.next()).then(step, fail);
|
|
659
674
|
}
|
|
660
675
|
else {
|
|
661
676
|
write(error, undefined);
|
|
662
677
|
write(node, result as T);
|
|
678
|
+
write(pending, false);
|
|
663
679
|
}
|
|
664
680
|
});
|
|
665
681
|
|
|
@@ -673,6 +689,8 @@ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> |
|
|
|
673
689
|
return read(node);
|
|
674
690
|
});
|
|
675
691
|
|
|
692
|
+
(wrapper as Computed<T | undefined> & { pending: Signal<boolean> }).pending = pending;
|
|
693
|
+
|
|
676
694
|
asyncMeta.set(wrapper as Computed<unknown>, { factory: factory as Computed<unknown> });
|
|
677
695
|
wrapper.disposal = stop;
|
|
678
696
|
|
|
@@ -753,7 +771,7 @@ const batch = <T>(fn: () => T): T => {
|
|
|
753
771
|
|
|
754
772
|
// A fn returning a Promise or AsyncIterable transparently becomes an async computed: the first run is
|
|
755
773
|
// the probe, reused as the factory (no duplicate dispatch). A plain fn returns the node directly.
|
|
756
|
-
const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<T>) => boolean) | null = null):
|
|
774
|
+
const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<T>) => boolean) | null = null): ComputedResult<T> => {
|
|
757
775
|
// eager probe so self.value carries fn's return even when this is a non-first tracked op — the
|
|
758
776
|
// detection below cannot depend on the deferred branch, which never runs fn synchronously.
|
|
759
777
|
let o = observer,
|
|
@@ -774,7 +792,7 @@ const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<
|
|
|
774
792
|
|
|
775
793
|
self.equals = equals as ((a: unknown, b: unknown) => boolean) | null;
|
|
776
794
|
|
|
777
|
-
return self as
|
|
795
|
+
return self as unknown as ComputedResult<T>;
|
|
778
796
|
};
|
|
779
797
|
|
|
780
798
|
// Forces a re-derivation without the dummy-signal-dependency hack. writes++ FIRST so a gv-stamped
|
|
@@ -803,7 +821,6 @@ const dispose = <T>(computed: Computed<T>): void => {
|
|
|
803
821
|
// processed inline (no worklist node) and the pool is touched only for re-entrant deep cascades.
|
|
804
822
|
if (draining) {
|
|
805
823
|
disposeHead = walkPush(computed as Computed<unknown>, null, disposeHead);
|
|
806
|
-
|
|
807
824
|
return;
|
|
808
825
|
}
|
|
809
826
|
|
|
@@ -847,16 +864,18 @@ const dispose = <T>(computed: Computed<T>): void => {
|
|
|
847
864
|
}
|
|
848
865
|
};
|
|
849
866
|
|
|
850
|
-
const effect = <T>(fn: Computed<T>['fn'],
|
|
867
|
+
const effect = <T>(fn: Computed<T>['fn'], apply?: (value: T, prev: T | undefined) => void) => {
|
|
868
|
+
let prev: T | undefined;
|
|
869
|
+
|
|
851
870
|
let c = makeComputed<T | undefined>(
|
|
852
|
-
|
|
871
|
+
apply
|
|
853
872
|
? (o) => {
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
873
|
+
let v = fn(o);
|
|
874
|
+
|
|
875
|
+
untrack(() => apply(v, prev));
|
|
876
|
+
prev = v;
|
|
877
|
+
|
|
878
|
+
return v;
|
|
860
879
|
}
|
|
861
880
|
: fn
|
|
862
881
|
);
|
|
@@ -1001,9 +1020,12 @@ root.disposables = 0;
|
|
|
1001
1020
|
const signal = <T>(value: T, equals: ((a: T, b: T) => boolean) | null = null): Signal<T> => {
|
|
1002
1021
|
return {
|
|
1003
1022
|
equals: equals as ((a: unknown, b: unknown) => boolean) | null,
|
|
1023
|
+
key: undefined,
|
|
1004
1024
|
keys: null,
|
|
1005
1025
|
nextPending: null,
|
|
1026
|
+
parent: undefined,
|
|
1006
1027
|
rv: 0,
|
|
1028
|
+
state: 0,
|
|
1007
1029
|
subs: null,
|
|
1008
1030
|
subsTail: null,
|
|
1009
1031
|
type: SIGNAL,
|
|
@@ -1029,6 +1051,7 @@ signal.selector = <T>(node: Signal<T>, key: T): boolean => {
|
|
|
1029
1051
|
nextPending: null,
|
|
1030
1052
|
parent: node,
|
|
1031
1053
|
rv: 0,
|
|
1054
|
+
state: 0,
|
|
1032
1055
|
subs: null,
|
|
1033
1056
|
subsTail: null,
|
|
1034
1057
|
type: SIGNAL,
|
package/src/types.ts
CHANGED
|
@@ -22,6 +22,11 @@ interface Computed<T> {
|
|
|
22
22
|
value: T;
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
+
type ComputedResult<T> =
|
|
26
|
+
T extends Promise<any> | AsyncIterable<any>
|
|
27
|
+
? Computed<Settled<T>> & { pending: Signal<boolean> }
|
|
28
|
+
: Computed<Settled<T>>;
|
|
29
|
+
|
|
25
30
|
interface Link {
|
|
26
31
|
dep: Signal<unknown> | Computed<unknown>;
|
|
27
32
|
nextDep: Link | null;
|
|
@@ -56,9 +61,12 @@ type Settled<T> =
|
|
|
56
61
|
|
|
57
62
|
type Signal<T> = {
|
|
58
63
|
equals: ((a: unknown, b: unknown) => boolean) | null;
|
|
64
|
+
key: unknown;
|
|
59
65
|
keys: Map<T, SelectorSignal<T>> | null;
|
|
60
66
|
nextPending: Signal<unknown> | null;
|
|
67
|
+
parent: Signal<unknown> | undefined;
|
|
61
68
|
rv: number;
|
|
69
|
+
state: number;
|
|
62
70
|
subs: Link | null;
|
|
63
71
|
subsTail: Link | null;
|
|
64
72
|
type: typeof SIGNAL;
|
|
@@ -74,6 +82,7 @@ interface TransformResult {
|
|
|
74
82
|
|
|
75
83
|
export type {
|
|
76
84
|
Computed,
|
|
85
|
+
ComputedResult,
|
|
77
86
|
Link,
|
|
78
87
|
Reactive,
|
|
79
88
|
SelectorSignal,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import { computed, dispose, effect, isComputed, isSignal, read, root, signal, write } from '~/system';
|
|
3
3
|
import { tick, waitFor } from './lib/wait-for';
|
|
4
|
-
import type { Computed } from '~/system';
|
|
4
|
+
import type { Computed, Signal } from '~/system';
|
|
5
5
|
|
|
6
6
|
|
|
7
7
|
describe('asyncComputed', () => {
|
|
@@ -295,6 +295,92 @@ describe('asyncComputed', () => {
|
|
|
295
295
|
expect(read(node)).toBe(3);
|
|
296
296
|
});
|
|
297
297
|
|
|
298
|
+
it('pending toggles true then false across a resolving promise', async () => {
|
|
299
|
+
let node!: Computed<number | undefined> & { pending: Signal<boolean> },
|
|
300
|
+
resolve!: (v: number) => void;
|
|
301
|
+
|
|
302
|
+
root(() => {
|
|
303
|
+
node = computed(() => new Promise<number>((r) => {
|
|
304
|
+
resolve = r;
|
|
305
|
+
}));
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// The polling effect dispatches synchronously at creation — in flight now
|
|
309
|
+
expect(read(node.pending)).toBe(true);
|
|
310
|
+
expect(read(node)).toBeUndefined();
|
|
311
|
+
|
|
312
|
+
resolve(42);
|
|
313
|
+
await waitFor(() => read(node) === 42, 'node resolves to 42');
|
|
314
|
+
|
|
315
|
+
expect(read(node)).toBe(42);
|
|
316
|
+
expect(read(node.pending)).toBe(false);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it('pending rises on refetch while the value stays stale', async () => {
|
|
320
|
+
let node!: Computed<number | undefined> & { pending: Signal<boolean> },
|
|
321
|
+
resolvers: ((v: number) => void)[] = [],
|
|
322
|
+
s = signal(1);
|
|
323
|
+
|
|
324
|
+
root(() => {
|
|
325
|
+
node = computed(() => {
|
|
326
|
+
read(s);
|
|
327
|
+
|
|
328
|
+
return new Promise<number>((r) => {
|
|
329
|
+
resolvers.push(r);
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
expect(read(node.pending)).toBe(true);
|
|
335
|
+
|
|
336
|
+
resolvers[0](10);
|
|
337
|
+
await waitFor(() => read(node) === 10, 'node settles to 10');
|
|
338
|
+
|
|
339
|
+
expect(read(node)).toBe(10);
|
|
340
|
+
expect(read(node.pending)).toBe(false);
|
|
341
|
+
|
|
342
|
+
// Refetch — dependency change re-dispatches the factory
|
|
343
|
+
write(s, 2);
|
|
344
|
+
await waitFor(() => resolvers.length === 2, 'refetch dispatched');
|
|
345
|
+
|
|
346
|
+
// stale-while-revalidate: value stays 10, but pending rises to signal the in-flight fetch
|
|
347
|
+
expect(read(node)).toBe(10);
|
|
348
|
+
expect(read(node.pending)).toBe(true);
|
|
349
|
+
|
|
350
|
+
resolvers[1](20);
|
|
351
|
+
await waitFor(() => read(node) === 20, 'node settles to 20');
|
|
352
|
+
|
|
353
|
+
expect(read(node)).toBe(20);
|
|
354
|
+
expect(read(node.pending)).toBe(false);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('pending clears on rejection', async () => {
|
|
358
|
+
let node!: Computed<number | undefined> & { pending: Signal<boolean> },
|
|
359
|
+
reject!: (e: Error) => void;
|
|
360
|
+
|
|
361
|
+
root(() => {
|
|
362
|
+
node = computed(() => new Promise<number>((_, r) => {
|
|
363
|
+
reject = r;
|
|
364
|
+
}));
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(read(node.pending)).toBe(true);
|
|
368
|
+
|
|
369
|
+
reject(new Error('boom'));
|
|
370
|
+
await waitFor(() => read(node.pending) === false, 'pending clears after rejection');
|
|
371
|
+
|
|
372
|
+
expect(read(node.pending)).toBe(false);
|
|
373
|
+
expect(() => read(node)).toThrow('boom');
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it('sync computed has no pending', () => {
|
|
377
|
+
root(() => {
|
|
378
|
+
let node = computed(() => 42);
|
|
379
|
+
|
|
380
|
+
expect((node as { pending?: unknown }).pending).toBeUndefined();
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
298
384
|
it('disposing the returned computed stops the polling effect and the factory', async () => {
|
|
299
385
|
let calls = 0,
|
|
300
386
|
s = signal(1);
|
package/test/effects.test.ts
CHANGED
|
@@ -269,3 +269,65 @@ describe('effect patterns', () => {
|
|
|
269
269
|
});
|
|
270
270
|
});
|
|
271
271
|
});
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
describe('effect apply phase', () => {
|
|
275
|
+
it('apply receives (value, prev) after each run', async () => {
|
|
276
|
+
let calls: [number, number | undefined][] = [],
|
|
277
|
+
s = signal(1);
|
|
278
|
+
|
|
279
|
+
effect(
|
|
280
|
+
() => read(s),
|
|
281
|
+
(value, prev) => {
|
|
282
|
+
calls.push([value, prev]);
|
|
283
|
+
}
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
expect(calls).toEqual([[1, undefined]]);
|
|
287
|
+
|
|
288
|
+
write(s, 2);
|
|
289
|
+
await Promise.resolve();
|
|
290
|
+
|
|
291
|
+
expect(calls).toEqual([[1, undefined], [2, 1]]);
|
|
292
|
+
|
|
293
|
+
write(s, 3);
|
|
294
|
+
await Promise.resolve();
|
|
295
|
+
|
|
296
|
+
expect(calls).toEqual([[1, undefined], [2, 1], [3, 2]]);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it('apply runs untracked — its reads do not subscribe', async () => {
|
|
300
|
+
let applied: number[] = [],
|
|
301
|
+
runs = 0,
|
|
302
|
+
s = signal(1),
|
|
303
|
+
t = signal(100);
|
|
304
|
+
|
|
305
|
+
effect(
|
|
306
|
+
() => {
|
|
307
|
+
runs++;
|
|
308
|
+
|
|
309
|
+
return read(s);
|
|
310
|
+
},
|
|
311
|
+
(value) => {
|
|
312
|
+
applied.push(value + read(t));
|
|
313
|
+
}
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(runs).toBe(1);
|
|
317
|
+
expect(applied).toEqual([101]);
|
|
318
|
+
|
|
319
|
+
// apply read t untracked, so writing t must NOT re-run the effect
|
|
320
|
+
write(t, 200);
|
|
321
|
+
await Promise.resolve();
|
|
322
|
+
|
|
323
|
+
expect(runs).toBe(1);
|
|
324
|
+
expect(applied).toEqual([101]);
|
|
325
|
+
|
|
326
|
+
// writing s re-runs fn; apply then reads the current t (200)
|
|
327
|
+
write(s, 2);
|
|
328
|
+
await Promise.resolve();
|
|
329
|
+
|
|
330
|
+
expect(runs).toBe(2);
|
|
331
|
+
expect(applied).toEqual([101, 202]);
|
|
332
|
+
});
|
|
333
|
+
});
|
package/test/errors.test.ts
CHANGED
|
@@ -174,19 +174,19 @@ describe('effect error contract', () => {
|
|
|
174
174
|
stop();
|
|
175
175
|
});
|
|
176
176
|
|
|
177
|
-
it('effect
|
|
177
|
+
it('effect catching its own error internally rethrows nothing', async () => {
|
|
178
178
|
let errors: unknown[] = [],
|
|
179
179
|
s = signal(0),
|
|
180
|
-
stop = effect(
|
|
181
|
-
|
|
180
|
+
stop = effect(() => {
|
|
181
|
+
try {
|
|
182
182
|
if (read(s) === 1) {
|
|
183
183
|
throw new Error('handled');
|
|
184
184
|
}
|
|
185
|
-
}
|
|
186
|
-
(e)
|
|
185
|
+
}
|
|
186
|
+
catch (e) {
|
|
187
187
|
errors.push(e);
|
|
188
188
|
}
|
|
189
|
-
);
|
|
189
|
+
});
|
|
190
190
|
|
|
191
191
|
let captured = await captureUncaught(() => {
|
|
192
192
|
write(s, 1);
|
package/test/system.test.ts
CHANGED
|
@@ -1031,14 +1031,14 @@ describe('edge cases', () => {
|
|
|
1031
1031
|
return val * 10;
|
|
1032
1032
|
});
|
|
1033
1033
|
|
|
1034
|
-
effect(
|
|
1035
|
-
|
|
1034
|
+
effect(() => {
|
|
1035
|
+
try {
|
|
1036
1036
|
effectValues.push(read(c));
|
|
1037
|
-
}
|
|
1038
|
-
(e)
|
|
1037
|
+
}
|
|
1038
|
+
catch (e) {
|
|
1039
1039
|
effectErrors.push(e);
|
|
1040
1040
|
}
|
|
1041
|
-
);
|
|
1041
|
+
});
|
|
1042
1042
|
|
|
1043
1043
|
expect(effectValues).toEqual([0]);
|
|
1044
1044
|
|
|
@@ -1072,14 +1072,14 @@ describe('edge cases', () => {
|
|
|
1072
1072
|
return val;
|
|
1073
1073
|
});
|
|
1074
1074
|
|
|
1075
|
-
effect(
|
|
1076
|
-
|
|
1075
|
+
effect(() => {
|
|
1076
|
+
try {
|
|
1077
1077
|
effectValues.push(read(c));
|
|
1078
|
-
}
|
|
1079
|
-
(e)
|
|
1078
|
+
}
|
|
1079
|
+
catch (e) {
|
|
1080
1080
|
effectErrors.push(e);
|
|
1081
1081
|
}
|
|
1082
|
-
);
|
|
1082
|
+
});
|
|
1083
1083
|
|
|
1084
1084
|
expect(effectValues).toEqual([0]);
|
|
1085
1085
|
|
package/.claude/CHANGELOG.md
DELETED
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## Completed
|
|
4
|
-
- [relocate-benchmarks] 567bd82. Relocate 7 benches to bench/ mirror + config, delete tests/ — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
5
|
-
- [relocate-test-suites] 26fa1017. Relocate 23 suites to test/ mirror + config — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
6
|
-
- [waitfor-test-helper] fa0a934a. waitFor/tick helper replacing 50 fixed-sleep sync sites — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
7
|
-
- [compiler-import-order] 2610d50d. Normalize import order across compiler modules — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
8
|
-
- [reactive-index-cleanup] 00b21810. reactive() facade unknown-bridges + import order — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
9
|
-
- [reactive-array-cleanup] e0aa8b86. ReactiveArray listener typing, sort map, accessor order, imports — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
10
|
-
- [system-any-casts] b890f047. Eliminate any casts in system.ts (unknown-bridged pool sentinels) — landed by direct-implement after blocked run 36ee71d0 (harness gate false-block: depless worktrees, issue 20260720T141515Z). Gate: pnpm agent:test green (tsc --noEmit + 369 tests), benches discover 7. · spec: spec-coding-standards
|
|
11
|
-
- abandoned spend: ~$199.51 equivalent API cost across 7 terminal non-COMPLETED item(s) (system-any-casts, reactive-array-cleanup, reactive-index-cleanup, compiler-import-order, waitfor-test-helper, relocate-test-suites, relocate-benchmarks); recorded, not divided into per-item costs (run 36ee71d0)
|
|
12
|
-
- run-level spend: ~$0.98 equivalent API cost (warm implementer runs + unit-scoped + boundary seats; never divided across items; blended rate = mean(input, output, cache_read, cache_creation) per million tokens) (run 36ee71d0)
|
|
13
|
-
- [constants-export-order] 84f0b3613de0076e070ba4ddcd69c3e0d68a9f3a. Deviations: none; ladder: 3 attempts (critic → replanner completed → critic), completed. Cost: ~$20.03 equivalent API cost (blended-rate approximation). · spec: spec-coding-standards
|
|
14
|
-
- [compiler-const-enum] 1b252cd2180063eefc04bf2b221ce4e9eff969a5. Deviations: none; ladder: 3 attempts (critic → replanner completed → critic), completed. Cost: ~$34.13 equivalent API cost (blended-rate approximation). · spec: spec-coding-standards
|
|
15
|
-
- [types-variadic-any] da6b4873d4238db26d0d75f19a67f8c966a01b83. Deviations: none; ladder: 3 attempts (critic → replanner completed → critic), completed. Cost: ~$47.04 equivalent API cost (blended-rate approximation). · spec: spec-coding-standards
|
|
16
|
-
- [reactive-object-cleanup] 769d007e46a9f6b0b299c2c3d48fa55dffe29628. Deviations: none; ladder: 3 attempts (critic → replanner completed → critic), completed. Cost: ~$32.61 equivalent API cost (blended-rate approximation). · spec: spec-coding-standards
|
|
17
|
-
- cost source of truth: ccusage per-model breakdown (local transcripts carry the input/output/cache split the run journal does not) — figures above are a blended-rate approximation per contracts/models.json pricing (run 36ee71d0)
|
|
18
|
-
- [recursion-free-walks] 143d5591. Recursion-free notify(), update(), and dispose()/unlink() walks — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next-2
|
|
19
|
-
- [unobserved-hooks] b7a93aa4. onUnobserved() — last-subscriber lifecycle hook — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next-2
|
|
20
|
-
- [invalidate] 60df87ae. invalidate() — standalone re-derivation of any computed — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next-2
|
|
21
|
-
- [custom-equals] a4cb4e04. Custom equals on signal() and computed() — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next-2
|
|
22
|
-
- follow-up discharged (completed) from spec-signals-next: pending-only-writes: complete full shape — selector recursive fan-out + nextPend… — evidence: 6a9b8e6
|
|
23
|
-
- abandoned spend: ~$206.41 equivalent API cost across 4 terminal non-COMPLETED item(s) (custom-equals, invalidate, unobserved-hooks, recursion-free-walks); recorded, not divided into per-item costs (run 3306873d)
|
|
24
|
-
- run-level spend: ~$2.11 equivalent API cost (warm implementer runs + unit-scoped + boundary seats; never divided across items; blended rate = mean(input, output, cache_read, cache_creation) per million tokens) (run 3306873d)
|
|
25
|
-
- [async-iterable-resolve] 17b3878753d238869fdf6723dff468705d2795f8. Deviations: deviated — resolve()'s 'rejects when the tracked expression throws' was split into its own it() rather than combined into one test with the resolve-success case, matching the file's one-scenario-per-it convention from tests/async-hardening.ts; both scenarios from Design test 6 are covered.; added — Verified async generator .return() timing empirically (a queued return() on a generator suspended mid an unresolved internal await does not settle until that await resolves) via a throwaway Node script before writing tests 1-3, since the Design's prose doesn't spell out that an abandoned generator's pending gate must be released for its finally to run; tests 1-3 release the old generation's gate to observe closure, consistent with test 3's own description.; added — Added a gate()/tick() test-local helper pair (not in Design) to keep the generator-gating boilerplate readable across tests 1-5; mirrors async-hardening.ts's resolver-array convention.; ladder: 1 attempt (critic), completed. Cost: ~$16.19 equivalent API cost (blended-rate approximation). · spec: spec-signals-next-2
|
|
26
|
-
- cost source of truth: ccusage per-model breakdown (local transcripts carry the input/output/cache split the run journal does not) — figures above are a blended-rate approximation per contracts/models.json pricing (run 3306873d)
|
|
27
|
-
- abandoned spend: ~$229.74 equivalent API cost across 5 terminal non-COMPLETED item(s) (custom-equals, invalidate, unobserved-hooks, async-iterable-resolve, recursion-free-walks); recorded, not divided into per-item costs (run 2f191933)
|
|
28
|
-
- run-level spend: ~$0.56 equivalent API cost (warm implementer runs + unit-scoped + boundary seats; never divided across items; blended rate = mean(input, output, cache_read, cache_creation) per million tokens) (run 2f191933)
|
|
29
|
-
- [untrack-peek] 5d2a577c2b941bdc6fe869951c770ed3c12a82f8. Deviations: deviated — pull(node) extracted only the notified-broadcast + observer-nulled update() call (matching design's literal extraction target); drainPending() and the height>=heap_i||NOTIFY_MASK gate stay inline in read() exactly as before for byte-identical behavior.; deviated — peek() has no separate explicit NOTIFY_MASK pre-check before calling pull() — it always drains pending writes then calls pull(node) unconditionally for a STATE_COMPUTED node, relying on pull()'s internal notify-broadcast (which sets DIRTY/CHECK bits) plus update()'s own gv/CHECK/DIRTY branching to no-op cheaply when nothing changed. A literal external NOTIFY_MASK gate in peek() would check the bit *before* the broadcast that sets it, which never fires on a freshly-written-but-undrained dependency and would make peek() return stale values, contradicting Acceptance clause 3's 'up-to-date value for a dirty computed'. Verified via a dedicated test that peek() sees a fresh value synchronously right after write(), with 0 regressions across the full 343-test suite.; ladder: 1 attempt (critic), completed. Cost: ~$20.51 equivalent API cost (blended-rate approximation). · spec: spec-signals-next
|
|
30
|
-
- [flush-batch] 3bc415d5568da53154f59a32069afaebfc381d04. Deviations: deviated — flush() implemented as `while (stabilizer === STABILIZER_SCHEDULED) stabilize();` instead of the Design's single `if`. Traced the RESCHEDULE path: a write() during an effect's recompute only flips stabilizer to RESCHEDULE and queues the *next* microtask when its subscriber's height is at or below the current heap_i already scanned in this pass (drain-before-scan only re-triggers on a later height iteration reached within the same for-loop) — a single stabilize() call leaves that tail unsettled until the next microtask, violating Acceptance clause 1's 'settles before flush() returns'. The while-loop drains it synchronously while still satisfying the no-op cases (RUNNING/RESCHEDULE/IDLE never equal SCHEDULED) and clause 4 (any stray queued microtask fires against an already-empty/IDLE state and no-ops).; ladder: 1 attempt (critic), completed. Cost: ~$11.27 equivalent API cost (blended-rate approximation). · spec: spec-signals-next
|
|
31
|
-
- cost source of truth: ccusage per-model breakdown (local transcripts carry the input/output/cache split the run journal does not) — figures above are a blended-rate approximation per contracts/models.json pricing (run 2f191933)
|
|
32
|
-
- follow-up discharged (completed) from spec-signals-next: read-version-dedup + pending-only-writes: stamp rv (+ nextPending on Signal entr… — evidence: 6a9b8e6
|
|
33
|
-
- [cleanup-hardening] 14a40dd7. cleanup() hardening — run every disposer, never abort the batch — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
34
|
-
- [async-computed-hardening] 033b205f. asyncComputed hardening — dirty-gap guard, isPending, leak test, ReactiveObject reuse — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
35
|
-
- [async-error-propagation] bf8ad5c0. asyncComputed error propagation (rejections surface, ownership fixed) — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
36
|
-
- [computed-error-caching] 79224d08. Computed error caching + propagation (errors never vanish) — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
37
|
-
- [benchmark-harness] 9d6d7b2c. Canonical reactivity benchmark suite + glitch-freedom regression tests — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
38
|
-
- [test-bench-scripts] 524d07bc. Add test/bench script aliases (agent:test, agent:bench) — reconciled (killed-run recovery). Deviations: unrecorded (the run died before its boundary). Cost: ~$UNMEASURED (manual pre-run, no journal)~ · spec: spec-signals-next
|
|
39
|
-
- [read-version-dedup] dcbaae90. O(1) interleaved repeat-read dedup (read-version stamp) — landed by hand (combined perf integration, bench-gated vs main: 0 regressions). Deviations: SelectorSignal entry-literal rv stamp deferred until signal-is-selector lands (superset-shape rule, follow-up); computed object-size assertion reconciled to 15. Cost: ~$UNMEASURED (manual integration, no journal)~ · spec: spec-signals-next
|
|
40
|
-
- [global-version-fast-path] dcbaae90. globalVersion fast path for clean-graph reads — landed by hand (combined perf integration, bench-gated vs main: 0 regressions). Deviations: computed object-size assertion reconciled to 15 (shared bump with read-version-dedup + pending-only-writes). Cost: ~$UNMEASURED (manual integration, no journal)~ · spec: spec-signals-next
|
|
41
|
-
- [pending-only-writes] dcbaae90. Pending-only writes — defer fan-out off the write hot path (reduced-shape core) — landed by hand (combined perf integration, bench-gated vs main: 0 regressions). Deviations: reduced-shape core only (intrusive nextPending queue, drainPending, per-height + read()-preamble drain); full shape (lazy mark-don't-schedule, selector recursive fan-out + nextPending on the SelectorSignal entry, tests/flush.ts invariant 2) deferred to follow-up pending flush-batch + lazy-computeds + signal-is-selector; SelectorSignal entry-literal nextPending/rv stamp deferred until signal-is-selector; computed object-size assertion reconciled to 15. Cost: ~$UNMEASURED (manual integration, no journal)~ · spec: spec-signals-next
|
|
42
|
-
- [signal-is-selector] 6a9b8e63. signal.selector() per-key selector primitive — O(2) write-path fan-out, lazy per-key entries, eviction on last unsub — landed by hand off main (dcbaae9). Deviations: SelectorSignal entry literal pre-declares nextPending + rv (perf superset-shape, discharges follow-up #1); write-path bench informal (feature, not perf) — one predicted-not-taken keys null-check, write-neutral; suite 328→336 (+8 acceptance tests incl. SameValueZero NaN/object-ref caveats); glitch-freedom green. Cost: ~$UNMEASURED (manual, no journal)~ · spec: spec-signals-next
|
|
43
|
-
- [lazy-computeds] REVERTED (no landing sha). Lazy computeds — compute on first read, sleep while unobserved — removed from scope by user decision 2026-07-19: lazy-by-default was implemented + A/B benchmarked and REGRESSED (full-suite ABBA geomean 1.04; read-class 0.77–0.88× eager; steady-read parity disproven by isolation bench). Do not re-attempt lazy-by-default naively; a non-regressing opt-in approach would be a fresh design. Patch preserved at ~/.claude/storage/lazy-bench/lazy-by-default-full.patch. Feature file removed from the spec per user request. Cost: ~$UNMEASURED (manual, no journal)~ · spec: spec-signals-next
|