@esportsplus/reactivity 0.31.3 → 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.
Files changed (67) hide show
  1. package/README.md +43 -6
  2. package/bench/cellx.bench.ts +61 -0
  3. package/bench/dynamic.bench.ts +103 -0
  4. package/bench/kairo.bench.ts +368 -0
  5. package/bench/lib/reactive-adapter.ts +51 -0
  6. package/bench/molbench.bench.ts +75 -0
  7. package/{tests/bench/system.ts → bench/system.bench.ts} +3 -3
  8. package/build/compiler/array.d.ts +1 -1
  9. package/build/compiler/constants.d.ts +7 -6
  10. package/build/compiler/constants.js +6 -7
  11. package/build/compiler/object.d.ts +1 -1
  12. package/build/compiler/object.js +1 -2
  13. package/build/compiler/primitives.d.ts +1 -1
  14. package/build/constants.d.ts +3 -1
  15. package/build/constants.js +3 -1
  16. package/build/reactive/array.d.ts +7 -3
  17. package/build/reactive/array.js +32 -24
  18. package/build/reactive/index.d.ts +2 -2
  19. package/build/reactive/index.js +1 -1
  20. package/build/reactive/object.d.ts +4 -4
  21. package/build/reactive/object.js +8 -23
  22. package/build/system.d.ts +15 -6
  23. package/build/system.js +432 -69
  24. package/build/types.d.ts +22 -4
  25. package/package.json +10 -7
  26. package/src/compiler/array.ts +1 -1
  27. package/src/compiler/constants.ts +8 -6
  28. package/src/compiler/index.ts +2 -1
  29. package/src/compiler/object.ts +2 -2
  30. package/src/compiler/plugins/vite.ts +1 -0
  31. package/src/compiler/primitives.ts +1 -1
  32. package/src/constants.ts +6 -2
  33. package/src/reactive/array.ts +50 -35
  34. package/src/reactive/index.ts +6 -6
  35. package/src/reactive/object.ts +16 -41
  36. package/src/system.ts +635 -80
  37. package/src/types.ts +38 -9
  38. package/test/async-computed.test.ts +409 -0
  39. package/test/async-errors.test.ts +146 -0
  40. package/test/async-hardening.test.ts +73 -0
  41. package/test/async-iterable.test.ts +221 -0
  42. package/test/async-nested.test.ts +19 -0
  43. package/test/deep-graphs.test.ts +215 -0
  44. package/{tests/effects.ts → test/effects.test.ts} +122 -0
  45. package/test/equals.test.ts +142 -0
  46. package/test/errors.test.ts +201 -0
  47. package/test/flush.test.ts +185 -0
  48. package/test/glitch-freedom.test.ts +115 -0
  49. package/test/invalidate.test.ts +147 -0
  50. package/test/lib/wait-for.ts +28 -0
  51. package/test/pending-writes.test.ts +139 -0
  52. package/{tests/reactive.ts → test/reactive/reactive.test.ts} +20 -19
  53. package/test/read-dedup.test.ts +120 -0
  54. package/test/signal-selector.test.ts +187 -0
  55. package/{tests/system.ts → test/system.test.ts} +210 -19
  56. package/test/tsconfig.json +16 -0
  57. package/test/untrack.test.ts +146 -0
  58. package/vitest.config.ts +2 -2
  59. package/tests/async-computed.ts +0 -239
  60. /package/{tests/bench/array.ts → bench/reactive/array.bench.ts} +0 -0
  61. /package/{tests/bench/reactive-object.ts → bench/reactive/reactive-object.bench.ts} +0 -0
  62. /package/{tests → bench}/tsconfig.json +0 -0
  63. /package/{tests/compiler.ts → test/compiler/compiler.test.ts} +0 -0
  64. /package/{tests/primitives.ts → test/primitives.test.ts} +0 -0
  65. /package/{tests/array.ts → test/reactive/array.test.ts} +0 -0
  66. /package/{tests/nested.ts → test/reactive/nested.test.ts} +0 -0
  67. /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, ComputedResult, 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
+ }
186
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);
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,183 @@ 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;
348
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;
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),
616
+ pending = signal(false),
363
617
  v = 0;
364
618
 
