@esportsplus/reactivity 0.31.3 → 0.32.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.
Files changed (68) hide show
  1. package/.claude/CHANGELOG.md +43 -0
  2. package/README.md +13 -6
  3. package/bench/cellx.bench.ts +61 -0
  4. package/bench/dynamic.bench.ts +103 -0
  5. package/bench/kairo.bench.ts +368 -0
  6. package/bench/lib/reactive-adapter.ts +51 -0
  7. package/bench/molbench.bench.ts +75 -0
  8. package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
  9. package/build/compiler/array.d.ts +1 -1
  10. package/build/compiler/constants.d.ts +7 -6
  11. package/build/compiler/constants.js +6 -7
  12. package/build/compiler/object.d.ts +1 -1
  13. package/build/compiler/object.js +1 -2
  14. package/build/compiler/primitives.d.ts +1 -1
  15. package/build/constants.d.ts +3 -1
  16. package/build/constants.js +3 -1
  17. package/build/reactive/array.d.ts +8 -5
  18. package/build/reactive/array.js +14 -14
  19. package/build/reactive/index.d.ts +2 -2
  20. package/build/reactive/index.js +1 -1
  21. package/build/reactive/object.d.ts +4 -4
  22. package/build/reactive/object.js +8 -23
  23. package/build/system.d.ts +15 -6
  24. package/build/system.js +415 -69
  25. package/build/types.d.ts +16 -4
  26. package/package.json +5 -2
  27. package/src/compiler/array.ts +1 -1
  28. package/src/compiler/constants.ts +8 -6
  29. package/src/compiler/index.ts +2 -1
  30. package/src/compiler/object.ts +2 -2
  31. package/src/compiler/plugins/vite.ts +1 -0
  32. package/src/compiler/primitives.ts +1 -1
  33. package/src/constants.ts +6 -2
  34. package/src/reactive/array.ts +26 -23
  35. package/src/reactive/index.ts +6 -6
  36. package/src/reactive/object.ts +16 -41
  37. package/src/system.ts +612 -80
  38. package/src/types.ts +29 -9
  39. package/test/async-computed.test.ts +323 -0
  40. package/test/async-errors.test.ts +146 -0
  41. package/test/async-hardening.test.ts +73 -0
  42. package/test/async-iterable.test.ts +221 -0
  43. package/test/async-nested.test.ts +19 -0
  44. package/test/deep-graphs.test.ts +215 -0
  45. package/{tests/effects.ts → test/effects.test.ts} +60 -0
  46. package/test/equals.test.ts +142 -0
  47. package/test/errors.test.ts +201 -0
  48. package/test/flush.test.ts +185 -0
  49. package/test/glitch-freedom.test.ts +115 -0
  50. package/test/invalidate.test.ts +147 -0
  51. package/test/lib/wait-for.ts +28 -0
  52. package/test/pending-writes.test.ts +139 -0
  53. package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
  54. package/test/read-dedup.test.ts +120 -0
  55. package/test/signal-selector.test.ts +187 -0
  56. package/{tests/system.ts → test/system.test.ts} +214 -23
  57. package/test/tsconfig.json +16 -0
  58. package/test/untrack.test.ts +146 -0
  59. package/vitest.config.ts +2 -2
  60. package/tests/async-computed.ts +0 -239
  61. /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
  62. /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
  63. /package/{tests → bench}/tsconfig.json +0 -0
  64. /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
  65. /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
  66. /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
  67. /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
  68. /package/{tests/objects.ts → test/reactive/objects.test.ts} +0 -0
package/src/system.ts CHANGED
@@ -1,13 +1,25 @@
1
1
  import {
2
+ PACKAGE_NAME,
2
3
  SIGNAL,
3
4
  STABILIZER_IDLE, STABILIZER_RESCHEDULE, STABILIZER_RUNNING, STABILIZER_SCHEDULED,
4
- STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
5
+ STATE_CHECK, STATE_COMPUTED, STATE_DIRTY, STATE_EFFECT, STATE_ERROR, STATE_IN_HEAP, STATE_NOTIFY_MASK, STATE_RECOMPUTING
5
6
  } from './constants';
6
- import { Computed, Link, Signal } from './types';
7
- import { isObject } from '@esportsplus/utilities';
7
+ import { Computed, Link, SelectorSignal, Settled, Signal } from './types';
8
+ import { isObject, isPromise } from '@esportsplus/utilities';
8
9
 
9
10
 
10
- let depth = 0,
11
+ // Shared free-listed stack node for the iterative notify()/update()/dispose() walks: `link` carries
12
+ // a notify sibling-continuation or an update dep-cursor; `computed` carries an update/dispose node.
13
+ type Walk = {
14
+ computed: Computed<unknown> | null;
15
+ link: Link | null;
16
+ prev: Walk | null;
17
+ };
18
+
19
+ let asyncMeta = new WeakMap<Computed<unknown>, { factory: Computed<unknown> }>(),
20
+ depth = 0,
21
+ disposeHead: Walk | null = null,
22
+ draining = false,
11
23
  heap: (Computed<unknown> | undefined)[] = new Array(64),
