@zakkster/lite-signal 1.0.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/Signal.js ADDED
@@ -0,0 +1,1007 @@
1
+ /**
2
+ * @zakkster/lite-signal
3
+ * --------------------
4
+ * Zero-GC reactive graph.
5
+ *
6
+ * Architecture: monomorphic object pool + versioned push-pull propagation
7
+ * + SMI modular arithmetic for 32-bit version-wrap safety.
8
+ *
9
+ * Performance characteristics:
10
+ * - Object pool: nodes and links are allocated from preallocated arrays. Steady-state
11
+ * operations (signal.set / computed.peek / effect re-run) perform zero allocations
12
+ * after warmup.
13
+ * - Stable read order: re-tracking dependencies in the same order yields O(1) link reuse
14
+ * via the `activeObserverCurrentDep` cursor.
15
+ * - Chaotic / randomized read order degrades to O(N) per dep (linear search of headDep
16
+ * list) — see {@link allocateLink}.
17
+ * - Computed resolution is recursive on the JS call stack. Maximum chain depth is bound
18
+ * by the engine stack limit (~10,000 frames).
19
+ * - 32-bit modular arithmetic for versioning: the engine is immune to integer-overflow
20
+ * crashes regardless of uptime.
21
+ *
22
+ * Public surface: {@link signal}, {@link computed}, {@link effect}, {@link batch},
23
+ * {@link untrack}, {@link onCleanup}, {@link stats}, {@link createRegistry},
24
+ * {@link setDefaultRegistry}, {@link CapacityError}.
25
+ */
26
+
27
+ // ─── Node flag bits ────────────────────────────────────────────────────────────
28
+ const FLAG_COMPUTED = 1 << 0;
29
+ const FLAG_EFFECT = 1 << 1;
30
+ const FLAG_QUEUED = 1 << 2;
31
+ const FLAG_COMPUTING = 1 << 3;
32
+ const FLAG_HAS_ERROR = 1 << 4;
33
+ const FLAG_SIGNAL = 1 << 5; // Identifies signals for universal disposal
34
+
35
+ /**
36
+ * Internal: a reactive node (signal, computed, or effect).
37
+ * Lives in a preallocated pool; never released to GC during normal use.
38
+ * @private
39
+ */
40
+ class ReactiveNode {
41
+ constructor() {
42
+ /** Bitmask: FLAG_COMPUTED | FLAG_EFFECT | FLAG_QUEUED | FLAG_COMPUTING | FLAG_HAS_ERROR */
43
+ this.flags = 0;
44
+ /** Current value (signal, computed) or error (when FLAG_HAS_ERROR is set). */
45
+ this.value = undefined;
46
+ /** Compute body (computed, effect). */
47
+ this.computeFn = undefined;
48
+ /** Single fn OR array of fns; cleared after invocation. */
49
+ this.cleanupFn = undefined;
50
+ /** Custom equality predicate. Defaults to Object.is. */
51
+ this.equals = undefined;
52
+ /** Optional effect scheduler. */
53
+ this.scheduler = undefined;
54
+
55
+ /** Bumped on every change that mutates value. 32-bit modular. */
56
+ this.version = 0;
57
+ /** Last globalVersion at which this node was re-evaluated. */
58
+ this.evalVersion = 0;
59
+ /** Last globalVersion at which this node was marked dirty (de-duplicates traversal). */
60
+ this.markEpoch = 0;
61
+ /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
62
+ this.gen = 0;
63
+
64
+ // Doubly-linked dependency list (this node depends on these sources).
65
+ this.headDep = null;
66
+ this.tailDep = null;
67
+ /** Cursor pointing into headDep during re-tracking. */
68
+ this.currentDep = null;
69
+ // Doubly-linked subscriber list (these targets depend on this node).
70
+ this.headSub = null;
71
+
72
+ // Pool free-list pointer.
73
+ this.nextFree = null;
74
+ }
75
+ }
76
+
77
+ /**
78
+ * Internal: a directed edge between a source node and a target node.
79
+ * Pool-allocated, never GC'd.
80
+ * @private
81
+ */
82
+ class ReactiveLink {
83
+ constructor() {
84
+ this.source = null;
85
+ this.target = null;
86
+
87
+ this.prevDep = null;
88
+ this.nextDep = null;
89
+ this.prevSub = null;
90
+ this.nextSub = null;
91
+
92
+ this.nextFree = null;
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Thrown when the registry would need to grow beyond its hard ceiling
98
+ * (or {@link RegistryConfig.onCapacityExceeded} is `"throw"` and the pool is full).
99
+ */
100
+ export class CapacityError extends Error {
101
+ /**
102
+ * @param {"nodes"|"links"} kind Which pool was exhausted.
103
+ * @param {number} capacity Capacity at the time of the error.
104
+ */
105
+ constructor(kind, capacity) {
106
+ super(`CapacityError: ${kind} capacity (${capacity}) exceeded.`);
107
+ this.name = "CapacityError";
108
+ /** @type {"nodes"|"links"} */
109
+ this.kind = kind;
110
+ /** @type {number} */
111
+ this.capacity = capacity;
112
+ }
113
+ }
114
+
115
+ /**
116
+ * Create an isolated reactive registry.
117
+ *
118
+ * Use this when you need multiple independent reactive graphs (e.g. one per
119
+ * Twitch Extension viewer, one per worker, one per test).
120
+ *
121
+ * @param {object} [config]
122
+ * @param {number} [config.maxNodes=1024] Initial node-pool capacity.
123
+ * @param {number} [config.maxLinks=maxNodes*4] Initial link-pool capacity.
124
+ * @param {"throw"|"grow"} [config.onCapacityExceeded="throw"]
125
+ * `"throw"` fails fast when pools are full.
126
+ * `"grow"` doubles the pool (bounded by `maxLinks * 16` for links).
127
+ * @param {number} [config.maxFlushPasses=100] Cycle-protection: max effect-queue
128
+ * drain passes before throwing CycleError.
129
+ * @returns {Registry}
130
+ */
131
+ export function createRegistry(config = {}) {
132
+ // Per-registry symbols. NODE_PTR carries a direct pool-slot reference;
133
+ // NODE_GEN stamps the slot's generation at the moment of API creation
134
+ // so dispose() can detect stale handles after the slot has been recycled.
135
+ const NODE_PTR = Symbol("node_ptr");
136
+ const NODE_GEN = Symbol("node_gen");
137
+
138
+ let currentNodesCapacity = config.maxNodes || 1024;
139
+ let currentLinkCapacity = config.maxLinks || currentNodesCapacity * 4;
140
+ const policy = config.onCapacityExceeded || "throw";
141
+ const maxFlushPasses = config.maxFlushPasses || 100;
142
+ const maxLinkLimit = currentLinkCapacity * 16;
143
+
144
+ // --- ZERO-GC OBJECT POOLS ---
145
+ const nodePool = [];
146
+ for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
147
+ let freeNodeHead = nodePool[0];
148
+ for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
149
+
150
+ const linkPool = [];
151
+ for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
152
+ let freeLinkHead = linkPool[0];
153
+ for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
154
+
155
+ let activeNodes = 0 | 0;
156
+ let activeLinks = 0 | 0;
157
+ let statSignals = 0 | 0;
158
+ let statComputeds = 0 | 0;
159
+ let statEffects = 0 | 0;
160
+
161
+ // --- QUEUES & STACKS (Monomorphic arrays) ---
162
+ const effectQueueA = [];
163
+ const effectQueueB = [];
164
+ const markStack = [];
165
+ for (let i = 0; i < currentNodesCapacity; i++) {
166
+ effectQueueA[i] = null;
167
+ effectQueueB[i] = null;
168
+ markStack[i] = null;
169
+ }
170
+
171
+ let activeQueue = effectQueueA;
172
+ let activeQueueLen = 0 | 0;
173
+ let isQueueA = true;
174
+
175
+ // --- GLOBAL STATE ---
176
+ let globalVersion = 1 | 0; // Forced 32-bit SMI
177
+ let currentObserver = null;
178
+ let activeObserverCurrentDep = null;
179
+ let batchDepth = 0 | 0;
180
+ let isTrackingDeps = false;
181
+ let isFlushing = false;
182
+
183
+ // --- ALLOCATORS ---
184
+
185
+ /**
186
+ * Establish (or reuse) a dependency link from `source` → `target`.
187
+ *
188
+ * Fast path: cursor match (re-tracking same dep at same position) — O(1), no allocation.
189
+ * Slow path: linear search of target.headDep for an existing link — O(N) in N deps.
190
+ * Cold path: pool exhausted → grow or throw per policy.
191
+ *
192
+ * @private
193
+ */
194
+ function allocateLink(source, target) {
195
+ let expected = activeObserverCurrentDep;
196
+ if (expected !== null && expected.source === source) {
197
+ activeObserverCurrentDep = expected.nextDep;
198
+ return expected;
199
+ }
200
+
201
+ let existing = target.headDep;
202
+ let found = null;
203
+ while (existing !== null) {
204
+ if (existing.source === source) {
205
+ found = existing;
206
+ break;
207
+ }
208
+ existing = existing.nextDep;
209
+ }
210
+
211
+ let link;
212
+ if (found !== null) {
213
+ link = found;
214
+ let p = link.prevDep;
215
+ let n = link.nextDep;
216
+ if (p !== null) p.nextDep = n; else target.headDep = n;
217
+ if (n !== null) n.prevDep = p; else target.tailDep = p;
218
+ } else {
219
+ if (freeLinkHead === null) {
220
+ if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
221
+ const newCap = currentLinkCapacity * 2;
222
+ if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
223
+
224
+ const newLinks = new Array(newCap - currentLinkCapacity);
225
+ for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
226
+ for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
227
+
228
+ const startIdx = linkPool.length;
229
+ linkPool.length = newCap;
230
+
231
+ for (let i = 0; i < newLinks.length; i++) {
232
+ linkPool[startIdx + i] = newLinks[i];
233
+ }
234
+
235
+ freeLinkHead = newLinks[0];
236
+ currentLinkCapacity = newCap;
237
+ }
238
+
239
+ link = freeLinkHead;
240
+ freeLinkHead = link.nextFree;
241
+ link.nextFree = null;
242
+ activeLinks = (activeLinks + 1) | 0;
243
+
244
+ link.source = source;
245
+ link.target = target;
246
+
247
+ link.prevSub = null;
248
+ link.nextSub = source.headSub;
249
+ if (source.headSub !== null) source.headSub.prevSub = link;
250
+ source.headSub = link;
251
+ }
252
+
253
+ link.nextDep = expected;
254
+ if (expected !== null) {
255
+ let p = expected.prevDep;
256
+ link.prevDep = p;
257
+ expected.prevDep = link;
258
+ if (p !== null) p.nextDep = link; else target.headDep = link;
259
+ } else {
260
+ let tail = target.tailDep;
261
+ link.prevDep = tail;
262
+ if (tail !== null) tail.nextDep = link; else target.headDep = link;
263
+ target.tailDep = link;
264
+ }
265
+
266
+ return link;
267
+ }
268
+
269
+ /** Return a link to the free pool and unlink it from the source's sub list. @private */
270
+ function freeLink(link, target, source) {
271
+ const pSub = link.prevSub;
272
+ const nSub = link.nextSub;
273
+ if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
274
+ if (nSub !== null) nSub.prevSub = pSub;
275
+
276
+ link.source = null;
277
+ link.target = null;
278
+ link.prevDep = null;
279
+ link.nextDep = null;
280
+ link.prevSub = null;
281
+ link.nextSub = null;
282
+
283
+ link.nextFree = freeLinkHead;
284
+ freeLinkHead = link;
285
+ activeLinks = (activeLinks - 1) | 0;
286
+ }
287
+
288
+ // --- MANUAL MEMORY MANAGEMENT ---
289
+
290
+ function disposeNode(node) {
291
+ if (node.flags === 0) return; // Already freed
292
+
293
+ runCleanup(node);
294
+
295
+ // 1. Unlink from dependencies
296
+ let dLink = node.headDep;
297
+ while (dLink !== null) {
298
+ const next = dLink.nextDep;
299
+ freeLink(dLink, node, dLink.source);
300
+ dLink = next;
301
+ }
302
+
303
+ // 2. Unlink from subscribers
304
+ let sLink = node.headSub;
305
+ while (sLink !== null) {
306
+ const target = sLink.target;
307
+ const next = sLink.nextSub;
308
+
309
+ const pDep = sLink.prevDep;
310
+ const nDep = sLink.nextDep;
311
+ if (pDep !== null) pDep.nextDep = nDep; else target.headDep = nDep;
312
+ if (nDep !== null) nDep.prevDep = pDep; else target.tailDep = pDep;
313
+
314
+ sLink.source = null;
315
+ sLink.target = null;
316
+ sLink.prevDep = null;
317
+ sLink.nextDep = null;
318
+ sLink.prevSub = null;
319
+ sLink.nextSub = null;
320
+ sLink.nextFree = freeLinkHead;
321
+ freeLinkHead = sLink;
322
+ activeLinks = (activeLinks - 1) | 0;
323
+
324
+ sLink = next;
325
+ }
326
+
327
+ // 3. Clear node state and return to pool
328
+ node.computeFn = undefined;
329
+ node.cleanupFn = undefined;
330
+ node.scheduler = undefined;
331
+ node.value = undefined;
332
+ node.equals = undefined;
333
+ node.flags = 0;
334
+ node.headDep = null;
335
+ node.tailDep = null;
336
+ node.currentDep = null;
337
+ node.headSub = null;
338
+
339
+ node.gen = (node.gen + 1) | 0;
340
+ node.nextFree = freeNodeHead;
341
+ freeNodeHead = node;
342
+ activeNodes = (activeNodes - 1) | 0;
343
+ }
344
+
345
+ /**
346
+ * Claim a node from the free pool, reinitialise, and return it.
347
+ * Grows pool per `policy` if exhausted.
348
+ * @private
349
+ */
350
+ function createNode(value, flags) {
351
+ if (freeNodeHead === null) {
352
+ if (policy === "throw") throw new CapacityError("nodes", currentNodesCapacity);
353
+ const newCap = currentNodesCapacity * 2;
354
+
355
+ const newNodes = new Array(newCap - currentNodesCapacity);
356
+ for (let i = 0; i < newNodes.length; i++) newNodes[i] = new ReactiveNode();
357
+ for (let i = 0; i < newNodes.length - 1; i++) newNodes[i].nextFree = newNodes[i + 1];
358
+
359
+ const startIdx = nodePool.length;
360
+ nodePool.length = newCap; // Pre-allocate the new length
361
+ for (let i = 0; i < newNodes.length; i++) {
362
+ nodePool[startIdx + i] = newNodes[i];
363
+ }
364
+
365
+ freeNodeHead = newNodes[0];
366
+
367
+ effectQueueA.length = newCap;
368
+ effectQueueB.length = newCap;
369
+ markStack.length = newCap;
370
+
371
+ currentNodesCapacity = newCap;
372
+ }
373
+
374
+ const node = freeNodeHead;
375
+ freeNodeHead = node.nextFree;
376
+ node.nextFree = null;
377
+ activeNodes = (activeNodes + 1) | 0;
378
+
379
+ node.value = value;
380
+ node.flags = flags | 0;
381
+ node.headDep = null;
382
+ node.tailDep = null;
383
+ node.currentDep = null;
384
+ node.headSub = null;
385
+ node.version = 0;
386
+ node.evalVersion = 0;
387
+ node.markEpoch = 0;
388
+ return node;
389
+ }
390
+
391
+ /** Invoke registered cleanup function(s) on `node` and clear. @private */
392
+ function runCleanup(node) {
393
+ const cleanup = node.cleanupFn;
394
+ if (cleanup) {
395
+ if (typeof cleanup === "function") cleanup();
396
+ else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
397
+ node.cleanupFn = undefined;
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Mark all transitive subscribers of `startNode` dirty.
403
+ * Iterative DFS via the markStack to avoid call-stack growth.
404
+ * Effects are enqueued for the flush phase; computeds are merely marked.
405
+ * @private
406
+ */
407
+ function markDownstream(startNode) {
408
+ let stackLen = 0 | 0;
409
+ markStack[stackLen] = startNode;
410
+ stackLen = (stackLen + 1) | 0;
411
+
412
+ while (stackLen > 0) {
413
+ stackLen = (stackLen - 1) | 0;
414
+ const n = markStack[stackLen];
415
+
416
+ let link = n.headSub;
417
+ while (link !== null) {
418
+ const t = link.target;
419
+ if ((t.markEpoch | 0) !== (globalVersion | 0)) {
420
+ t.markEpoch = globalVersion | 0;
421
+ const flags = t.flags | 0;
422
+
423
+ if ((flags & FLAG_EFFECT) !== 0) {
424
+ if ((flags & FLAG_QUEUED) === 0) {
425
+ t.flags = flags | FLAG_QUEUED;
426
+ activeQueue[activeQueueLen] = t;
427
+ activeQueueLen = (activeQueueLen + 1) | 0;
428
+ }
429
+ } else {
430
+ markStack[stackLen] = t;
431
+ stackLen = (stackLen + 1) | 0;
432
+ }
433
+ }
434
+ link = link.nextSub;
435
+ }
436
+ }
437
+ }
438
+
439
+ /**
440
+ * Execute an effect through a scheduler trampoline. Guards against running
441
+ * a stale node (disposed and recycled before the scheduler fired).
442
+ * @private
443
+ */
444
+ function safeExecute(node, gen) {
445
+ if ((node.gen | 0) !== (gen | 0)) return;
446
+ if ((node.flags & FLAG_EFFECT) === 0) return;
447
+ executeEffect(node);
448
+ }
449
+
450
+ /**
451
+ * Drain the effect queue. Double-buffered so new effects scheduled mid-flush
452
+ * end up in the next pass.
453
+ * @private
454
+ */
455
+ function flushEffects() {
456
+ if (isFlushing) return;
457
+ isFlushing = true;
458
+ let passes = 0 | 0;
459
+
460
+ while (activeQueueLen > 0) {
461
+ passes = (passes + 1) | 0;
462
+ if (passes > maxFlushPasses) {
463
+ isFlushing = false;
464
+ throw new Error("CycleError: flush passes exceeded");
465
+ }
466
+
467
+ const toRun = activeQueueLen | 0;
468
+ const currentQueue = activeQueue;
469
+
470
+ isQueueA = !isQueueA;
471
+ activeQueue = isQueueA ? effectQueueA : effectQueueB;
472
+ activeQueueLen = 0 | 0;
473
+
474
+ for (let i = 0; i < toRun; i++) {
475
+ const node = currentQueue[i];
476
+ const scheduler = node.scheduler;
477
+ if (scheduler) {
478
+ const gen = node.gen | 0;
479
+ scheduler(() => safeExecute(node, gen));
480
+ } else {
481
+ executeEffect(node);
482
+ }
483
+ }
484
+ }
485
+ isFlushing = false;
486
+ }
487
+
488
+ /**
489
+ * Free any tail links not visited during the current re-tracking pass.
490
+ * Called after computeFn returns: anything still hanging off the cursor is stale.
491
+ * @private
492
+ */
493
+ function severTail(node) {
494
+ let stale = activeObserverCurrentDep;
495
+ if (stale !== null) {
496
+ let prev = stale.prevDep;
497
+ if (prev !== null) prev.nextDep = null; else node.headDep = null;
498
+ node.tailDep = prev;
499
+
500
+ while (stale !== null) {
501
+ let next = stale.nextDep;
502
+ freeLink(stale, node, stale.source);
503
+ stale = next;
504
+ }
505
+ }
506
+ }
507
+
508
+ /**
509
+ * Run an effect's compute body, re-tracking dependencies.
510
+ * Short-circuits if no dependency has bumped its version since last eval.
511
+ * @private
512
+ */
513
+ function executeEffect(node) {
514
+ if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Infinite effect loop detected.");
515
+
516
+ const isFirst = node.evalVersion === 0;
517
+
518
+ if (!isFirst) {
519
+ let link = node.headDep;
520
+ const evalVer = node.evalVersion | 0;
521
+ let needsRun = false;
522
+ while (link !== null) {
523
+ const dep = link.source;
524
+ if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
525
+
526
+ // Overflow-safe modular arithmetic version check
527
+ if (((dep.version - evalVer) | 0) > 0) {
528
+ needsRun = true;
529
+ break;
530
+ }
531
+ link = link.nextDep;
532
+ }
533
+ if (!needsRun) {
534
+ node.flags = node.flags & ~FLAG_QUEUED;
535
+ node.evalVersion = globalVersion | 0;
536
+ return;
537
+ }
538
+ }
539
+
540
+ node.flags = (node.flags & ~FLAG_QUEUED) | FLAG_COMPUTING;
541
+
542
+ runCleanup(node);
543
+
544
+ const prevObserver = currentObserver;
545
+ const prevActiveDep = activeObserverCurrentDep;
546
+ const prevTracking = isTrackingDeps;
547
+
548
+ currentObserver = node;
549
+ activeObserverCurrentDep = node.headDep;
550
+ isTrackingDeps = true;
551
+
552
+ try {
553
+ node.computeFn();
554
+ } finally {
555
+ severTail(node);
556
+ node.currentDep = activeObserverCurrentDep;
557
+
558
+ currentObserver = prevObserver;
559
+ activeObserverCurrentDep = prevActiveDep;
560
+ isTrackingDeps = prevTracking;
561
+
562
+ node.flags = node.flags & ~FLAG_COMPUTING;
563
+ node.evalVersion = globalVersion | 0;
564
+ }
565
+ }
566
+
567
+ /**
568
+ * Resolve a computed node's current value: re-run if a dependency has
569
+ * changed since last evaluation, else return cached value.
570
+ *
571
+ * Errors thrown by computeFn are captured in `node.value` with FLAG_HAS_ERROR;
572
+ * subsequent reads re-throw until a dependency change re-runs computeFn.
573
+ *
574
+ * @private
575
+ */
576
+ function pullComputed(node) {
577
+ if ((node.evalVersion | 0) === (globalVersion | 0)) {
578
+ if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
579
+ return node.value;
580
+ }
581
+
582
+ let shouldRun = node.evalVersion === 0;
583
+ if (!shouldRun) {
584
+ let link = node.headDep;
585
+ const evalVer = node.evalVersion | 0;
586
+ while (link !== null) {
587
+ const dep = link.source;
588
+ if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
589
+ // Modular Arithmetic 32-bit Wrap Check
590
+ if (((dep.version - evalVer) | 0) > 0) {
591
+ shouldRun = true;
592
+ break;
593
+ }
594
+ link = link.nextDep;
595
+ }
596
+ }
597
+
598
+ if (shouldRun) {
599
+ if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
600
+ node.flags = node.flags | FLAG_COMPUTING;
601
+
602
+ // Run cleanups registered during the previous compute pass before re-tracking.
603
+ // Mirrors effect semantics so `onCleanup` works in both kinds of observer.
604
+ runCleanup(node);
605
+
606
+ const prevObserver = currentObserver;
607
+ const prevActiveDep = activeObserverCurrentDep;
608
+ const prevTracking = isTrackingDeps;
609
+
610
+ currentObserver = node;
611
+ activeObserverCurrentDep = node.headDep;
612
+ isTrackingDeps = true;
613
+
614
+ try {
615
+ const newValue = node.computeFn();
616
+ const eq = node.equals;
617
+ if (node.evalVersion === 0 || !eq || !eq(node.value, newValue)) {
618
+ node.value = newValue;
619
+ node.version = globalVersion | 0;
620
+ }
621
+ node.flags = node.flags & ~FLAG_HAS_ERROR;
622
+ } catch (err) {
623
+ node.value = err;
624
+ node.flags = node.flags | FLAG_HAS_ERROR;
625
+ node.version = globalVersion | 0;
626
+ } finally {
627
+ severTail(node);
628
+ node.currentDep = activeObserverCurrentDep;
629
+
630
+ currentObserver = prevObserver;
631
+ activeObserverCurrentDep = prevActiveDep;
632
+ isTrackingDeps = prevTracking;
633
+
634
+ node.flags = node.flags & ~FLAG_COMPUTING;
635
+ }
636
+ }
637
+
638
+ node.evalVersion = globalVersion | 0;
639
+ if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
640
+ return node.value;
641
+ }
642
+
643
+ // --- PUBLIC API SURFACE ---
644
+
645
+ /**
646
+ * Create a reactive signal.
647
+ *
648
+ * @template T
649
+ * @param {T} initial Initial value.
650
+ * @param {object} [opts]
651
+ * @param {(a:T,b:T)=>boolean} [opts.equals=Object.is]
652
+ * Equality predicate. Returning true short-circuits notification.
653
+ * @returns {Signal<T>}
654
+ */
655
+ function signal(initial, opts = {}) {
656
+ const node = createNode(initial, FLAG_SIGNAL);
657
+ node.equals = opts.equals !== undefined ? opts.equals : Object.is;
658
+ node.version = globalVersion | 0;
659
+ statSignals = (statSignals + 1) | 0;
660
+
661
+ const read = () => {
662
+ if (isTrackingDeps && currentObserver !== null) allocateLink(node, currentObserver);
663
+ return node.value;
664
+ };
665
+ read.peek = () => node.value;
666
+ read.set = (value) => {
667
+ const eq = node.equals;
668
+ if (eq && eq(node.value, value)) return;
669
+ node.value = value;
670
+ globalVersion = (globalVersion + 1) | 0;
671
+ node.version = globalVersion | 0;
672
+ markDownstream(node);
673
+ if (batchDepth === 0) flushEffects();
674
+ };
675
+ read.update = (fn) => read.set(fn(node.value));
676
+
677
+ read.subscribe = (fn) => {
678
+ let captured;
679
+ const invokeSub = () => fn(captured);
680
+ return effect(() => {
681
+ captured = read();
682
+ untrack(invokeSub);
683
+ });
684
+ };
685
+
686
+ // Secret pointer for safe, isolated disposal without allocating closures
687
+ read[NODE_PTR] = node;
688
+ read[NODE_GEN] = node.gen | 0;
689
+ return read;
690
+ }
691
+
692
+ /**
693
+ * Create a memoised, lazy derived value. The compute body only runs when a
694
+ * downstream observer reads it AND a dependency has changed since the last
695
+ * read.
696
+ *
697
+ * @template T
698
+ * @param {() => T} fn Compute body.
699
+ * @param {object} [opts]
700
+ * @param {(a:T,b:T)=>boolean} [opts.equals=Object.is]
701
+ * Equality predicate. Returning true blocks propagation downstream.
702
+ * @returns {Computed<T>}
703
+ */
704
+ function computed(fn, opts = {}) {
705
+ const node = createNode(undefined, FLAG_COMPUTED);
706
+ node.computeFn = fn;
707
+ node.equals = opts.equals !== undefined ? opts.equals : Object.is;
708
+ statComputeds = (statComputeds + 1) | 0;
709
+
710
+ const read = () => {
711
+ if (isTrackingDeps && currentObserver !== null) allocateLink(node, currentObserver);
712
+ return pullComputed(node);
713
+ };
714
+ read.peek = () => pullComputed(node);
715
+
716
+ read.subscribe = (fn) => {
717
+ let captured;
718
+ const invokeSub = () => fn(captured);
719
+ return effect(() => {
720
+ captured = read();
721
+ untrack(invokeSub);
722
+ });
723
+ };
724
+
725
+ read[NODE_PTR] = node;
726
+ read[NODE_GEN] = node.gen | 0;
727
+ return read;
728
+ }
729
+
730
+ /**
731
+ * Create an eagerly-run side effect that re-executes whenever its tracked
732
+ * dependencies change.
733
+ *
734
+ * Errors thrown by the effect body propagate to the caller of `set()` (or
735
+ * to the scheduler trampoline). The effect's dependency state is fully
736
+ * restored before the error propagates.
737
+ *
738
+ * @param {() => void} fn Effect body.
739
+ * @param {object} [opts]
740
+ * @param {(run:()=>void)=>void} [opts.scheduler]
741
+ * Optional trampoline (e.g. queueMicrotask, requestAnimationFrame).
742
+ * Receives a `run` callback that the scheduler must eventually invoke.
743
+ * @returns {() => void} Dispose function. Idempotent. Safe to call
744
+ * after registry.destroy().
745
+ */
746
+ function effect(fn, opts = {}) {
747
+ const node = createNode(undefined, FLAG_EFFECT);
748
+ node.computeFn = fn;
749
+ node.scheduler = opts.scheduler;
750
+ statEffects = (statEffects + 1) | 0;
751
+
752
+ const scheduler = opts.scheduler;
753
+ let firstRunError = null;
754
+ if (scheduler) {
755
+ const gen = node.gen | 0;
756
+ scheduler(() => safeExecute(node, gen));
757
+ } else {
758
+ try {
759
+ executeEffect(node);
760
+ } catch (err) {
761
+ // First-run failure: dispose the node so we don't leak a half-initialised
762
+ // effect in the registry. Propagate the error to the caller.
763
+ firstRunError = err;
764
+ }
765
+ }
766
+
767
+ let disposed = false;
768
+ const birthGen = node.gen | 0;
769
+ const disposeFn = function dispose() {
770
+ if (disposed) return;
771
+ disposed = true;
772
+ // Generation guard: if destroy() (or a future direct disposal)
773
+ // recycled this slot to a different node, the stale closure must
774
+ // NOT operate on it — without this check, the closure would call
775
+ // disposeNode() on whatever now lives in the slot and desync
776
+ // statEffects (it would decrement even though the node is no
777
+ // longer an effect). Mirrors the NODE_GEN stamp on signals/computeds.
778
+ if ((node.gen | 0) !== birthGen) return;
779
+ if (node.flags !== 0) {
780
+ disposeNode(node);
781
+ statEffects = (statEffects - 1) | 0;
782
+ }
783
+ };
784
+
785
+ if (firstRunError !== null) {
786
+ disposeFn();
787
+ throw firstRunError;
788
+ }
789
+ return disposeFn;
790
+ }
791
+
792
+ function dispose(api) {
793
+ // Safe read: foreign APIs or effects return undefined for NODE_PTR.
794
+ const node = api?.[NODE_PTR];
795
+
796
+ if (!node) {
797
+ // Plain functions self-execute (effect dispose handles and the
798
+ // dispose returned by .subscribe()). BUT we must not invoke a
799
+ // FOREIGN signal/computed here — they are also functions, and
800
+ // calling one would (1) read the value if untracked, or worse,
801
+ // (2) cross-link the foreign node into our observer if called
802
+ // inside a tracking context. Duck-type on `.peek`: every reactive
803
+ // primitive carries it; plain dispose handles do not.
804
+ if (typeof api === "function" && typeof api.peek !== "function") api();
805
+ return;
806
+ }
807
+
808
+ // Generation guard: if the slot has been recycled since this handle
809
+ // was issued, the handle is stale — silently no-op. Without this,
810
+ // a second dispose() call after the slot has been reallocated to a
811
+ // different signal/computed would free the new occupant.
812
+ const stamp = api[NODE_GEN] | 0;
813
+ if (stamp !== (node.gen | 0)) return;
814
+
815
+ if (node.flags !== 0) {
816
+ const isSig = (node.flags & FLAG_SIGNAL) !== 0;
817
+ const isComp = (node.flags & FLAG_COMPUTED) !== 0;
818
+
819
+ disposeNode(node);
820
+
821
+ if (isSig) statSignals = (statSignals - 1) | 0;
822
+ if (isComp) statComputeds = (statComputeds - 1) | 0;
823
+ }
824
+ }
825
+
826
+ /**
827
+ * Coalesce multiple synchronous writes into a single effect-flush pass.
828
+ * Nested batches are merged.
829
+ *
830
+ * @template T
831
+ * @param {() => T} fn
832
+ * @returns {T}
833
+ */
834
+ function batch(fn) {
835
+ batchDepth = (batchDepth + 1) | 0;
836
+ try {
837
+ return fn();
838
+ } finally {
839
+ batchDepth = (batchDepth - 1) | 0;
840
+ if (batchDepth === 0) flushEffects();
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Run `fn` without recording any signal/computed reads as dependencies.
846
+ * Useful inside effects to peek at signals you don't want to react to.
847
+ *
848
+ * @template T
849
+ * @param {() => T} fn
850
+ * @returns {T}
851
+ */
852
+ function untrack(fn) {
853
+ const prev = isTrackingDeps;
854
+ isTrackingDeps = false;
855
+ try {
856
+ return fn();
857
+ } finally {
858
+ isTrackingDeps = prev;
859
+ }
860
+ }
861
+
862
+ /**
863
+ * Register a function to run when the enclosing effect re-runs or is disposed.
864
+ *
865
+ * No-op if called outside an effect / computed body.
866
+ *
867
+ * @param {() => void} fn
868
+ */
869
+ function onCleanup(fn) {
870
+ if (currentObserver !== null) {
871
+ const existing = currentObserver.cleanupFn;
872
+ if (existing === undefined) currentObserver.cleanupFn = fn;
873
+ else if (typeof existing === "function") currentObserver.cleanupFn = [existing, fn];
874
+ else existing.push(fn);
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Snapshot of registry counters. Useful for diagnostics and tests.
880
+ * @returns {RegistryStats}
881
+ */
882
+ function stats() {
883
+ return {
884
+ signals: statSignals,
885
+ computeds: statComputeds,
886
+ effects: statEffects,
887
+ activeLinks,
888
+ pooledLinks: currentLinkCapacity - activeLinks,
889
+ linkPoolCapacity: currentLinkCapacity,
890
+ nodePoolCapacity: currentNodesCapacity,
891
+ activeNodes
892
+ };
893
+ }
894
+
895
+ /**
896
+ * Reset the entire registry: clear every node, every link, every queue, the
897
+ * global clock. All previously-issued read/set/dispose closures become
898
+ * no-ops (they're guarded internally — they will not corrupt the pool).
899
+ */
900
+ function destroy() {
901
+ for (let i = 0; i < currentNodesCapacity; i++) {
902
+ const n = nodePool[i];
903
+ n.value = undefined;
904
+ n.computeFn = undefined;
905
+ n.cleanupFn = undefined;
906
+ n.equals = undefined;
907
+ n.scheduler = undefined;
908
+ n.flags = 0;
909
+ n.headDep = null;
910
+ n.tailDep = null;
911
+ n.currentDep = null;
912
+ n.headSub = null;
913
+ n.version = 0;
914
+ n.evalVersion = 0;
915
+ n.markEpoch = 0;
916
+ // Bump gen so any scheduler trampolines holding a stale node ref bail.
917
+ n.gen = (n.gen + 1) | 0;
918
+ if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
919
+ }
920
+ nodePool[currentNodesCapacity - 1].nextFree = null;
921
+ freeNodeHead = nodePool[0];
922
+
923
+ for (let i = 0; i < currentLinkCapacity; i++) {
924
+ const l = linkPool[i];
925
+ l.source = null;
926
+ l.target = null;
927
+ l.prevDep = null;
928
+ l.nextDep = null;
929
+ l.prevSub = null;
930
+ l.nextSub = null;
931
+ if (i < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
932
+ }
933
+ linkPool[currentLinkCapacity - 1].nextFree = null;
934
+ freeLinkHead = linkPool[0];
935
+
936
+ activeNodes = 0 | 0;
937
+ activeLinks = 0 | 0;
938
+ activeQueueLen = 0 | 0;
939
+ isFlushing = false;
940
+ batchDepth = 0 | 0;
941
+ currentObserver = null;
942
+ activeObserverCurrentDep = null;
943
+ isTrackingDeps = false;
944
+ globalVersion = 1 | 0;
945
+ statSignals = 0 | 0;
946
+ statComputeds = 0 | 0;
947
+ statEffects = 0 | 0;
948
+ }
949
+
950
+ return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy};
951
+ }
952
+
953
+ // ─────────────────────────────────────────────────────────────────
954
+ // GLOBAL BINDINGS
955
+ // ─────────────────────────────────────────────────────────────────
956
+
957
+ let defaultRegistry = createRegistry();
958
+
959
+ /**
960
+ * Replace the registry backing the top-level {@link signal} / {@link computed} /
961
+ * {@link effect} / {@link batch} / {@link untrack} / {@link onCleanup} / {@link stats}
962
+ * exports.
963
+ *
964
+ * @param {Registry} registry
965
+ */
966
+ export function setDefaultRegistry(registry) {
967
+ defaultRegistry = registry;
968
+ }
969
+
970
+ /** @type {Registry["signal"]} */
971
+ export function signal(initial, opts) {
972
+ return defaultRegistry.signal(initial, opts);
973
+ }
974
+
975
+ /** @type {Registry["computed"]} */
976
+ export function computed(fn, opts) {
977
+ return defaultRegistry.computed(fn, opts);
978
+ }
979
+
980
+ /** @type {Registry["effect"]} */
981
+ export function effect(fn, opts) {
982
+ return defaultRegistry.effect(fn, opts);
983
+ }
984
+
985
+ export function dispose(api) {
986
+ return defaultRegistry.dispose(api);
987
+ }
988
+
989
+ /** @type {Registry["batch"]} */
990
+ export function batch(fn) {
991
+ return defaultRegistry.batch(fn);
992
+ }
993
+
994
+ /** @type {Registry["untrack"]} */
995
+ export function untrack(fn) {
996
+ return defaultRegistry.untrack(fn);
997
+ }
998
+
999
+ /** @type {Registry["onCleanup"]} */
1000
+ export function onCleanup(fn) {
1001
+ return defaultRegistry.onCleanup(fn);
1002
+ }
1003
+
1004
+ /** @type {Registry["stats"]} */
1005
+ export function stats() {
1006
+ return defaultRegistry.stats();
1007
+ }