@zakkster/lite-signal 1.2.0 → 1.2.2

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 (6) hide show
  1. package/CHANGELOG.md +331 -68
  2. package/README.md +244 -155
  3. package/Signal.d.ts +74 -20
  4. package/Signal.js +191 -85
  5. package/llms.txt +189 -66
  6. package/package.json +7 -3
package/Signal.js CHANGED
@@ -1,26 +1,23 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.2.0
2
+ * @zakkster/lite-signal v1.2.2
3
3
  * --------------------
4
- * Hybrid Doubly-Linked-List Reactive Graph Engine decoupled (Signal1_3) base
4
+ * Hybrid Doubly-Linked-List Reactive Graph Engine -- decoupled (Signal1_3) base
5
5
  * with the two 1.1.3 performance fixes ported in:
6
- * 1. pullComputed clean short-circuit (markEpoch) kills the dynamic-graph
6
+ * 1. pullComputed clean short-circuit (markEpoch) -- kills the dynamic-graph
7
7
  * regression: "large web app" 4900ms -> 665ms, "wide dense" 4472 -> 952.
8
- * 2. allocateLink: O(1) tailSub dedup replaces the O(N) prefix scan divergent
8
+ * 2. allocateLink: O(1) tailSub dedup replaces the O(N) prefix scan -- divergent
9
9
  * re-tracking is O(N) not O(N^2) (600-dep flip micro: 1373ms -> 62ms).
10
10
  * Ownership tree + L1/L2/L3 layering + observer/owner split are UNCHANGED; they
11
11
  * were never the regression. Same EDGE NOTE as 1.1.3 applies to fix (2): a nested
12
12
  * re-read of the same source can retain one bounded, dispose-reclaimed link.
13
13
  *
14
- * Original header:
15
- * v1.3.2: Hybrid Doubly-Linked-List Reactive Graph Engine.
16
- *
17
14
  * Performance model:
18
15
  * - ReactiveLink DLL object pool guarantees O(1) graph edge allocation.
19
16
  * - Inlined O(1) cursor fast-path for stable steady-state reads.
20
17
  * - Divergence triggers immediate tail-severing to bound worst-case complexity.
21
18
  * - O(1) Owner Context Tree ensures automatic teardown of nested observers.
22
19
  *
23
- * ── ARCHITECTURE: three layers + a public API, with a strict dependency direction ──
20
+ * -- ARCHITECTURE: three layers + a public API, with a strict dependency direction --
24
21
  *
25
22
  * L1 GRAPH TOPOLOGY allocateLink, freeLink, severTail
26
23
  * Owns the ReactiveLink pool and the dep/sub doubly-linked lists.
@@ -29,7 +26,7 @@
29
26
  * L2 OWNERSHIP / LIFECYCLE createNode, disposeNode, runCleanup
30
27
  * Owns the owner tree and node death + user cleanup.
31
28
  * INVARIANT: never touches the `activeObserverCurrentDep` cursor.
32
- * Sanctioned downward edge L1: disposeNode walks a dying node's own
29
+ * Sanctioned downward edge -> L1: disposeNode walks a dying node's own
33
30
  * dep/sub lists and calls freeLink to extract it from the graph.
34
31
  *
35
32
  * L3 PROPAGATION / EXECUTION markDownstream, flushEffects, executeEffect, pullComputed
@@ -37,18 +34,18 @@
37
34
  * (a pure propagation primitive). executeEffect/pullComputed are the
38
35
  * ORCHESTRATORS: they drive the cursor + severTail (L1) AND, before a
39
36
  * re-run, call runCleanup (L2) to cascade-dispose owned children.
40
- * Sanctioned upward call L2: executeEffect/pullComputed runCleanup.
37
+ * Sanctioned upward call -> L2: executeEffect/pullComputed -> runCleanup.
41
38
  *
42
39
  * API signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy
43
40
  *
44
- * The only cross-layer edges are L3runCleanup and L2freeLink. The graph of
41
+ * The only cross-layer edges are L3->runCleanup and L2->freeLink. The graph of
45
42
  * dependencies is acyclic; nothing in L1 reaches up, nothing in L2 touches
46
43
  * the cursor, and the engine is the single place the two subsystems meet.
47
44
  *
48
- * ── OWNER vs OBSERVER ──
45
+ * -- OWNER vs OBSERVER --
49
46
  * `currentObserver` = the node whose READS establish dependencies (tracking).
50
47
  * `currentOwner` = the node that OWNS anything created right now (lifecycle).
51
- * Today they move together, so behaviour is unchanged but they are distinct
48
+ * Today they move together, so behaviour is unchanged -- but they are distinct
52
49
  * pointers so future runWithOwner/createRoot can attach ownership without