12
24
  heap_i = 0,
13
25
  heap_n = 0,
@@ -15,9 +27,39 @@ let depth = 0,
15
27
  microtask = queueMicrotask,
16
28
  notified = false,
17
29
  observer: Computed<unknown> | null = null,
30
+ pendingHead: Signal<unknown> | null = null,
18
31
  scope: Computed<unknown> | null = null,
19
32
  stabilizer = STABILIZER_IDLE,
20
- version = 0;
33
+ version = 0,
34
+ walkPoolHead: Walk | null = null,
35
+ writes = 0;
36
+
37
+
38
+ function walkPop(walk: Walk): Walk | null {
39
+ let prev = walk.prev;
40
+
41
+ walk.computed = null;
42
+ walk.link = null;
43
+ walk.prev = walkPoolHead;
44
+ walkPoolHead = walk;
45
+
46
+ return prev;
47
+ }
48
+
49
+ function walkPush(computed: Computed<unknown> | null, link: Link | null, prev: Walk | null): Walk {
50
+ let walk = walkPoolHead;
51
+
52
+ if (walk !== null) {
53
+ walkPoolHead = walk.prev;
54
+ walk.computed = computed;
55
+ walk.link = link;
56
+ walk.prev = prev;
57
+
58
+ return walk;
59
+ }
60
+
61
+ return { computed, link, prev };
62
+ }
21
63
 
22
64
 
23
65
  function cleanup<T>(computed: Computed<T>): void {
@@ -25,18 +67,33 @@ function cleanup<T>(computed: Computed<T>): void {
25
67
  return;
26
68
  }
27
69
 
28
- let value = computed.cleanup;
70
+ let errors: unknown[] = [],
71
+ value = computed.cleanup;
72
+
73
+ computed.cleanup = null;
29
74
 
30
75
  if (typeof value === 'function') {
31
- value();
76
+ try {
77
+ value();
78
+ }
79
+ catch (e) {
80
+ errors.push(e);
81
+ }
32
82
  }
33
83
  else {
34
84
  for (let i = 0, n = value.length; i < n; i++) {
35
- value[i]();
85
+ try {
86
+ value[i]();
87
+ }
88
+ catch (e) {
89
+ errors.push(e);
90
+ }
36
91
  }
37
92
  }
38
93
 
39
- computed.cleanup = null;
94
+ if (errors.length) {
95
+ throw errors.length === 1 ? errors[0] : new AggregateError(errors, `${PACKAGE_NAME}: cleanup produced multiple errors`);
96
+ }
40
97
  }
41
98
 
