@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 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 };
@@ -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
- let n = this.length, before = new Array(n);
150
- for (let i = 0; i < n; i++) {
151
- before[i] = this[i];
152
- }
153
- super.sort(fn);
154
- let buckets = new Map(), order = new Array(n);
155
- for (let i = 0; i < n; i++) {
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
- else {
161
- list.push(i);
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
- for (let i = 0; i < n; i++) {
165
- let list = buckets.get(this[i]);
166
- order[i] = list.length === 1 ? list[0] : list[list.length - 1];
167
- if (list.length > 1) {
168
- list.pop();
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): Computed<import("../types.js").Settled<ReturnType<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): Computed<Settled<T>>;
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"], onError?: (e: unknown) => void) => () => void;
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, onError) => {
578
- let c = makeComputed(onError
591
+ const effect = (fn, apply) => {
592
+ let prev;
593
+ let c = makeComputed(apply
579
594
  ? (o) => {
580
- try {
581
- return fn(o);
582
- }
583
- catch (e) {
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.27.3"
4
+ "@esportsplus/utilities": "^0.28.0"
5
5
  },
6
6
  "devDependencies": {
7
- "@esportsplus/typescript": "^0.29.1",
8
- "@types/node": "^25.6.0",
9
- "vite": "^8.0.8",
10
- "vitest": "^4.1.4"
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.32.0",
39
+ "version": "0.33.0",
40
40
  "scripts": {
41
41
  "agent:bench": "vitest bench --run",
42
42
  "agent:test": "tsc --noEmit && vitest run",
@@ -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
- let n = this.length,
249
- before = new Array(n) as T[];
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
- super.sort(fn);
275
+ for (let i = 0; i < n; i++) {
276
+ before[i] = this[i];
277
+ }
256
278
 
257
- let buckets = new Map<T, number[]>(),
258
- order = new Array(n);
279
+ super.sort(fn);
259
280
 
260
- for (let i = 0; i < n; i++) {
261
- let value = before[i],
262
- list = buckets.get(value);
281
+ let buckets = new Map<T, number[]>(),
282
+ order = new Array(n);
263
283
 
264
- if (!list) {
265
- buckets.set(value, [i]);
266
- }
267
- else {
268
- list.push(i);
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
- for (let i = 0; i < n; i++) {
273
- let list = buckets.get(this[i])!;
296
+ for (let i = 0; i < n; i++) {
297
+ let list = buckets.get(this[i])!;
274
298
 
275
- order[i] = list.length === 1 ? list[0] : list[list.length - 1];
299
+ order[i] = list.length === 1 ? list[0] : list[list.length - 1];
276
300
 
277
- if (list.length > 1) {
278
- list.pop();
301
+ if (list.length > 1) {
302
+ list.pop();
303
+ }
279
304
  }
280
- }
281
305
 
282
- this.dispatch('sort', { order });
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): Computed<Settled<T>> => {
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 Computed<Settled<T>>;
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'], onError?: (e: unknown) => void) => {
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
- onError
871
+ apply
853
872
  ? (o) => {
854
- try {
855
- return fn(o);
856
- }
857
- catch (e) {
858
- onError(e);
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);
@@ -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
+ });
@@ -174,19 +174,19 @@ describe('effect error contract', () => {
174
174
  stop();
175
175
  });
176
176
 
177
- it('effect onError receives the error and nothing rethrows', async () => {
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);
@@ -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
 
@@ -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