53
50
  * establishing reactive dependencies (and untrack can suppress tracking
54
51
  * without orphaning created nodes). createNode and onCleanup key off the
@@ -60,6 +57,11 @@ const FLAG_EFFECT = 1 << 1;
60
57
  const FLAG_QUEUED = 1 << 2;
61
58
  const FLAG_COMPUTING = 1 << 3;
62
59
  const FLAG_HAS_ERROR = 1 << 4;
60
+
61
+ // Hoisted equality default. Object.is lookup is fast under V8 IC but a module-
62
+ // scope const is monomorphic without ICs. Replaces the per-call lookup in
63
+ // signal() and computed() construction.
64
+ const OBJECT_IS = Object.is;
63
65
  const FLAG_SIGNAL = 1 << 5;
64
66
 
65
67
  /**
@@ -110,7 +112,7 @@ class ReactiveNode {
110
112
  this.headSub = null;
111
113
  this.tailSub = null;
112
114
 
113
- // Owner Context Tree (Auto-Disposal of Nested Observers) 1.2.0.
115
+ // Owner Context Tree (Auto-Disposal of Nested Observers) -- 1.2.0.
114
116
  // An effect/computed created inside another effect/computed is "owned"
115
117
  // by it. When the owner re-runs or is disposed, owned children are
116
118
  // cascade-disposed before the new run. Plain signals are NOT adopted
@@ -168,7 +170,7 @@ export class CapacityError extends Error {
168
170
  *
169
171
  * Use this when you need multiple independent reactive graphs (e.g. one per
170
172
  * Twitch Extension viewer, one per worker, one per test). The top-level
171
- * helpers ({@link signal}, {@link effect}, ) delegate to a single shared
173
+ * helpers ({@link signal}, {@link effect}, ...) delegate to a single shared
172
174
  * default registry; call {@link setDefaultRegistry} to swap that for your own.
173
175
  *
174
176
  * @param {object} [config]
@@ -223,7 +225,7 @@ export function createRegistry(config) {
223
225
  let batchDepth = 0;
224
226
  let isTrackingDeps = false;
225
227
 
226
- // ── Node identity + observer-lifecycle introspection (ported from 1.1.5) ──
228
+ // -- Node identity + observer-lifecycle introspection (ported from 1.1.5) --
227
229
  let nodeSeq = 1 | 0;
228
230
  let lifecycleCount = 0 | 0;
229
231
  const lifecycleMap = new WeakMap();
@@ -246,19 +248,19 @@ export function createRegistry(config) {
246
248
  const flushErrorBuffer = [];
247
249
  let flushErrorCount = 0;
248
250
 
249
- // ═══ L1 · GRAPH TOPOLOGY ══════════════════════════════════════
251
+ // === L1 * GRAPH TOPOLOGY ======================================
250
252
  // Owns the ReactiveLink pool and the dep/sub lists. Pure edge mechanics:
251
- // INVARIANT must never touch node.owner / firstOwned.
253
+ // INVARIANT -- must never touch node.owner / firstOwned.
252
254
 
253
- // ─── HYBRID ALLOCATOR ─────────────────────────────────────────
255
+ // --- HYBRID ALLOCATOR -----------------------------------------
254
256
 
255
257
  /**
256
- * Establish (or reuse) a dependency link from `source` `target`.
258
+ * Establish (or reuse) a dependency link from `source` -> `target`.
257
259
  *
258
- * Fast path: cursor match (re-tracking same dep at same position) O(1), no allocation.
259
- * Mid path: O(1) tailSub dedup (1.1.4 rewrite) divergent retracking stays O(N) overall,
260
- * not O(N²).
261
- * Cold path: pool exhausted grow or throw per policy.
260
+ * Fast path: cursor match (re-tracking same dep at same position) -- O(1), no allocation.
261
+ * Mid path: O(1) tailSub dedup (1.1.4 rewrite) -- divergent retracking stays O(N) overall,
262
+ * not O(N^2).
263
+ * Cold path: pool exhausted -> grow or throw per policy.
262
264
  *
263
265
  * SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
264
266
  * BEFORE any new link is allocated, so peak link usage never exceeds steady
@@ -272,10 +274,24 @@ export function createRegistry(config) {
272
274
  *
273
275
  * @private
274
276
  */