42
99
  function deleteFromHeap<T>(computed: Computed<T>) {
@@ -72,6 +129,26 @@ function deleteFromHeap<T>(computed: Computed<T>) {
72
129
  computed.prevHeap = computed;
73
130
  }
74
131
 
132
+ // Reconstructs main's eager write() fan-out in one batched pass: N writes to one signal
133
+ // queue it once, so each subscriber is heap-inserted once. Self-linked nextPending marks the tail.
134
+ function drainPending() {
135
+ let node = pendingHead;
136
+
137
+ pendingHead = null;
138
+
139
+ while (node !== null) {
140
+ let next = node.nextPending === node ? null : node.nextPending;
141
+
142
+ node.nextPending = null;
143
+
144
+ for (let link: Link | null = node.subs; link; link = link.nextSub) {
145
+ insertIntoHeap(link.sub);
146
+ }
147
+
148
+ node = next;
149
+ }
150
+ }
151
+
75
152
  function insertIntoHeap<T>(computed: Computed<T>) {
76
153
  let state = computed.state;
77
154
 
@@ -107,9 +184,15 @@ function insertIntoHeap<T>(computed: Computed<T>) {
107
184
 
108
185
  // https://github.com/stackblitz/alien-signals/blob/v2.0.3/src/system.ts#L52
109
186
  function link<T>(dep: Signal<T> | Computed<T>, sub: Computed<T>) {
187
+ // rv === version proves this dep already linked to the current observer during this run
188
+ if (dep.rv === version) {
189
+ return;
190
+ }
191
+
110
192
  let prevDep = sub.depsTail;
111
193
 
112
194
  if (prevDep && prevDep.dep === dep) {
195
+ dep.rv = version;
113
196
  return;
114
197
  }
115
198
 
@@ -119,6 +202,7 @@ function link<T>(dep: Signal<T> | Computed<T>, sub: Computed<T>) {
119
202
  nextDep = prevDep ? prevDep.nextDep : sub.deps;
120
203
 
121
204
  if (nextDep && nextDep.dep === dep) {
205
+ dep.rv = version;
122
206
  nextDep.version = version;
123
207
  sub.depsTail = nextDep;
124
208
  return;
@@ -133,9 +217,12 @@ function link<T>(dep: Signal<T> | Computed<T>, sub: Computed<T>) {
133
217
  prevSub.version === version &&
134
218
  prevSub.sub === sub
135
219
  ) {
220
+ dep.rv = version;
136
221
  return;
137
222
  }
138
223
 
224
+ dep.rv = version;
225
+
139
226
  let pooled = linkPoolHead,
140
227
  newLink =
141
228
  sub.depsTail =
@@ -172,6 +259,9 @@ function link<T>(dep: Signal<T> | Computed<T>, sub: Computed<T>) {
172
259
  }
173
260
  }
174
261
 
262
+ // Iterative sub-tree walk over an explicit stack (call-stack recursion overflows on deep graphs).
263
+ // Only the ROOT receives newState; every descendant receives STATE_CHECK. A node already CHECK/DIRTY
264
+ // prunes the descent (the `< STATE_CHECK` guard).
175
265
  function notify<T>(computed: Computed<T>, newState: number) {
176
266
  let state = computed.state;
177
267
 
@@ -181,9 +271,72 @@ function notify<T>(computed: Computed<T>, newState: number) {
181
271
 
182
272
  computed.state = state | newState;
183
273
 
184
- for (let link = computed.subs; link; link = link.nextSub) {
185
- notify(link.sub, STATE_CHECK);
274
+ let link: Link | null = computed.subs,
275
+ stack: Walk | null = null;
276
+
277
+ for (;;) {
278
+ while (link !== null) {
279
+ let sub = link.sub,
280
+ subState = sub.state;
281
+
282
+ if ((subState & STATE_NOTIFY_MASK) < STATE_CHECK) {
283
+ sub.state = subState | STATE_CHECK;
284
+
285
+ if (sub.subs !== null) {
286
+ if (link.nextSub !== null) {
287
+ stack = walkPush(null, link.nextSub, stack);
288
+ }
289
+
290
+ link = sub.subs;
291
+ continue;
292
+ }
293
+ }
294
+
295
+ link = link.nextSub;
296
+ }
297
+
298
+ if (stack === null) {
299
+ break;
300
+ }
301
+
302
+ link = stack.link;
303
+ stack = walkPop(stack);
304
+ }
305
+ }
306
+
307
+ // Shared by read()'s tracked pull and peek()'s untracked pull. observer is nulled around update()
308
+ // so a recompute triggered here tracks into the node's own scope, never the caller's.
309
+ function pull<T>(node: Computed<T>): void {
310
+ if (!notified) {
311
+ notified = true;
312
+
313
+ for (let i = 0; i <= heap_n; i++) {
314
+ for (let computed = heap[i]; computed !== undefined; computed = computed.nextHeap) {
315
+ notify(computed, STATE_DIRTY);
316
+ }
317
+ }
318
+ }
319
+
320
+ let o = observer;
321
+
322
+ observer = null;
323
+ update(node);
324
+ observer = o;
325
+ }
326
+
327
+ function propagate<T>(computed: Computed<T>) {
328
+ for (let c = computed.subs; c; c = c.nextSub) {
329
+ let s = c.sub,
330
+ state = s.state;
331
+
332
+ if (state & STATE_CHECK) {
333
+ s.state = state | STATE_DIRTY;
334
+ }
335
+
336
+ insertIntoHeap(s);
186
337
  }
338
+
339
+ schedule();
187
340
  }
188
341
 
189
342
  function recompute<T>(computed: Computed<T>, del: boolean) {
@@ -196,16 +349,28 @@ function recompute<T>(computed: Computed<T>, del: boolean) {
196
349
  }
197
350
 
198
351
  if (computed.cleanup) {
199
- cleanup(computed);
352
+ // A failing PREVIOUS generation's teardown must not poison this recompute or the stabilize pass
353
+ try {
354
+ cleanup(computed);
355
+ }
356
+ catch (e) {
357
+ microtask(() => {
358
+ throw e;
359
+ });
360
+ }
200
361
  }
201
362
 
202
- let o = observer,
363
+ let err: unknown,
364
+ flags = computed.state & STATE_EFFECT,
365
+ hadError = computed.state & STATE_ERROR,
366
+ o = observer,
203
367
  ok = true,
204
- value;
368
+ value,
369
+ w = writes;
205
370
 
206
371
  observer = computed;
207
372
  computed.depsTail = null;
208
- computed.state = STATE_COMPUTED | STATE_RECOMPUTING;
373
+ computed.state = STATE_COMPUTED | STATE_RECOMPUTING | flags;
209
374
 
210
375
  depth++;
211
376
  version++;
@@ -215,11 +380,16 @@ function recompute<T>(computed: Computed<T>, del: boolean) {
215
380
  }
216
381
  catch (e) {
217
382
  ok = false;
383
+ err = e;
218
384
  }
219
385
 
220
386
  depth--;
387
+ // Fresh version so rv/link stamps from this run (incl. nested creations) go stale — false negatives only
388
+ version++;
221
389
  observer = o;
222
- computed.state = STATE_COMPUTED;
390
+ computed.state = STATE_COMPUTED | flags;
391
+ // Entry snapshot, not current writes: a node whose fn wrote mid-run must stay gv < writes and validate normally
392
+ computed.gv = w;
223
393
 
224
394
  let depsTail = computed.depsTail as Link | null,
225
395
  remove = depsTail ? depsTail.nextDep : computed.deps;
@@ -238,21 +408,34 @@ function recompute<T>(computed: Computed<T>, del: boolean) {
238
408
  }
239
409
  }
240
410
 
241
- if (ok && value !== computed.value) {
242
- computed.value = value as T;
411
+ if (ok) {
412
+ computed.error = null;
243
413
 
244
- for (let c = computed.subs; c; c = c.nextSub) {
245
- let s = c.sub,
246
- state = s.state;
414
+ // A value comparator can't compare a missing prior value (and would fault dereferencing
415
+ // undefined), so a recompute whose PREVIOUS value was undefined falls back to === — not
416
+ // only the first run.
417
+ let changed = computed.equals === null || computed.value === undefined
418
+ ? value !== computed.value
419
+ : !computed.equals(computed.value, value);
247
420
 
248
- if (state & STATE_CHECK) {
249
- s.state = state | STATE_DIRTY;
250
- }
421
+ computed.value = value as T;
251
422
 
252
- insertIntoHeap(s);
423
+ if (changed || hadError) {
424
+ propagate(computed);
253
425
  }
426
+ }
427
+ else {
428
+ computed.error = err;
429
+ computed.state |= STATE_ERROR;
254
430
 
255
- schedule();
431
+ if (flags) {
432
+ microtask(() => {
433
+ throw err;
434
+ });
435
+ }
436
+ else {
437
+ propagate(computed);
438
+ }
256
439
  }
257
440
  }
258
441
 
@@ -277,6 +460,12 @@ function stabilize() {
277
460
  stabilizer = STABILIZER_RUNNING;
278
461
 
279
462
  for (heap_i = 0; heap_i <= heap_n; heap_i++) {
463
+ // Drain before scanning each height so writes emitted by a lower level's recompute
464
+ // land their subscribers in this same pass, matching main's eager same-pass pickup
465
+ if (pendingHead !== null) {
466
+ drainPending();
467
+ }
468
+
280
469
  let computed = heap[heap_i];
281
470
 
282
471
  heap[heap_i] = undefined;
@@ -322,11 +511,22 @@ function unlink(link: Link): Link | null {
322
511
  if (prevSub) {
323
512
  prevSub.nextSub = nextSub;
324
513
  }
325
- else if ((dep.subs = nextSub) === null && (dep as Computed<unknown>).state & STATE_COMPUTED) {
326
- dispose(dep as Computed<unknown>);
514
+ else if ((dep.subs = nextSub) === null) {
515
+ if ((dep as Computed<unknown>).state & STATE_COMPUTED) {
516
+ dispose(dep as Computed<unknown>);
517
+ }
518
+ else if ((dep as SelectorSignal<unknown>).parent !== undefined) {
519
+ let parent = (dep as SelectorSignal<unknown>).parent;
520
+
521
+ parent.keys!.delete((dep as SelectorSignal<unknown>).key);
522
+
523
+ if (parent.keys!.size === 0) {
524
+ parent.keys = null;
525
+ }
526
+ }
327
527
  }
328
528
 
329
- link.dep = link.sub = null as any;
529
+ link.dep = link.sub = null as unknown as Computed<unknown>;
330
530
  link.nextSub = link.prevSub = null;
331
531
  link.nextDep = linkPoolHead;
332
532
  linkPoolHead = link;
@@ -334,56 +534,165 @@ function unlink(link: Link): Link | null {
334
534
  return nextDep;
335
535
  }
336
536
 
337
- function update<T>(computed: Computed<T>): void {
338
- if (computed.state & STATE_CHECK) {
339
- for (let link = computed.deps; link; link = link.nextDep) {
340
- let dep = link.dep;
537
+ // Iterative CHECK-pull over an explicit frame stack (call-stack recursion overflows on deep graphs).
538
+ // `w` is the entry snapshot of writes so the gv === writes clean-graph exit stays consistent across
539
+ // one pull even if a mid-walk recompute bumps the global. The DIRTY-break stops checking further deps
540
+ // once a node is known dirty; the notify mask is cleared on every node left.
541
+ function update<T>(root: Computed<T>): void {
542
+ let link: Link | null = null,
543
+ node: Computed<unknown> = root as Computed<unknown>,
544
+ resuming = false,
545
+ stack: Walk | null = null,
546
+ w = writes;
547
+
548
+ for (;;) {
549
+ if (!resuming) {
550
+ if (node.gv === w) {
551
+ node.state &= ~STATE_NOTIFY_MASK;
552
+ }
553
+ else if (node.state & STATE_CHECK) {
554
+ link = node.deps;
555
+ resuming = true;
556
+ }
557
+ else if (node.state & STATE_DIRTY) {
558
+ recompute(node, true);
559
+ node.state &= ~STATE_NOTIFY_MASK;
560
+ }
561
+ else {
562
+ node.gv = w;
563
+ node.state &= ~STATE_NOTIFY_MASK;
564
+ }
565
+ }
341
566
 
342
- if ((dep as Computed<unknown>).state & STATE_COMPUTED) {
343
- update(dep as Computed<unknown>);
567
+ if (resuming) {
568
+ let descended = false;
344
569
 
345
- if (computed.state & STATE_DIRTY) {
570
+ while (link !== null && !(node.state & STATE_DIRTY)) {
571
+ if ((link.dep as Computed<unknown>).state & STATE_COMPUTED) {
572
+ stack = walkPush(node, link.nextDep, stack);
573
+ node = link.dep as Computed<unknown>;
574
+ link = null;
575
+ resuming = false;
576
+ descended = true;
346
577
  break;
347
578
  }
579
+
580
+ link = link.nextDep;
581
+ }
582
+
583
+ if (descended) {
584
+ continue;
585
+ }
586
+
587
+ if (node.state & STATE_DIRTY) {
588
+ recompute(node, true);
589
+ }
590
+ else {
591
+ node.gv = w;
348
592
  }
593
+
594
+ node.state &= ~STATE_NOTIFY_MASK;
595
+ resuming = false;
349
596
  }
350
- }
351
597
 
352
- if (computed.state & STATE_DIRTY) {
353
- recompute(computed, true);
354
- }
598
+ if (stack === null) {
599
+ return;
600
+ }
355
601
 
356
- computed.state = STATE_COMPUTED;
602
+ node = stack.computed!;
603
+ link = stack.link;
604
+ stack = walkPop(stack);
605
+ resuming = true;
606
+ }
357
607
  }
358
608
 
359
609
 
360
- const asyncComputed = <T>(fn: Computed<Promise<T>>['fn']): Signal<T | undefined> => {
361
- let factory = computed(fn),
610
+ // Drives an async factory — a computed whose fn returns a Promise or AsyncIterable into a settled
611
+ // wrapper: the polling effect awaits each dispatch and writes the value/error signals the wrapper
612
+ // reads. `id` plus the dirty-gap guard enforce latest-wins across re-dispatches.
613
+ function makeAsyncComputed<T>(factory: Computed<Promise<T> | AsyncIterable<T> | T>): Computed<T | undefined> {
614
+ let error = signal<unknown>(undefined),
362
615
  node = signal<T | undefined>(undefined),
363
616
  v = 0;
364
617
 
365
- onCleanup(effect(() => {
366
- let id = ++v;
618
+ let stop = effect(() => {
619
+ let fail = (e: unknown) => {
620
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
621
+ write(error, e === undefined ? new Error('reactivity: async computed rejected with undefined') : e);
622
+ }
623
+ },
624
+ id = ++v,
625
+ // Heap membership (a write's eager insert) marks a pending re-run the notify mask alone misses.
626
+ result = read(factory);
627
+
628
+ if (isPromise(result)) {
629
+ (result as Promise<T>).then(
630
+ (value) => {
631
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
632
+ write(error, undefined);
633
+ write(node, value);
634
+ }
635
+ },
636
+ fail
637
+ );
638
+ }
639
+ else if (result != null && typeof (result as AsyncIterable<T>)[Symbol.asyncIterator] === 'function') {
640
+ let it = (result as AsyncIterable<T>)[Symbol.asyncIterator]();
641
+
642
+ onCleanup(() => {
643
+ it.return?.();
644
+ });
367
645
 
368
- (read(factory) as Promise<T>).then((value) => {
369
- if (id === v) {
370
- write(node, value);
646
+ let step = (r: IteratorResult<T>) => {
647
+ if (id !== v || (factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
648
+ return;
649
+ }
650
+
651
+ if (!r.done) {
652
+ write(error, undefined);
653
+ write(node, r.value);
654
+ it.next().then(step, fail);
655
+ }
656
+ };
657
+
658
+ untrack(() => it.next()).then(step, fail);
659
+ }
660
+ else {
661
+ write(error, undefined);
662
+ write(node, result as T);
663
+ }
664
+ });
665
+
666
+ let wrapper = makeComputed<T | undefined>(() => {
667
+ let e = read(error);
668
+
669
+ if (e !== undefined) {
670
+ throw e;
371
671
  }
372
- }, () => {});
373
- }));
374
672
 
375
- return node;
376
- };
673
+ return read(node);
674
+ });
377
675
 
378
- const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
676
+ asyncMeta.set(wrapper as Computed<unknown>, { factory: factory as Computed<unknown> });
677
+ wrapper.disposal = stop;
678
+
679
+ return wrapper;
680
+ }
681
+
682
+ function makeComputed<T>(fn: Computed<T>['fn'], eager: boolean = false): Computed<T> {
379
683
  let self: Computed<T> = {
380
684
  cleanup: null,
381
685
  deps: null,
382
686
  depsTail: null,
687
+ disposal: null,
688
+ equals: null,
689
+ error: null,
383
690
  fn: fn,
691
+ gv: 0,
384
692
  height: 0,
385
693
  nextHeap: undefined,
386
- prevHeap: null as any,
694
+ prevHeap: null as unknown as Computed<unknown>,
695
+ rv: 0,
387
696
  state: STATE_COMPUTED,
388
697
  subs: null,
389
698
  subsTail: null,
@@ -397,6 +706,13 @@ const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
397
706
  self.height = observer.height;
398
707
  recompute(self, false);
399
708
  }
709
+ else if (eager) {
710
+ // computed() must know fn's return type to pick sync vs async. This probe runs BEFORE
711
+ // link() below, so self has no subs yet — recompute's propagate is a no-op and cannot
712
+ // re-run the parent. Deferring here (as effect() still does) would leave value unset.
713
+ self.height = observer.height + 1;
714
+ recompute(self, false);
715
+ }
400
716
  else {
401
717
  self.height = observer.height + 1;
402
718
  insertIntoHeap(self);
@@ -416,32 +732,162 @@ const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
416
732
  }
417
733
 
418
734
  return self;
735
+ }
736
+
737
+ // Reuses the same recompute-nesting counter schedule() consults, so writes inside fn defer
738
+ // scheduling until fn returns; pair with flush() for a synchronous transaction.
739
+ const batch = <T>(fn: () => T): T => {
740
+ depth++;
741
+
742
+ try {
743
+ return fn();
744
+ }
745
+ finally {
746
+ depth--;
747
+
748
+ if (!depth) {
749
+ schedule();
750
+ }
751
+ }
419
752
  };
420
753
 
421
- const dispose = <T>(computed: Computed<T>) => {
422
- deleteFromHeap(computed);
754
+ // A fn returning a Promise or AsyncIterable transparently becomes an async computed: the first run is
755
+ // 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>> => {
757
+ // eager probe so self.value carries fn's return even when this is a non-first tracked op — the
758
+ // detection below cannot depend on the deferred branch, which never runs fn synchronously.
759
+ let o = observer,
760
+ self: Computed<unknown> = makeComputed(fn, true),
761
+ value = self.value;
423
762
 
424
- let dep = computed.deps;
763
+ if (isPromise(value) || (value != null && typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function')) {
764
+ // Built untracked: makeAsyncComputed's polling effect + wrapper take makeComputed's
765
+ // observer-null path (the proven top-level flow) instead of entangling with the enclosing
766
+ // observer's recompute, which deadlocks the scheduler. The probe is already linked+owned by
767
+ // the parent as the factory; the wrapper needs its own teardown tied to that same scope.
768
+ self = untrack(() => makeAsyncComputed(self as Computed<Promise<unknown> | AsyncIterable<unknown>>));
425
769
 
426
- while (dep) {
427
- dep = unlink(dep);
770
+ if (o) {
771
+ onCleanup(() => dispose(self));
772
+ }
428
773
  }
429
774
 
430
- computed.deps = null;
775
+ self.equals = equals as ((a: unknown, b: unknown) => boolean) | null;
431
776
 
432
- if (computed.cleanup) {
433
- cleanup(computed);
777
+ return self as Computed<Settled<T>>;
778
+ };
779
+
780
+ // Forces a re-derivation without the dummy-signal-dependency hack. writes++ FIRST so a gv-stamped
781
+ // node cannot skip the forced re-run via update()'s clean-graph fast path; an async computed's wrapper
782
+ // redirects to its factory so the promise re-dispatches (a refetch).
783
+ computed.invalidate = <T>(c: Computed<T>): void => {
784
+ let meta = asyncMeta.get(c);
785
+
786
+ if (meta) {
787
+ computed.invalidate(meta.factory);
788
+ return;
434
789
  }
790
+
791
+ writes++;
792
+ c.state |= STATE_DIRTY;
793
+ insertIntoHeap(c);
794
+ schedule();
435
795
  };
436
796
 
437
- const effect = <T>(fn: Computed<T>['fn']) => {
438
- let c = computed(fn);
797
+ // Teardown runs as an iterative LIFO drain, not recursion: a recursive dispose→unlink→dispose cascade
798
+ // overflows the call stack on deep chains. A dispose issued while a drain runs enqueues and returns,
799
+ // so the running drain picks it up. Field-nulling keeps the drain exactly-once, so a double dispose
800
+ // stays a no-op. The try/finally guarantees `draining` resets even if a cleanup/disposal callback throws.
801
+ const dispose = <T>(computed: Computed<T>): void => {
802
+ // A dispose issued mid-drain only enqueues; the running drain picks it up, so the first node is
803
+ // processed inline (no worklist node) and the pool is touched only for re-entrant deep cascades.
804
+ if (draining) {
805
+ disposeHead = walkPush(computed as Computed<unknown>, null, disposeHead);
806
+
807
+ return;
808
+ }
809
+
810
+ draining = true;
811
+
812
+ let node: Computed<unknown> = computed as Computed<unknown>;
813
+
814
+ try {
815
+ for (;;) {
816
+ deleteFromHeap(node);
817
+
818
+ let dep = node.deps;
819
+
820
+ while (dep) {
821
+ dep = unlink(dep);
822
+ }
823
+
824
+ node.deps = null;
825
+
826
+ if (node.cleanup) {
827
+ cleanup(node);
828
+ }
829
+
830
+ if (node.disposal) {
831
+ let d = node.disposal;
832
+
833
+ node.disposal = null;
834
+ d();
835
+ }
836
+
837
+ if (disposeHead === null) {
838
+ break;
839
+ }
840
+
841
+ node = disposeHead.computed!;
842
+ disposeHead = walkPop(disposeHead);
843
+ }
844
+ }
845
+ finally {
846
+ draining = false;
847
+ }
848
+ };
849
+
850
+ const effect = <T>(fn: Computed<T>['fn'], onError?: (e: unknown) => void) => {
851
+ let c = makeComputed<T | undefined>(
852
+ onError
853
+ ? (o) => {
854
+ try {
855
+ return fn(o);
856
+ }
857
+ catch (e) {
858
+ onError(e);
859
+ }
860
+ }
861
+ : fn
862
+ );
863
+
864
+ c.state |= STATE_EFFECT;
865
+
866
+ // The creation run precedes the EFFECT tag, so its failure bypasses recompute's rethrow arm.
867
+ if (c.state & STATE_ERROR) {
868
+ let err = c.error;
869
+
870
+ microtask(() => {
871
+ throw err;
872
+ });
873
+ }
439
874
 
440
875
  return () => {
441
876
  dispose(c);
442
877
  };
443
878
  };
444
879
 
880
+ // RUNNING/RESCHEDULE means a pass is already draining (or a flush is already in this call chain);
881
+ // re-entering stabilize here would corrupt heap_i, so this is a deliberate no-op.
882
+ // Loops (not a single call): a write during a pass can target a height stabilize()'s current
883
+ // pass already scanned past, which only flips stabilizer to RESCHEDULE for the *next* microtask
884
+ // rather than draining in-pass — looping here is what actually settles that tail synchronously.
885
+ const flush = (): void => {
886
+ while (stabilizer === STABILIZER_SCHEDULED) {
887
+ stabilize();
888
+ }
889
+ };
890
+
445
891
  const isComputed = (value: unknown): value is Computed<unknown> => {
446
892
  return isObject(value) && !!((value as unknown as Computed<unknown>).state & STATE_COMPUTED);
447
893
  };
@@ -472,11 +918,33 @@ const onCleanup = (fn: VoidFunction): typeof fn => {
472
918
  return fn;
473
919
  };
474
920
 
921
+ const peek = <T>(node: Signal<T> | Computed<T>): T => {
922
+ if ((node as Computed<T>).state & STATE_COMPUTED) {
923
+ if (pendingHead !== null) {
924
+ drainPending();
925
+ }
926
+
927
+ pull(node as Computed<T>);
928
+ }
929
+
930
+ if ((node as Computed<T>).state & STATE_ERROR) {
931
+ throw (node as Computed<T>).error;
932
+ }
933
+
934
+ return node.value;
935
+ };
936
+
475
937
  const read = <T>(node: Signal<T> | Computed<T>): T => {
476
938
  if (observer) {
477
939
  link(node, observer);
478
940
 
479
941
  if ((node as Computed<unknown>).state & STATE_COMPUTED) {
942
+ // Invariant 1: a tracked mid-cycle read must see pending writes — drain so the heap
943
+ // and this node's notify bits reflect them before the broadcast condition is read
944
+ if (pendingHead !== null) {
945
+ drainPending();
946
+ }
947
+
480
948
  let height = (node as Computed<T>).height;
481
949
 
482
950
  if (height >= observer.height) {
@@ -484,21 +952,15 @@ const read = <T>(node: Signal<T> | Computed<T>): T => {
484
952
  }
485
953
 
486
954
  if (height >= heap_i || (node as Computed<T>).state & STATE_NOTIFY_MASK) {
487
- if (!notified) {
488
- notified = true;
489
-
490
- for (let i = 0; i <= heap_n; i++) {
491
- for (let computed = heap[i]; computed !== undefined; computed = computed.nextHeap) {
492
- notify(computed, STATE_DIRTY);
493
- }
494
- }
495
- }
496
-
497
- update(node as Computed<T>);
955
+ pull(node as Computed<T>);
498
956
  }
499
957
  }
500
958
  }
501
959
 
960
+ if ((node as Computed<T>).state & STATE_ERROR) {
961
+ throw (node as Computed<T>).error;
962
+ }
963
+
502
964
  return node.value;
503
965
  };
504
966
 
@@ -536,8 +998,12 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
536
998
 
537
999
  root.disposables = 0;
538
1000
 
539
- const signal = <T>(value: T): Signal<T> => {
1001
+ const signal = <T>(value: T, equals: ((a: T, b: T) => boolean) | null = null): Signal<T> => {
540
1002
  return {
1003
+ equals: equals as ((a: unknown, b: unknown) => boolean) | null,
1004
+ keys: null,
1005
+ nextPending: null,
1006
+ rv: 0,
541
1007
  subs: null,
542
1008
  subsTail: null,
543
1009
  type: SIGNAL,
@@ -545,12 +1011,72 @@ const signal = <T>(value: T): Signal<T> => {
545
1011
  };
546
1012
  };
547
1013
 
1014
+ // SameValueZero (Map) key lookup: NaN matches itself, ±0 collapse — slightly wider than === for those.
1015
+ // Object keys compare by reference and must be stable. The initial snapshot below uses ===.
1016
+ signal.selector = <T>(node: Signal<T>, key: T): boolean => {
1017
+ if (!observer) {
1018
+ return node.value === key;
1019
+ }
1020
+
1021
+ let keys = (node.keys ??= new Map()),
1022
+ entry = keys.get(key);
1023
+
1024
+ if (entry === undefined) {
1025
+ keys.set(key, entry = {
1026
+ equals: null,
1027
+ key,
1028
+ keys: null,
1029
+ nextPending: null,
1030
+ parent: node,
1031
+ rv: 0,
1032
+ subs: null,
1033
+ subsTail: null,
1034
+ type: SIGNAL,
1035
+ value: node.value === key
1036
+ });
1037
+ }
1038
+
1039
+ return read(entry);
1040
+ };
1041
+
1042
+ const untrack = <T>(fn: () => T): T => {
1043
+ let o = observer;
1044
+
1045
+ observer = null;
1046
+
1047
+ try {
1048
+ return fn();
1049
+ }
1050
+ finally {
1051
+ observer = o;
1052
+ }
1053
+ };
1054
+
548
1055
  const write = <T>(signal: Signal<T>, value: T) => {
549
- if (signal.value === value) {
1056
+ let prev = signal.value;
1057
+
1058
+ if (signal.equals === null ? prev === value : signal.equals(prev, value)) {
550
1059
  return;
551
1060
  }
552
1061
 
553
1062
  signal.value = value;
1063
+ writes++;
1064
+
1065
+ // Per-key fan-out: only the leaving (prev) and entering (value) entries flip — O(2), key-count independent.
1066
+ // Runs before the subs === null exit: a parent may carry per-key subscribers yet zero direct subs.
1067
+ if (signal.keys !== null) {
1068
+ let entry = signal.keys.get(prev);
1069
+
1070
+ if (entry !== undefined) {
1071
+ write(entry, false);
1072
+ }
1073
+
1074
+ entry = signal.keys.get(value);
1075
+
1076
+ if (entry !== undefined) {
1077
+ write(entry, true);
1078
+ }
1079
+ }
554
1080
 
555
1081
  if (signal.subs === null) {
556
1082
  return;
@@ -558,8 +1084,10 @@ const write = <T>(signal: Signal<T>, value: T) => {
558
1084
 
559
1085
  notified = false;
560
1086
 
561
- for (let link: Link | null = signal.subs; link; link = link.nextSub) {
562
- insertIntoHeap(link.sub);
1087
+ // O(1) deferred mark: queue the signal (self-link = tail) unless already queued; fan-out defers to drain
1088
+ if (signal.nextPending === null) {
1089
+ signal.nextPending = pendingHead ?? signal;
1090
+ pendingHead = signal;
563
1091
  }
564
1092
 
565
1093
  schedule();
@@ -567,13 +1095,17 @@ const write = <T>(signal: Signal<T>, value: T) => {
567
1095
 
568
1096
 
569
1097
  export {
570
- asyncComputed, computed,
1098
+ batch,
1099
+ computed,
571
1100
  dispose,
572
1101
  effect,
1102
+ flush,
573
1103
  isComputed, isSignal,
574
1104
  onCleanup,
1105
+ peek,
575
1106
  read, root,
576
1107
  signal,
1108
+ untrack,
577
1109
  write
578
1110
  };
579
1111
  export type { Computed, Signal };