365
- onCleanup(effect(() => {
366
- let id = ++v;
619
+ let stop = effect(() => {
620
+ let fail = (e: unknown) => {
621
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
622
+ write(error, e === undefined ? new Error('reactivity: async computed rejected with undefined') : e);
623
+ write(pending, false);
624
+ }
625
+ },
626
+ id = ++v,
627
+ // Heap membership (a write's eager insert) marks a pending re-run the notify mask alone misses.
628
+ result = read(factory);
629
+
630
+ if (isPromise(result)) {
631
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
632
+ write(pending, true);
633
+ }
634
+
635
+ (result as Promise<T>).then(
636
+ (value) => {
637
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
638
+ write(error, undefined);
639
+ write(node, value);
640
+ write(pending, false);
641
+ }
642
+ },
643
+ fail
644
+ );
645
+ }
646
+ else if (result != null && typeof (result as AsyncIterable<T>)[Symbol.asyncIterator] === 'function') {
647
+ let it = (result as AsyncIterable<T>)[Symbol.asyncIterator]();
367
648
 
368
- (read(factory) as Promise<T>).then((value) => {
369
- if (id === v) {
370
- write(node, value);
649
+ onCleanup(() => {
650
+ it.return?.();
651
+ });
652
+
653
+ let step = (r: IteratorResult<T>) => {
654
+ if (id !== v || (factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
655
+ return;
656
+ }
657
+
658
+ if (!r.done) {
659
+ write(error, undefined);
660
+ write(node, r.value);
661
+ write(pending, false);
662
+ it.next().then(step, fail);
663
+ }
664
+ else {
665
+ write(pending, false);
666
+ }
667
+ };
668
+
669
+ if (id === v && !(factory.state & (STATE_IN_HEAP | STATE_NOTIFY_MASK))) {
670
+ write(pending, true);
371
671
  }
372
- }, () => {});
373
- }));
374
672
 
375
- return node;
376
- };
673
+ untrack(() => it.next()).then(step, fail);
674
+ }
675
+ else {
676
+ write(error, undefined);
677
+ write(node, result as T);
678
+ write(pending, false);
679
+ }
680
+ });
681
+
682
+ let wrapper = makeComputed<T | undefined>(() => {
683
+ let e = read(error);
684
+
685
+ if (e !== undefined) {
686
+ throw e;
687
+ }
688
+
689
+ return read(node);
690
+ });
691
+
692
+ (wrapper as Computed<T | undefined> & { pending: Signal<boolean> }).pending = pending;
693
+
694
+ asyncMeta.set(wrapper as Computed<unknown>, { factory: factory as Computed<unknown> });
695
+ wrapper.disposal = stop;
377
696
 