277
+ // --- Graph-mutation hook (1.2.1 keystone prototype) ---------------------
278
+ // Single nullable listener; every fire point is `if (mutationHook !== null)`
279
+ // -- branch-predicted free when absent, allocation-free when present
280
+ // (opcode + two int args). Enables push-based devtools (watchGraph) and the
281
+ // recompute profiler. Opcodes: 1 node-create, 2 node-dispose, 3 link-add,
282
+ // 4 link-remove, 5 recompute.
283
+ let mutationHook = null;
284
+ function onGraphMutation(fn) {
285
+ if (fn !== null && typeof fn !== "function") throw new TypeError("onGraphMutation: listener must be a function or null");
286
+ const prev = mutationHook;
287
+ mutationHook = fn;
288
+ return () => { if (mutationHook === fn) mutationHook = prev; };
289
+ }
290
+
275
291
  function allocateLink(source, target) {
276
292
  // Eligibility gate (restored from 1.1.5): an observer disposed mid-run (self-dispose, or
277
293
  // an outer observer torn down while suspended) has flags cleared to 0. Linking would splice
278
- // a dead, pool-bound node back into source's subscriber list a phantom edge. Cold path only.
294
+ // a dead, pool-bound node back into source's subscriber list -- a phantom edge. Cold path only.
279
295
  if (target.flags === 0) return null;
280
296
  let expected = activeObserverCurrentDep;
281
297
 
@@ -326,7 +342,7 @@ export function createRegistry(config) {
326
342
 
327
343
  link.nextSub = null;
328
344
  link.prevSub = source.tailSub;
329
- const _was0 = lifecycleCount !== 0 && source.headSub === null; // 01 detect (pre-link)
345
+ const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0->1 detect (pre-link)
330
346
  if (source.tailSub !== null) source.tailSub.nextSub = link;
331
347
  else source.headSub = link;
332
348
  source.tailSub = link;
@@ -338,15 +354,17 @@ export function createRegistry(config) {
338
354
  if (tail !== null) tail.nextDep = link;
339
355
  else target.headDep = link;
340
356
  target.tailDep = link;
357
+ if (mutationHook !== null) mutationHook(3, source.id, target.id);
341
358
  }
342
359
 
343
360
  /** Return a link to the free pool and unlink it from the source's sub list. @private */
344
361
  function freeLink(link, target, source) {
362
+ if (mutationHook !== null) mutationHook(4, link.source !== null ? link.source.id : -1, link.target !== null ? link.target.id : -1);
345
363
  const pSub = link.prevSub;
346
364
  const nSub = link.nextSub;
347
365
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
348
366
  if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
349
- if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 10
367
+ if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 1->0
350
368
 
351
369
  link.source = null;
352
370
  link.target = null;
@@ -382,20 +400,21 @@ export function createRegistry(config) {
382
400
  }
383
401
  }
384
402
 
385
- // ═══ L2 · OWNERSHIP / LIFECYCLE ═══════════════════════════════
403
+ // === L2 * OWNERSHIP / LIFECYCLE ===============================
386
404
  // Owns the owner tree, node death, and user cleanup.
387
- // INVARIANT must never touch the activeObserverCurrentDep cursor.
388
- // Sanctioned downward edge L1: disposeNode calls freeLink to extract a
405
+ // INVARIANT -- must never touch the activeObserverCurrentDep cursor.
406
+ // Sanctioned downward edge -> L1: disposeNode calls freeLink to extract a
389
407
  // dying node from the graph.
390
408
 
391
- // ─── LIFECYCLE & OWNERSHIP ───────────────────────────────────────
409
+ // --- LIFECYCLE & OWNERSHIP ---------------------------------------
392
410
 
393
411
  function disposeNode(node) {
412
+ if (mutationHook !== null) mutationHook(2, node.id, node.flags | 0);
394
413
  if (node.flags === 0) return;
395
414
 
396
415
  // RACE WITH ACTIVE TRACKING: an effect/computed may call dispose on
397
416
  // itself from inside its own body (#141). Once we tear the node down
398
- // its dep-list, FLAG_COMPUTING, and cursor become stale immediately
417
+ // its dep-list, FLAG_COMPUTING, and cursor become stale immediately --
399
418
  // any read() that runs in the REST of the body would otherwise try to
400
419
  // hang a fresh link off a freed slot. Null the tracking state now so
401
420
  // subsequent reads in this call stack become no-ops, and let
@@ -433,7 +452,7 @@ export function createRegistry(config) {
433
452
 
434
453
  runCleanup(node);
435
454
 
436
- // CROSS-EDGE L2L1: extract this node's own edges from the graph.
455
+ // CROSS-EDGE L2->L1: extract this node's own edges from the graph.
437
456
  let dLink = node.headDep;
438
457
  while (dLink !== null) {
439
458
  const next = dLink.nextDep;
@@ -489,7 +508,7 @@ export function createRegistry(config) {
489
508
  * Claim a node from the free pool, reinitialise, and return it.
490
509
  * Grows pool per `policy` if exhausted (or throws CapacityError under "throw").
491
510
  * Adopts the new node into `currentOwner` if there is one AND the new node is
492
- * an observer (computed/effect) plain signals are not adopted (see ReactiveNode
511
+ * an observer (computed/effect) -- plain signals are not adopted (see ReactiveNode
493
512
  * comment on the owner tree).
494
513
  * @private
495
514
  */
@@ -517,57 +536,68 @@ export function createRegistry(config) {
517
536
  node.nextFree = null;
518
537
  activeNodes = (activeNodes + 1) | 0;
519
538
 
539
+ // 1.2.2: Clean free-list invariant (Andrii's recommendation).
540
+ //
541
+ // Every node leaving the pool is guaranteed-clean for the seven fields
542
+ // {headDep, tailDep, headSub, tailSub, revertEpoch, preBatchValue,
543
+ // preBatchVersion}: dispose() clears them on the recycle path, and the
544
+ // ReactiveNode constructor initializes them to the same values on the
545
+ // fresh-allocation path (pool growth at lines above). Re-writing them
546
+ // here was defense against a state that cannot exist.
547
+ //
548
+ // What stays: fields that define the new lifetime (value, flags, id,
549
+ // firstOwned, conditional owner-tree wiring) AND fields dispose does
550
+ // not touch (version, evalVersion, markEpoch -- used by the propagation
551
+ // and pull machinery, must be reset for the new lifetime).
520
552
  node.value = value;
521
553
  node.flags = flags | 0;
522
- node.headDep = null;
523
- node.tailDep = null;
524
- node.headSub = null;
525
- node.tailSub = null;
526
554
  node.version = 0;
527
555
  node.evalVersion = 0;
528
556
  node.markEpoch = 0;
529
- node.revertEpoch = 0;
530
- node.preBatchValue = undefined;
531
- node.preBatchVersion = 0;
532
557
  node.id = nodeSeq; nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (ported from 1.1.5)
533
558
 
534
- // Wire into Owner Context (lifecycle, not tracking keyed off currentOwner).
559
+ // Wire into Owner Context (lifecycle, not tracking -- keyed off currentOwner).
535
560
  // ONLY observers (computed/effect) are adopted: a re-running owner disposes
536
561
  // its nested observers (which would otherwise leak dep links), but plain
537
562
  // signals have no deps to leak, and disposing them breaks lazy-allocation
538
563
  // libraries (lite-store allocates a key's signal on first read, INSIDE the
539
- // reading computed adopting it meant that computed's next run wiped the
564
+ // reading computed -- adopting it meant that computed's next run wiped the
540
565
  // store key). Signals are therefore never owner-adopted.
541
- // firstOwned is reset unconditionally (reuse-safety: a recycled former-owner
542
- // must not carry stale children into runCleanup). prevOwned/nextOwned are
543
- // written only on the adoption path -- an unadopted node is in no owner's
544
- // firstOwned chain, so its prevOwned/nextOwned are never traversed and may
545
- // stay stale. Saves two writes per signal and per top-level computed/effect.
546
- node.firstOwned = null;
566
+ //
567
+ // 1.2.2 clean free-list invariant (extended to the owner tree):
568
+ // owner / prevOwned / firstOwned are all guaranteed-null on every node
569
+ // leaving the pool. Both teardown paths null them -- disposeNode (lines
570
+ // ~451-453) on direct dispose, runCleanup (lines ~609-615) on parent
571
+ // cascade -- and the ReactiveNode constructor inits them to null on the
572
+ // fresh-allocation path. The three former null-writes here (firstOwned,
573
+ // the adoption-path prevOwned, and the else-branch owner) were defense
574
+ // against a state that cannot exist. Only the writes that establish the
575
+ // NEW lifetime remain: owner + nextOwned + the parent's chain splice on
576
+ // the adoption path. nextOwned is written unconditionally on adoption
577
+ // (it takes the prior firstOwned, which may be non-null), so it is a
578
+ // real lifetime write, not a redundant clear.
547
579
  if (currentOwner !== null && (flags & (FLAG_COMPUTED | FLAG_EFFECT)) !== 0) {
548
580
  node.owner = currentOwner;
549
- node.prevOwned = null;
550
581
  node.nextOwned = currentOwner.firstOwned;
551
582
  if (currentOwner.firstOwned !== null) {
552
583
  currentOwner.firstOwned.prevOwned = node;
553
584
  }
554
585
  currentOwner.firstOwned = node;
555
- } else {
556
- node.owner = null;
557
586
  }
558
587
 
588
+ if (mutationHook !== null) mutationHook(1, node.id, node.flags | 0);
559
589
  return node;
560
590
  }
561
591
 
562
592
  /**
563
593
  * Cascade-dispose owned children inside-out (deepest first), then invoke this
564
594
  * node's own cleanup if any. Cascade order is the v1.2 conformance fix for
565
- * #238 / #241 / #243 nested cleanups must fire grandchild child outer
595
+ * #238 / #241 / #243 -- nested cleanups must fire grandchild -> child -> outer
566
596
  * so that a parent's cleanup still sees its own state intact.
567
597
  * @private
568
598
  */
569
599
  function runCleanup(node) {
570
- // Cascade children FIRST deepest cleanups fire before shallowest.
600
+ // Cascade children FIRST -- deepest cleanups fire before shallowest.
571
601
  // This matches the universal invariant in the upstream conformance suite
572
602
  // (#238 / #241 / #243): nested cleanups run inside-out on owner-tree
573
603
  // disposal, mirroring the parent-knows-best assumption shared with
@@ -606,19 +636,19 @@ export function createRegistry(config) {
606
636
  }
607
637
  }
608
638
 
609
- // ═══ L3 · PROPAGATION / EXECUTION ═════════════════════════════
639
+ // === L3 * PROPAGATION / EXECUTION =============================
610
640
  // markDownstream is owner-free AND cursor-free (a pure propagation
611
641
  // primitive). executeEffect/pullComputed are the orchestrators: they drive
612
642
  // the cursor + severTail (L1) and, before a re-run, call runCleanup (L2) to
613
- // cascade-dispose owned children. Sanctioned upward call L2: runCleanup.
643
+ // cascade-dispose owned children. Sanctioned upward call -> L2: runCleanup.
614
644
 
615
- // ─── EXECUTION ENGINE ─────────────────────────────────────────
645
+ // --- EXECUTION ENGINE -----------------------------------------
616
646
 
617
647
  /**
618
648
  * Mark all transitive subscribers of `startNode` dirty.
619
649
  * Iterative DFS via the markStack to avoid call-stack growth.
620
650
  * Effects are enqueued for the flush phase; computeds are merely marked
621
- * (their re-evaluation is lazy triggered by the next read).
651
+ * (their re-evaluation is lazy -- triggered by the next read).
622
652
  * @private
623
653
  */
624
654
  function markDownstream(startNode) {
@@ -654,7 +684,7 @@ export function createRegistry(config) {
654
684
  * effects scheduled mid-flush land in the next pass. Individual effect throws
655
685
  * are caught and buffered; at end-of-flush a single throw is rethrown directly,
656
686
  * multiple throws are aggregated into an `AggregateError` (1.2.0). Exceeds
657
- * `maxFlushPasses` (default 100) Error prefixed `"CycleError:"`.
687
+ * `maxFlushPasses` (default 100) -> Error prefixed `"CycleError:"`.
658
688
  * @private
659
689
  */
660
690
  function flushEffects() {
@@ -714,7 +744,7 @@ export function createRegistry(config) {
714
744
  * Run an effect's compute body, re-tracking dependencies.
715
745
  * Short-circuits if no dependency has bumped its version since last eval.
716
746
  * If the body self-disposes (node.gen advances during the body), skips the
717
- * post-body bookkeeping (severTail, flag clear, evalVersion bump) that
747
+ * post-body bookkeeping (severTail, flag clear, evalVersion bump) -- that
718
748
  * gen-snapshot guard is the v1.2 conformance fix for #141.
719
749
  * @private
720
750
  */
@@ -744,7 +774,7 @@ export function createRegistry(config) {
744
774
  }
745
775
 
746
776
  node.flags = (node.flags & ~FLAG_QUEUED) | FLAG_COMPUTING;
747
- runCleanup(node); // CROSS-EDGE L3L2: dispose owned children before re-run
777
+ runCleanup(node); // CROSS-EDGE L3->L2: dispose owned children before re-run
748
778
  if ((node.flags & FLAG_EFFECT) === 0) return;
749
779
 
750
780
  const prevObserver = currentObserver;
@@ -760,9 +790,10 @@ export function createRegistry(config) {
760
790
  // SELF-DISPOSE DETECTION: snapshot the gen. disposeNode bumps gen,
761
791
  // so if it advanced during the body the node was disposed (and may
762
792
  // already have been recycled into a different role). Skip the
763
- // dep-list / flag / version mutations in that case they would
793
+ // dep-list / flag / version mutations in that case -- they would
764
794
  // either crash on the freed link list or corrupt the new resident.
765
795
  const savedGen = node.gen;
796
+ if (mutationHook !== null) mutationHook(5, node.id, 0);
766
797
  try {
767
798
  node.computeFn();
768
799
  } finally {
@@ -788,7 +819,7 @@ export function createRegistry(config) {
788
819
  * Errors thrown by computeFn are captured in `node.value` with FLAG_HAS_ERROR;
789
820
  * subsequent reads re-throw until a dependency change re-runs computeFn.
790
821
  *
791
- * Same gen-snapshot self-dispose guard as executeEffect see #141 fix.
822
+ * Same gen-snapshot self-dispose guard as executeEffect -- see #141 fix.
792
823
  *
793
824
  * @private
794
825
  */
@@ -825,7 +856,7 @@ export function createRegistry(config) {
825
856
  if (shouldRun) {
826
857
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
827
858
  node.flags |= FLAG_COMPUTING;
828
- runCleanup(node); // CROSS-EDGE L3L2: dispose owned children before recompute
859
+ runCleanup(node); // CROSS-EDGE L3->L2: dispose owned children before recompute
829
860
 
830
861
  const prevObserver = currentObserver;
831
862
  const prevOwner = currentOwner;
@@ -837,8 +868,9 @@ export function createRegistry(config) {
837
868
  activeObserverCurrentDep = node.headDep;
838
869
  isTrackingDeps = true;
839
870
 
840
- // Same self-dispose detection as executeEffect see comment there.
871
+ // Same self-dispose detection as executeEffect -- see comment there.
841
872
  const savedGen = node.gen;
873
+ if (mutationHook !== null) mutationHook(5, node.id, 0);
842
874
  try {
843
875
  const newValue = node.computeFn();
844
876
  const eq = node.equals;
@@ -854,7 +886,7 @@ export function createRegistry(config) {
854
886
  node.version = globalVersion;
855
887
  } else {
856
888
  // The body disposed `node` and then threw. The error has
857
- // nowhere to land the caller of the read that triggered
889
+ // nowhere to land -- the caller of the read that triggered
858
890
  // this pull has already had its tracking state torn down.
859
891
  // Swallow rather than corrupt a recycled slot. The
860
892
  // canonical thrown-computed test (#168 / cached error)
@@ -879,9 +911,9 @@ export function createRegistry(config) {
879
911
  return node.value;
880
912
  }
881
913
 
882
- // ─── PUBLIC API ──────────────────────────────────────────────────
914
+ // --- PUBLIC API --------------------------------------------------
883
915
 
884
- // ─── shared accessor methods (one set per registry, not per primitive) ───────
916
+ // --- shared accessor methods (one set per registry, not per primitive) -------
885
917
  // update/subscribe are method-invoked (s.update(fn), s.subscribe(fn)), so `this`
886
918
  // is the read function and this[NODE_PTR] is the node. set() and peek() stay
887
919
  // closures: set() is the hot write path (a closure over `node` beats the
@@ -906,8 +938,16 @@ export function createRegistry(config) {
906
938
  // arrows. Method-invoked, so `this` is the read function and this[NODE_PTR]
907
939
  // is the node. Signal: direct value read. Computed: pull (still respects
908
940
  // the cached/short-circuit fast paths since pullComputed handles them).
909
- function sharedSignalPeek() { return this[NODE_PTR].value; }
910
- function sharedComputedPeek() { return pullComputed(this[NODE_PTR]); }
941
+ function sharedSignalPeek() {
942
+ const node = this[NODE_PTR];
943
+ if (this[NODE_GEN] !== node.gen) return undefined; // stale handle: slot recycled (ABA guard, matches read())
944
+ return node.value;
945
+ }
946
+ function sharedComputedPeek() {
947
+ const node = this[NODE_PTR];
948
+ if (this[NODE_GEN] !== node.gen) return undefined;
949
+ return pullComputed(node);
950
+ }
911
951
 
912
952
  /**
913
953
  * Create a reactive signal.
@@ -921,11 +961,20 @@ export function createRegistry(config) {
921
961
  */
922
962
  function signal(initial, opts) {
923
963
  const node = createNode(initial, FLAG_SIGNAL);
924
- node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
964
+ node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : OBJECT_IS;
925
965
  node.version = globalVersion;
926
966
  statSignals++;
927
967
 
968
+ // birthGen pinned at construction. The set/read closures check
969
+ // `node.gen === birthGen` to detect stale handles after dispose +
970
+ // pool-slot recycling. Without this, a retained set() from a disposed
971
+ // signal can overwrite the recycled slot's new resident; a retained
972
+ // read() inside an active observer can create a phantom subscription
973
+ // to the recycled slot. See probe-c1-stale-set.mjs / probe-c1-stale-read.mjs.
974
+ const birthGen = node.gen;
975
+
928
976
  const read = () => {
977
+ if (node.gen !== birthGen) return undefined;
929
978
  if (isTrackingDeps && currentObserver !== null) {
930
979
  let expected = activeObserverCurrentDep;
931
980
  if (expected !== null && expected.source === node) {
@@ -942,6 +991,7 @@ export function createRegistry(config) {
942
991
  // path, and a closure over `node` beats a shared method's this[NODE_PTR]
943
992
  // load. Keeping it a closure also restores detached `const {set}=signal()`.
944
993
  read.set = (value) => {
994
+ if (node.gen !== birthGen) return;
945
995
  const eq = node.equals;
946
996
  if (eq && eq(node.value, value)) return;
947
997
  if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
@@ -982,10 +1032,13 @@ export function createRegistry(config) {
982
1032
  function computed(fn, opts) {
983
1033
  const node = createNode(undefined, FLAG_COMPUTED);
984
1034
  node.computeFn = fn;
985
- node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
1035
+ node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : OBJECT_IS;
986
1036
  statComputeds++;
987
1037
 
1038
+ const birthGen = node.gen;
1039
+
988
1040
  const read = () => {
1041
+ if (node.gen !== birthGen) return undefined;
989
1042
  if (isTrackingDeps && currentObserver !== null) {
990
1043
  let expected = activeObserverCurrentDep;
991
1044
  if (expected !== null && expected.source === node) {
@@ -1039,7 +1092,7 @@ export function createRegistry(config) {
1039
1092
  const gen = node.gen | 0;
1040
1093
  // Cache the gen-bound thunk so re-schedules reuse the same closure.
1041
1094
  // The inline guard preserves ABA correctness across dispose+recycle
1042
- // (gen bumps on disposeNode stale thunk no-ops).
1095
+ // (gen bumps on disposeNode -> stale thunk no-ops).
1043
1096
  node.schedulerThunk = () => {
1044
1097
  if (node.gen === gen && (node.flags & FLAG_EFFECT) !== 0) executeEffect(node);
1045
1098
  };
@@ -1063,6 +1116,16 @@ export function createRegistry(config) {
1063
1116
  }
1064
1117
  };
1065
1118
 
1119
+ // Effect handles are first-class introspection handles (1.2.1): stamp
1120
+ // the same NODE_PTR / NODE_GEN pair signal() and computed() stamp, so
1121
+ // describe / track / dependencies / graph / findPath / ownerTree work
1122
+ // when handed the dispose handle directly. NODE_GEN mirrors birthGen
1123
+ // -- introspection validity agrees exactly with the disposer's own
1124
+ // stale-guard. (Pre-existing gap on every prior version: the disposer
1125
+ // was a bare closure and liveNode() reported live effects as stale.)
1126
+ disposeFn[NODE_PTR] = node;
1127
+ disposeFn[NODE_GEN] = birthGen;
1128
+
1066
1129
  if (firstRunError !== null) {
1067
1130
  disposeFn();
1068
1131
  throw firstRunError;
@@ -1084,7 +1147,7 @@ export function createRegistry(config) {
1084
1147
 
1085
1148
  /**
1086
1149
  * Coalesce multiple synchronous writes into a single effect-flush pass.
1087
- * Nested batches are merged only the outermost close triggers the flush.
1150
+ * Nested batches are merged -- only the outermost close triggers the flush.
1088
1151
  *
1089
1152
  * Pre-batch revert (1.2.0): if a signal is set, then set back to its
1090
1153
  * pre-batch value (under its `equals`) before the outer close, the version
@@ -1163,7 +1226,7 @@ export function createRegistry(config) {
1163
1226
  }
1164
1227
 
1165
1228
  /**
1166
- * Snapshot of registry counters. Useful for diagnostics and tests
1229
+ * Snapshot of registry counters. Useful for diagnostics and tests --
1167
1230
  * e.g. asserting that `activeNodes` returns to a baseline after teardown.
1168
1231
  * @returns {RegistryStats}
1169
1232
  */
@@ -1255,11 +1318,11 @@ export function createRegistry(config) {
1255
1318
  }
1256
1319
 
1257
1320
  function hasObservers(handle) {
1258
- const node = handle != null ? handle[NODE_PTR] : undefined;
1321
+ const node = liveNode(handle);
1259
1322
  return node !== undefined && node.headSub !== null;
1260
1323
  }
1261
1324
  function observeObservers(handle, opts) {
1262
- const node = handle != null ? handle[NODE_PTR] : undefined;
1325
+ const node = liveNode(handle);
1263
1326
  if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
1264
1327
  let e = lifecycleMap.get(node);
1265
1328
  if (e === undefined) {
@@ -1281,37 +1344,71 @@ export function createRegistry(config) {
1281
1344
  function describeNode(node) {
1282
1345
  const fl = node.flags;
1283
1346
  const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1347
+ // Plain property assignment, not Object.defineProperty.
1348
+ // Object.keys() never includes symbol-keyed properties regardless of
1349
+ // descriptor -- enumerable: false was defending nothing. Confirmed
1350
+ // empirically: `o[Symbol()] = x; Object.keys(o)` returns only
1351
+ // string-keyed enumerable props.
1284
1352
  const d = {id: node.id, kind, value: node.value};
1285
- Object.defineProperty(d, NODE_PTR, {value: node, enumerable: false});
1353
+ d[NODE_PTR] = node;
1354
+ d[NODE_GEN] = node.gen; // descriptors are re-walkable handles; stamp gen so the ABA guard holds for them too
1286
1355
  return d;
1287
1356
  }
1357
+ // Gen-guarded handle resolution (1.2.1): with the v1.2 owner tree, the
1358
+ // ENGINE recycles slots autonomously (owner re-run cascade-disposes owned
1359
+ // children), so stale handles are a normal occurrence -- introspecting the
1360
+ // slot's NEW resident through an old handle reports the wrong node.
1361
+ // read()/set() already guard via closure-captured birthGen; the
1362
+ // introspection surface must apply the same ABA guard via NODE_GEN.
1363
+ function liveNode(handle) {
1364
+ if (handle == null) return undefined;
1365
+ const node = handle[NODE_PTR];
1366
+ if (node === undefined) return undefined;
1367
+ if (handle[NODE_GEN] !== node.gen) return undefined; // stale: slot recycled
1368
+ return node;
1369
+ }
1288
1370
  function nodeId(handle) {
1289
- const node = handle != null ? handle[NODE_PTR] : undefined;
1371
+ const node = liveNode(handle);
1290
1372
  return node !== undefined ? node.id : undefined;
1291
1373
  }
1292
1374
  function describe(handle) {
1293
- const node = handle != null ? handle[NODE_PTR] : undefined;
1375
+ const node = liveNode(handle);
1294
1376
  return node !== undefined ? describeNode(node) : undefined;
1295
1377
  }
1296
1378
  function forEachObserver(handle, fn) {
1297
- const node = handle != null ? handle[NODE_PTR] : undefined;
1379
+ const node = liveNode(handle);
1298
1380
  if (node === undefined) return;
1299
1381
  let l = node.headSub;
1300
1382
  while (l !== null) { const nx = l.nextSub; fn(describeNode(l.target)); l = nx; }
1301
1383
  }
1384
+ /** Iterate this node's OWNED children (v1.2 owner tree). Additive 1.3 API
1385
+ * prototype: lets devtools/studio walk + render the ownership hierarchy
1386
+ * (cascade-disposal domains), which is invisible through dep/sub edges. */
1387
+ function forEachOwned(handle, fn) {
1388
+ const node = liveNode(handle);
1389
+ if (node === undefined) return;
1390
+ let c = node.firstOwned;
1391
+ while (c !== null) { const nx = c.nextOwned; fn(describeNode(c)); c = nx; }
1392
+ }
1393
+ /** Descriptor of this node's owner, or undefined (top-level / stale handle). */
1394
+ function ownerOf(handle) {
1395
+ const node = liveNode(handle);
1396
+ if (node === undefined || node.owner === null) return undefined;
1397
+ return describeNode(node.owner);
1398
+ }
1302
1399
  function forEachSource(handle, fn) {
1303
- const node = handle != null ? handle[NODE_PTR] : undefined;
1400
+ const node = liveNode(handle);
1304
1401
  if (node === undefined) return;
1305
1402
  let l = node.headDep;
1306
1403
  while (l !== null) { const nx = l.nextDep; fn(describeNode(l.source)); l = nx; }
1307
1404
  }
1308
1405
 
1309
- return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, nodeId, describe};
1406
+ return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, forEachOwned, ownerOf, nodeId, describe, onGraphMutation};
1310
1407
  }
1311
1408
 
1312
- // ─────────────────────────────────────────────────────────────────
1409
+ // -----------------------------------------------------------------
1313
1410
  // GLOBAL BINDINGS
1314
- // ─────────────────────────────────────────────────────────────────
1411
+ // -----------------------------------------------------------------
1315
1412
 
1316
1413
  let defaultRegistry = createRegistry();
1317
1414
 
@@ -1375,6 +1472,15 @@ export function forEachObserver(handle, fn) {
1375
1472
  export function forEachSource(handle, fn) {
1376
1473
  return defaultRegistry.forEachSource(handle, fn);
1377
1474
  }
1475
+ export function onGraphMutation(fn) {
1476
+ return defaultRegistry.onGraphMutation(fn);
1477
+ }
1478
+ export function forEachOwned(handle, fn) {
1479
+ return defaultRegistry.forEachOwned(handle, fn);
1480
+ }
1481
+ export function ownerOf(handle) {
1482
+ return defaultRegistry.ownerOf(handle);
1483
+ }
1378
1484
  export function nodeId(handle) {
1379
1485
  return defaultRegistry.nodeId(handle);
1380
1486
  }