378
- const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
697
+ return wrapper;
698
+ }
699
+
700
+ function makeComputed<T>(fn: Computed<T>['fn'], eager: boolean = false): Computed<T> {
379
701
  let self: Computed<T> = {
380
702
  cleanup: null,
381
703
  deps: null,
382
704
  depsTail: null,
705
+ disposal: null,
706
+ equals: null,
707
+ error: null,
383
708
  fn: fn,
709
+ gv: 0,
384
710
  height: 0,
385
711
  nextHeap: undefined,
386
- prevHeap: null as any,
712
+ prevHeap: null as unknown as Computed<unknown>,
713
+ rv: 0,
387
714
  state: STATE_COMPUTED,
388
715
  subs: null,
389
716
  subsTail: null,
@@ -397,6 +724,13 @@ const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
397
724
  self.height = observer.height;
398
725
  recompute(self, false);
399
726
  }
727
+ else if (eager) {
728
+ // computed() must know fn's return type to pick sync vs async. This probe runs BEFORE
729
+ // link() below, so self has no subs yet — recompute's propagate is a no-op and cannot
730
+ // re-run the parent. Deferring here (as effect() still does) would leave value unset.
731
+ self.height = observer.height + 1;
732
+ recompute(self, false);
733
+ }
400
734
  else {
401
735
  self.height = observer.height + 1;
402
736
  insertIntoHeap(self);
@@ -416,32 +750,163 @@ const computed = <T>(fn: Computed<T>['fn']): Computed<T> => {
416
750
  }
417
751
 
418
752
  return self;
753
+ }
754
+
755
+ // Reuses the same recompute-nesting counter schedule() consults, so writes inside fn defer
756
+ // scheduling until fn returns; pair with flush() for a synchronous transaction.
757
+ const batch = <T>(fn: () => T): T => {
758
+ depth++;
759
+
760
+ try {
761
+ return fn();
762
+ }
763
+ finally {
764
+ depth--;
765
+
766
+ if (!depth) {
767
+ schedule();
768
+ }
769
+ }
770
+ };
771
+
772
+ // A fn returning a Promise or AsyncIterable transparently becomes an async computed: the first run is
773
+ // the probe, reused as the factory (no duplicate dispatch). A plain fn returns the node directly.
774
+ const computed = <T>(fn: Computed<T>['fn'], equals: ((a: Settled<T>, b: Settled<T>) => boolean) | null = null): ComputedResult<T> => {
775
+ // eager probe so self.value carries fn's return even when this is a non-first tracked op — the
776
+ // detection below cannot depend on the deferred branch, which never runs fn synchronously.
777
+ let o = observer,
778
+ self: Computed<unknown> = makeComputed(fn, true),
779
+ value = self.value;
780
+
781
+ if (isPromise(value) || (value != null && typeof (value as { [Symbol.asyncIterator]?: unknown })[Symbol.asyncIterator] === 'function')) {
782
+ // Built untracked: makeAsyncComputed's polling effect + wrapper take makeComputed's
783
+ // observer-null path (the proven top-level flow) instead of entangling with the enclosing
784
+ // observer's recompute, which deadlocks the scheduler. The probe is already linked+owned by
785
+ // the parent as the factory; the wrapper needs its own teardown tied to that same scope.
786
+ self = untrack(() => makeAsyncComputed(self as Computed<Promise<unknown> | AsyncIterable<unknown>>));
787
+
788
+ if (o) {
789
+ onCleanup(() => dispose(self));
790
+ }
791
+ }
792
+
793
+ self.equals = equals as ((a: unknown, b: unknown) => boolean) | null;
794
+
795
+ return self as unknown as ComputedResult<T>;
419
796
  };
420
797
 
421
- const dispose = <T>(computed: Computed<T>) => {
422
- deleteFromHeap(computed);
798
+ // Forces a re-derivation without the dummy-signal-dependency hack. writes++ FIRST so a gv-stamped
799
+ // node cannot skip the forced re-run via update()'s clean-graph fast path; an async computed's wrapper
800
+ // redirects to its factory so the promise re-dispatches (a refetch).
801
+ computed.invalidate = <T>(c: Computed<T>): void => {
802
+ let meta = asyncMeta.get(c);
423
803
 
424
- let dep = computed.deps;
804
+ if (meta) {
805
+ computed.invalidate(meta.factory);
806
+ return;
807
+ }
425
808
 
426
- while (dep) {
427
- dep = unlink(dep);
809
+ writes++;
810
+ c.state |= STATE_DIRTY;
811
+ insertIntoHeap(c);
812
+ schedule();
813
+ };
814
+
815
+ // Teardown runs as an iterative LIFO drain, not recursion: a recursive dispose→unlink→dispose cascade
816
+ // overflows the call stack on deep chains. A dispose issued while a drain runs enqueues and returns,
817
+ // so the running drain picks it up. Field-nulling keeps the drain exactly-once, so a double dispose
818
+ // stays a no-op. The try/finally guarantees `draining` resets even if a cleanup/disposal callback throws.
819
+ const dispose = <T>(computed: Computed<T>): void => {
820
+ // A dispose issued mid-drain only enqueues; the running drain picks it up, so the first node is
821
+ // processed inline (no worklist node) and the pool is touched only for re-entrant deep cascades.
822
+ if (draining) {
823
+ disposeHead = walkPush(computed as Computed<unknown>, null, disposeHead);
824
+ return;
428
825
  }
429
826
 
430
- computed.deps = null;
827
+ draining = true;
431
828
 
432
- if (computed.cleanup) {
433
- cleanup(computed);
829
+ let node: Computed<unknown> = computed as Computed<unknown>;
830
+
831
+ try {
832
+ for (;;) {
833
+ deleteFromHeap(node);
834
+
835
+ let dep = node.deps;
836
+
837
+ while (dep) {
838
+ dep = unlink(dep);
839
+ }
840
+
841
+ node.deps = null;
842
+
843
+ if (node.cleanup) {
844
+ cleanup(node);
845
+ }
846
+
847
+ if (node.disposal) {
848
+ let d = node.disposal;
849
+
850
+ node.disposal = null;
851
+ d();
852
+ }
853
+
854
+ if (disposeHead === null) {
855
+ break;
856
+ }
857
+
858
+ node = disposeHead.computed!;
859
+ disposeHead = walkPop(disposeHead);
860
+ }
861
+ }
862
+ finally {
863
+ draining = false;
434
864
  }
435
865
  };
436
866
 
437
- const effect = <T>(fn: Computed<T>['fn']) => {
438
- let c = computed(fn);
867
+ const effect = <T>(fn: Computed<T>['fn'], apply?: (value: T, prev: T | undefined) => void) => {
868
+ let prev: T | undefined;
869
+
870
+ let c = makeComputed<T | undefined>(
871
+ apply
872
+ ? (o) => {
873
+ let v = fn(o);
874
+
875
+ untrack(() => apply(v, prev));
876
+ prev = v;
877
+
878
+ return v;
879
+ }
880
+ : fn
881
+ );
882
+
883
+ c.state |= STATE_EFFECT;
884
+
885
+ // The creation run precedes the EFFECT tag, so its failure bypasses recompute's rethrow arm.
886
+ if (c.state & STATE_ERROR) {
887
+ let err = c.error;
888
+
889
+ microtask(() => {
890
+ throw err;
891
+ });
892
+ }
439
893
 
440
894
  return () => {
441
895
  dispose(c);
442
896
  };
443
897
  };
444
898
 
899
+ // RUNNING/RESCHEDULE means a pass is already draining (or a flush is already in this call chain);
900
+ // re-entering stabilize here would corrupt heap_i, so this is a deliberate no-op.
901
+ // Loops (not a single call): a write during a pass can target a height stabilize()'s current
902
+ // pass already scanned past, which only flips stabilizer to RESCHEDULE for the *next* microtask
903
+ // rather than draining in-pass — looping here is what actually settles that tail synchronously.
904
+ const flush = (): void => {
905
+ while (stabilizer === STABILIZER_SCHEDULED) {
906
+ stabilize();
907
+ }
908
+ };
909
+
445
910
  const isComputed = (value: unknown): value is Computed<unknown> => {
446
911
  return isObject(value) && !!((value as unknown as Computed<unknown>).state & STATE_COMPUTED);
447
912
  };
@@ -472,11 +937,33 @@ const onCleanup = (fn: VoidFunction): typeof fn => {
472
937
  return fn;
473
938
  };
474
939
 
940
+ const peek = <T>(node: Signal<T> | Computed<T>): T => {
941
+ if ((node as Computed<T>).state & STATE_COMPUTED) {
942
+ if (pendingHead !== null) {
943
+ drainPending();
944
+ }
945
+
946
+ pull(node as Computed<T>);
947
+ }
948
+
949
+ if ((node as Computed<T>).state & STATE_ERROR) {
950
+ throw (node as Computed<T>).error;
951
+ }
952
+
953
+ return node.value;
954
+ };
955
+
475
956
  const read = <T>(node: Signal<T> | Computed<T>): T => {
476
957
  if (observer) {
477
958
  link(node, observer);
478
959
 
479
960
  if ((node as Computed<unknown>).state & STATE_COMPUTED) {
961
+ // Invariant 1: a tracked mid-cycle read must see pending writes — drain so the heap
962
+ // and this node's notify bits reflect them before the broadcast condition is read
963
+ if (pendingHead !== null) {
964
+ drainPending();
965
+ }
966
+
480
967
  let height = (node as Computed<T>).height;
481
968
 
482
969
  if (height >= observer.height) {
@@ -484,21 +971,15 @@ const read = <T>(node: Signal<T> | Computed<T>): T => {
484
971
  }
485
972
 
486
973
  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>);
974
+ pull(node as Computed<T>);
498
975
  }
499
976
  }
500
977
  }
501
978
 
979
+ if ((node as Computed<T>).state & STATE_ERROR) {
980
+ throw (node as Computed<T>).error;
981
+ }
982
+
502
983
  return node.value;
503
984
  };
504
985
 
@@ -536,8 +1017,15 @@ const root = <T>(fn: ((dispose: VoidFunction) => T) | (() => T)) => {
536
1017
 
537
1018
  root.disposables = 0;
538
1019
 
539
- const signal = <T>(value: T): Signal<T> => {
1020
+ const signal = <T>(value: T, equals: ((a: T, b: T) => boolean) | null = null): Signal<T> => {
540
1021
  return {
1022
+ equals: equals as ((a: unknown, b: unknown) => boolean) | null,
1023
+ key: undefined,
1024
+ keys: null,
1025
+ nextPending: null,
1026
+ parent: undefined,
1027
+ rv: 0,
1028
+ state: 0,
541
1029
  subs: null,
542
1030
  subsTail: null,
543
1031
  type: SIGNAL,
@@ -545,12 +1033,73 @@ const signal = <T>(value: T): Signal<T> => {
545
1033
  };
546
1034
  };
547
1035
 
1036
+ // SameValueZero (Map) key lookup: NaN matches itself, ±0 collapse — slightly wider than === for those.
1037
+ // Object keys compare by reference and must be stable. The initial snapshot below uses ===.
1038
+ signal.selector = <T>(node: Signal<T>, key: T): boolean => {
1039
+ if (!observer) {
1040
+ return node.value === key;
1041
+ }
1042
+
1043
+ let keys = (node.keys ??= new Map()),
1044
+ entry = keys.get(key);
1045
+
1046
+ if (entry === undefined) {
1047
+ keys.set(key, entry = {
1048
+ equals: null,
1049
+ key,
1050
+ keys: null,
1051
+ nextPending: null,
1052
+ parent: node,
1053
+ rv: 0,
1054
+ state: 0,
1055
+ subs: null,
1056
+ subsTail: null,
1057
+ type: SIGNAL,
1058
+ value: node.value === key
1059
+ });
1060
+ }
1061
+
1062
+ return read(entry);
1063
+ };
1064
+
1065
+ const untrack = <T>(fn: () => T): T => {
1066
+ let o = observer;
1067
+
1068
+ observer = null;
1069
+
1070
+ try {
1071
+ return fn();
1072
+ }
1073
+ finally {
1074
+ observer = o;
1075
+ }
1076
+ };
1077
+
548
1078
  const write = <T>(signal: Signal<T>, value: T) => {
549
- if (signal.value === value) {
1079
+ let prev = signal.value;
1080
+
1081
+ if (signal.equals === null ? prev === value : signal.equals(prev, value)) {
550
1082
  return;
551
1083
  }
552
1084
 
553
1085
  signal.value = value;
1086
+ writes++;
1087
+
1088
+ // Per-key fan-out: only the leaving (prev) and entering (value) entries flip — O(2), key-count independent.
1089
+ // Runs before the subs === null exit: a parent may carry per-key subscribers yet zero direct subs.
1090
+ if (signal.keys !== null) {
1091
+ let entry = signal.keys.get(prev);
1092
+
1093
+ if (entry !== undefined) {
1094
+ write(entry, false);
1095
+ }
1096
+
1097
+ entry = signal.keys.get(value);
1098
+
1099
+ if (entry !== undefined) {
1100
+ write(entry, true);
1101
+ }
1102
+ }
554
1103
 
555
1104
  if (signal.subs === null) {
556
1105
  return;
@@ -558,8 +1107,10 @@ const write = <T>(signal: Signal<T>, value: T) => {
558
1107
 
559
1108
  notified = false;
560
1109
 
561
- for (let link: Link | null = signal.subs; link; link = link.nextSub) {
562
- insertIntoHeap(link.sub);
1110
+ // O(1) deferred mark: queue the signal (self-link = tail) unless already queued; fan-out defers to drain
1111
+ if (signal.nextPending === null) {
1112
+ signal.nextPending = pendingHead ?? signal;
1113
+ pendingHead = signal;
563
1114
  }
564
1115
 
565
1116
  schedule();
@@ -567,13 +1118,17 @@ const write = <T>(signal: Signal<T>, value: T) => {
567
1118
 
568
1119
 
569
1120
  export {
570
- asyncComputed, computed,
1121
+ batch,
1122
+ computed,
571
1123
  dispose,
572
1124
  effect,
1125
+ flush,
573
1126
  isComputed, isSignal,
574
1127
  onCleanup,
1128
+ peek,
575
1129
  read, root,
576
1130
  signal,
1131
+ untrack,
577
1132
  write
578
1133
  };
579
1134
  export type { Computed, Signal };