@zakkster/lite-signal 1.2.1 → 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.
package/Signal.d.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * @zakkster/lite-signal zero-GC reactive graph.
2
+ * @zakkster/lite-signal -- zero-GC reactive graph.
3
3
  *
4
4
  * Public type surface for the JavaScript implementation in `Signal.js`.
5
5
  */
6
6
 
7
- // ─── Options ──────────────────────────────────────────────────────────────────
7
+ // --- Options ------------------------------------------------------------------
8
8
 
9
9
  /** Equality predicate. Returning `true` halts propagation. */
10
10
  export type EqualsFn<T> = (a: T, b: T) => boolean;
@@ -45,7 +45,7 @@ export type Dispose = () => void;
45
45
  */
46
46
  export type Disposable<T = unknown> = Signal<T> | Computed<T> | Dispose;
47
47
 
48
- // ─── Reactive primitive shapes ────────────────────────────────────────────────
48
+ // --- Reactive primitive shapes ------------------------------------------------
49
49
 
50
50
  /** Reactive source of truth. */
51
51
  export interface Signal<T> {
@@ -71,7 +71,7 @@ export interface Computed<T> {
71
71
  subscribe(fn: (value: T) => void): Dispose;
72
72
  }
73
73
 
74
- // ─── Diagnostics ──────────────────────────────────────────────────────────────
74
+ // --- Diagnostics --------------------------------------------------------------
75
75
 
76
76
  export interface RegistryStats {
77
77
  /** Number of signals created in this registry's lifetime. */
@@ -92,7 +92,7 @@ export interface RegistryStats {
92
92
  activeNodes: number;
93
93
  }
94
94
 
95
- // ─── Observer-lifecycle introspection (1.1.4) ─────────────────────────────────
95
+ // --- Observer-lifecycle introspection (1.1.4) ---------------------------------
96
96
 
97
97
  /** Whether a described node is a signal, a computed, or an effect. */
98
98
  export type NodeKind = "signal" | "computed" | "effect";
@@ -109,9 +109,9 @@ export interface NodeDescriptor {
109
109
 
110
110
  /** Transition callbacks for {@link Registry.observeObservers}. */
111
111
  export interface ObserveObserversHooks {
112
- /** Fired on the 01 observer transition (after registration). */
112
+ /** Fired on the 0->1 observer transition (after registration). */
113
113
  onConnect?: () => void;
114
- /** Fired on the 10 observer transition. */
114
+ /** Fired on the 1->0 observer transition. */
115
115
  onDisconnect?: () => void;
116
116
  }
117
117
 
@@ -121,25 +121,25 @@ export type Unobserve = () => void;
121
121
  /** Anything carrying a node identity that the introspection surface can read. */
122
122
  export type ReactiveHandle = Signal<any> | Computed<any>;
123
123
 
124
- // ─── Graph-mutation hook (1.2.1) ──────────────────────────────────────────────
124
+ // --- Graph-mutation hook (1.2.1) ----------------------------------------------
125
125
 
126
126
  /**
127
127
  * Opcode passed as the first argument to a {@link GraphMutationListener}.
128
128
  *
129
129
  * - `1` node create. `(intA, intB) = (node.id, node.flags)`.
130
- * - `2` node dispose. `(intA, intB) = (node.id, node.flags)` fires for every node
130
+ * - `2` node dispose. `(intA, intB) = (node.id, node.flags)` -- fires for every node
131
131
  * disposed, including cascaded owner-tree children.
132
132
  * - `3` link add. `(intA, intB) = (source.id, target.id)`.
133
- * - `4` link remove. `(intA, intB) = (source.id, target.id)` `-1` if the link
133
+ * - `4` link remove. `(intA, intB) = (source.id, target.id)` -- `-1` if the link
134
134
  * was already nulled (defensive, rare).
135
- * - `5` recompute. `(intA, intB) = (node.id, 0)` fires just before an effect
135
+ * - `5` recompute. `(intA, intB) = (node.id, 0)` -- fires just before an effect
136
136
  * re-run or a computed re-eval.
137
137
  */
138
138
  export type GraphMutationOpcode = 1 | 2 | 3 | 4 | 5;
139
139
 
140
140
  /**
141
141
  * Listener registered with {@link Registry.onGraphMutation}. Called synchronously
142
- * inside each mutation point with three integers no objects allocated.
142
+ * inside each mutation point with three integers -- no objects allocated.
143
143
  *
144
144
  * **Contract: observe only.** Listeners MUST NOT throw and MUST NOT mutate the
145
145
  * graph from inside the callback. Both will corrupt the engine's state. Wrap any
@@ -150,7 +150,7 @@ export type GraphMutationListener = (opcode: GraphMutationOpcode, intA: number,
150
150
  /** Idempotent unsubscriber returned by {@link Registry.onGraphMutation}. */
151
151
  export type GraphMutationUnsubscribe = () => void;
152
152
 
153
- // ─── Errors ───────────────────────────────────────────────────────────────────
153
+ // --- Errors -------------------------------------------------------------------
154
154
 
155
155
  /** Thrown when a pool ceiling is hit. */
156
156
  export class CapacityError extends Error {
@@ -160,7 +160,7 @@ export class CapacityError extends Error {
160
160
  constructor(kind: "nodes" | "links", capacity: number);
161
161
  }
162
162
 
163
- // ─── Registry ─────────────────────────────────────────────────────────────────
163
+ // --- Registry -----------------------------------------------------------------
164
164
 
165
165
  export interface RegistryConfig {
166
166
  /** Initial node-pool capacity. Default: 1024. */
@@ -197,8 +197,8 @@ export interface Registry {
197
197
  isTracking(): boolean;
198
198
  /** O(1): does this source have at least one live observer right now? A `peek` does not count. */
199
199
  hasObservers(handle: ReactiveHandle): boolean;
200
- /** Auto-pause hook: fires `onConnect` on the 01 observer transition and `onDisconnect`
201
- * on 10, after registration (transition-only no immediate fire if already observed).
200
+ /** Auto-pause hook: fires `onConnect` on the 0->1 observer transition and `onDisconnect`
201
+ * on 1->0, after registration (transition-only -- no immediate fire if already observed).
202
202
  * Re-tracking a persistently-read source does not churn. Returns an idempotent unobserve.
203
203
  * @throws TypeError if `handle` is not a reactive handle. */
204
204
  observeObservers(handle: ReactiveHandle, hooks?: ObserveObserversHooks): Unobserve;
@@ -251,12 +251,12 @@ export function createRegistry(config?: RegistryConfig): Registry;
251
251
  /** Replace the default registry backing the top-level helpers. */
252
252
  export function setDefaultRegistry(registry: Registry): void;
253
253
 
254
- // ─── Top-level helpers (delegate to default registry) ────────────────────────
254
+ // --- Top-level helpers (delegate to default registry) ------------------------
255
255
 
256
256
  export function signal<T>(initial: T, opts?: SignalOptions<T>): Signal<T>;
257
257
  export function computed<T>(fn: () => T, opts?: ComputedOptions<T>): Computed<T>;
258
258
  export function effect(fn: () => void, opts?: EffectOptions): Dispose;
259
- /** Universal disposal see {@link Registry.dispose}. */
259
+ /** Universal disposal -- see {@link Registry.dispose}. */
260
260
  export function dispose(api: Disposable): void;
261
261
  export function batch<T>(fn: () => T): T;
262
262
  export function untrack<T>(fn: () => T): T;
@@ -296,7 +296,7 @@ export interface WatchOptions {
296
296
 
297
297
  /**
298
298
  * Track a reactive source and run a callback whenever its projected value
299
- * changes. The callback receives `(newValue, oldValue, stop)` the third
299
+ * changes. The callback receives `(newValue, oldValue, stop)` -- the third
300
300
  * argument is a dispose function that can be called from inside the callback
301
301
  * to terminate the watcher.
302
302
  *
@@ -336,10 +336,10 @@ export function when(
336
336
  * Promise-returning variant of {@link when}. The returned promise resolves
337
337
  * when `predicate` first returns a truthy value.
338
338
  *
339
- * ⚠️ **HOT-PATH WARNING DO NOT USE PER FRAME.** This function calls
339
+ * ! **HOT-PATH WARNING -- DO NOT USE PER FRAME.** This function calls
340
340
  * `new Promise(...)`, which is a heap allocation (one Promise object plus
341
341
  * executor closure plus internal infrastructure per call). Promises require
342
- * heap allocation by the language spec this cost is unavoidable.
342
+ * heap allocation by the language spec -- this cost is unavoidable.
343
343
  *
344
344
  * **Use for:** high-level scene/UI orchestration, boot sequences, awaiting
345
345
  * user input or network state, level transitions. Anything that runs once
package/Signal.js CHANGED
@@ -1,26 +1,23 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.2.1
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
@@ -115,7 +112,7 @@ class ReactiveNode {
115
112
  this.headSub = null;
116
113
  this.tailSub = null;
117
114
 
118
- // Owner Context Tree (Auto-Disposal of Nested Observers) 1.2.0.
115
+ // Owner Context Tree (Auto-Disposal of Nested Observers) -- 1.2.0.
119
116
  // An effect/computed created inside another effect/computed is "owned"
120
117
  // by it. When the owner re-runs or is disposed, owned children are
121
118
  // cascade-disposed before the new run. Plain signals are NOT adopted
@@ -173,7 +170,7 @@ export class CapacityError extends Error {
173
170
  *
174
171
  * Use this when you need multiple independent reactive graphs (e.g. one per
175
172
  * Twitch Extension viewer, one per worker, one per test). The top-level
176
- * helpers ({@link signal}, {@link effect}, ) delegate to a single shared
173
+ * helpers ({@link signal}, {@link effect}, ...) delegate to a single shared
177
174
  * default registry; call {@link setDefaultRegistry} to swap that for your own.
178
175
  *
179
176
  * @param {object} [config]
@@ -228,7 +225,7 @@ export function createRegistry(config) {
228
225
  let batchDepth = 0;
229
226
  let isTrackingDeps = false;
230
227
 
231
- // ── Node identity + observer-lifecycle introspection (ported from 1.1.5) ──
228
+ // -- Node identity + observer-lifecycle introspection (ported from 1.1.5) --
232
229
  let nodeSeq = 1 | 0;
233
230
  let lifecycleCount = 0 | 0;
234
231
  const lifecycleMap = new WeakMap();
@@ -251,19 +248,19 @@ export function createRegistry(config) {
251
248
  const flushErrorBuffer = [];
252
249
  let flushErrorCount = 0;
253
250
 
254
- // ═══ L1 · GRAPH TOPOLOGY ══════════════════════════════════════
251
+ // === L1 * GRAPH TOPOLOGY ======================================
255
252
  // Owns the ReactiveLink pool and the dep/sub lists. Pure edge mechanics:
256
- // INVARIANT must never touch node.owner / firstOwned.
253
+ // INVARIANT -- must never touch node.owner / firstOwned.
257
254
 
258
- // ─── HYBRID ALLOCATOR ─────────────────────────────────────────
255
+ // --- HYBRID ALLOCATOR -----------------------------------------
259
256
 
260
257
  /**
261
- * Establish (or reuse) a dependency link from `source` `target`.
258
+ * Establish (or reuse) a dependency link from `source` -> `target`.
262
259
  *
263
- * Fast path: cursor match (re-tracking same dep at same position) O(1), no allocation.
264
- * Mid path: O(1) tailSub dedup (1.1.4 rewrite) divergent retracking stays O(N) overall,
265
- * not O(N²).
266
- * 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.
267
264
  *
268
265
  * SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
269
266
  * BEFORE any new link is allocated, so peak link usage never exceeds steady
@@ -277,12 +274,12 @@ export function createRegistry(config) {
277
274
  *
278
275
  * @private
279
276
  */
280
- // --- Graph-mutation hook (1.2.1 keystone prototype) ---------------------
281
- // Single nullable listener; every fire point is `if (mutationHook !== null)`
282
- // -- branch-predicted free when absent, allocation-free when present
283
- // (opcode + two int args). Enables push-based devtools (watchGraph) and the
284
- // recompute profiler. Opcodes: 1 node-create, 2 node-dispose, 3 link-add,
285
- // 4 link-remove, 5 recompute.
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.
286
283
  let mutationHook = null;
287
284
  function onGraphMutation(fn) {
288
285
  if (fn !== null && typeof fn !== "function") throw new TypeError("onGraphMutation: listener must be a function or null");
@@ -294,7 +291,7 @@ export function createRegistry(config) {
294
291
  function allocateLink(source, target) {
295
292
  // Eligibility gate (restored from 1.1.5): an observer disposed mid-run (self-dispose, or
296
293
  // an outer observer torn down while suspended) has flags cleared to 0. Linking would splice
297
- // 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.
298
295
  if (target.flags === 0) return null;
299
296
  let expected = activeObserverCurrentDep;
300
297
 
@@ -345,7 +342,7 @@ export function createRegistry(config) {
345
342
 
346
343
  link.nextSub = null;
347
344
  link.prevSub = source.tailSub;
348
- const _was0 = lifecycleCount !== 0 && source.headSub === null; // 01 detect (pre-link)
345
+ const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0->1 detect (pre-link)
349
346
  if (source.tailSub !== null) source.tailSub.nextSub = link;
350
347
  else source.headSub = link;
351
348
  source.tailSub = link;
@@ -367,7 +364,7 @@ export function createRegistry(config) {
367
364
  const nSub = link.nextSub;
368
365
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
369
366
  if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
370
- if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 10
367
+ if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 1->0
371
368
 
372
369
  link.source = null;
373
370
  link.target = null;
@@ -403,13 +400,13 @@ export function createRegistry(config) {
403
400
  }
404
401
  }
405
402
 
406
- // ═══ L2 · OWNERSHIP / LIFECYCLE ═══════════════════════════════
403
+ // === L2 * OWNERSHIP / LIFECYCLE ===============================
407
404
  // Owns the owner tree, node death, and user cleanup.
408
- // INVARIANT must never touch the activeObserverCurrentDep cursor.
409
- // 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
410
407
  // dying node from the graph.
411
408
 
412
- // ─── LIFECYCLE & OWNERSHIP ───────────────────────────────────────
409
+ // --- LIFECYCLE & OWNERSHIP ---------------------------------------
413
410
 
414
411
  function disposeNode(node) {
415
412
  if (mutationHook !== null) mutationHook(2, node.id, node.flags | 0);
@@ -417,7 +414,7 @@ export function createRegistry(config) {
417
414
 
418
415
  // RACE WITH ACTIVE TRACKING: an effect/computed may call dispose on
419
416
  // itself from inside its own body (#141). Once we tear the node down
420
- // its dep-list, FLAG_COMPUTING, and cursor become stale immediately
417
+ // its dep-list, FLAG_COMPUTING, and cursor become stale immediately --
421
418
  // any read() that runs in the REST of the body would otherwise try to
422
419
  // hang a fresh link off a freed slot. Null the tracking state now so
423
420
  // subsequent reads in this call stack become no-ops, and let
@@ -455,7 +452,7 @@ export function createRegistry(config) {
455
452
 
456
453
  runCleanup(node);
457
454
 
458
- // 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.
459
456
  let dLink = node.headDep;
460
457
  while (dLink !== null) {
461
458
  const next = dLink.nextDep;
@@ -511,7 +508,7 @@ export function createRegistry(config) {
511
508
  * Claim a node from the free pool, reinitialise, and return it.
512
509
  * Grows pool per `policy` if exhausted (or throws CapacityError under "throw").
513
510
  * Adopts the new node into `currentOwner` if there is one AND the new node is
514
- * an observer (computed/effect) plain signals are not adopted (see ReactiveNode
511
+ * an observer (computed/effect) -- plain signals are not adopted (see ReactiveNode
515
512
  * comment on the owner tree).
516
513
  * @private
517
514
  */
@@ -539,43 +536,53 @@ export function createRegistry(config) {
539
536
  node.nextFree = null;
540
537
  activeNodes = (activeNodes + 1) | 0;
541
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).
542
552
  node.value = value;
543
553
  node.flags = flags | 0;
544
- node.headDep = null;
545
- node.tailDep = null;
546
- node.headSub = null;
547
- node.tailSub = null;
548
554
  node.version = 0;
549
555
  node.evalVersion = 0;
550
556
  node.markEpoch = 0;
551
- node.revertEpoch = 0;
552
- node.preBatchValue = undefined;
553
- node.preBatchVersion = 0;
554
557
  node.id = nodeSeq; nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (ported from 1.1.5)
555
558
 
556
- // Wire into Owner Context (lifecycle, not tracking keyed off currentOwner).
559
+ // Wire into Owner Context (lifecycle, not tracking -- keyed off currentOwner).
557
560
  // ONLY observers (computed/effect) are adopted: a re-running owner disposes
558
561
  // its nested observers (which would otherwise leak dep links), but plain
559
562
  // signals have no deps to leak, and disposing them breaks lazy-allocation
560
563
  // libraries (lite-store allocates a key's signal on first read, INSIDE the
561
- // 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
562
565
  // store key). Signals are therefore never owner-adopted.
563
- // firstOwned is reset unconditionally (reuse-safety: a recycled former-owner
564
- // must not carry stale children into runCleanup). prevOwned/nextOwned are
565
- // written only on the adoption path -- an unadopted node is in no owner's
566
- // firstOwned chain, so its prevOwned/nextOwned are never traversed and may
567
- // stay stale. Saves two writes per signal and per top-level computed/effect.
568
- 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.
569
579
  if (currentOwner !== null && (flags & (FLAG_COMPUTED | FLAG_EFFECT)) !== 0) {
570
580
  node.owner = currentOwner;
571
- node.prevOwned = null;
572
581
  node.nextOwned = currentOwner.firstOwned;
573
582
  if (currentOwner.firstOwned !== null) {
574
583
  currentOwner.firstOwned.prevOwned = node;
575
584
  }
576
585
  currentOwner.firstOwned = node;
577
- } else {
578
- node.owner = null;
579
586
  }
580
587
 
581
588
  if (mutationHook !== null) mutationHook(1, node.id, node.flags | 0);
@@ -585,12 +592,12 @@ export function createRegistry(config) {
585
592
  /**
586
593
  * Cascade-dispose owned children inside-out (deepest first), then invoke this
587
594
  * node's own cleanup if any. Cascade order is the v1.2 conformance fix for
588
- * #238 / #241 / #243 nested cleanups must fire grandchild child outer
595
+ * #238 / #241 / #243 -- nested cleanups must fire grandchild -> child -> outer
589
596
  * so that a parent's cleanup still sees its own state intact.
590
597
  * @private
591
598
  */
592
599
  function runCleanup(node) {
593
- // Cascade children FIRST deepest cleanups fire before shallowest.
600
+ // Cascade children FIRST -- deepest cleanups fire before shallowest.
594
601
  // This matches the universal invariant in the upstream conformance suite
595
602
  // (#238 / #241 / #243): nested cleanups run inside-out on owner-tree
596
603
  // disposal, mirroring the parent-knows-best assumption shared with
@@ -629,19 +636,19 @@ export function createRegistry(config) {
629
636
  }
630
637
  }
631
638
 
632
- // ═══ L3 · PROPAGATION / EXECUTION ═════════════════════════════
639
+ // === L3 * PROPAGATION / EXECUTION =============================
633
640
  // markDownstream is owner-free AND cursor-free (a pure propagation
634
641
  // primitive). executeEffect/pullComputed are the orchestrators: they drive
635
642
  // the cursor + severTail (L1) and, before a re-run, call runCleanup (L2) to
636
- // cascade-dispose owned children. Sanctioned upward call L2: runCleanup.
643
+ // cascade-dispose owned children. Sanctioned upward call -> L2: runCleanup.
637
644
 
638
- // ─── EXECUTION ENGINE ─────────────────────────────────────────
645
+ // --- EXECUTION ENGINE -----------------------------------------
639
646
 
640
647
  /**
641
648
  * Mark all transitive subscribers of `startNode` dirty.
642
649
  * Iterative DFS via the markStack to avoid call-stack growth.
643
650
  * Effects are enqueued for the flush phase; computeds are merely marked
644
- * (their re-evaluation is lazy triggered by the next read).
651
+ * (their re-evaluation is lazy -- triggered by the next read).
645
652
  * @private
646
653
  */
647
654
  function markDownstream(startNode) {
@@ -677,7 +684,7 @@ export function createRegistry(config) {
677
684
  * effects scheduled mid-flush land in the next pass. Individual effect throws
678
685
  * are caught and buffered; at end-of-flush a single throw is rethrown directly,
679
686
  * multiple throws are aggregated into an `AggregateError` (1.2.0). Exceeds
680
- * `maxFlushPasses` (default 100) Error prefixed `"CycleError:"`.
687
+ * `maxFlushPasses` (default 100) -> Error prefixed `"CycleError:"`.
681
688
  * @private
682
689
  */
683
690
  function flushEffects() {
@@ -737,7 +744,7 @@ export function createRegistry(config) {
737
744
  * Run an effect's compute body, re-tracking dependencies.
738
745
  * Short-circuits if no dependency has bumped its version since last eval.
739
746
  * If the body self-disposes (node.gen advances during the body), skips the
740
- * post-body bookkeeping (severTail, flag clear, evalVersion bump) that
747
+ * post-body bookkeeping (severTail, flag clear, evalVersion bump) -- that
741
748
  * gen-snapshot guard is the v1.2 conformance fix for #141.
742
749
  * @private
743
750
  */
@@ -767,7 +774,7 @@ export function createRegistry(config) {
767
774
  }
768
775
 
769
776
  node.flags = (node.flags & ~FLAG_QUEUED) | FLAG_COMPUTING;
770
- runCleanup(node); // CROSS-EDGE L3L2: dispose owned children before re-run
777
+ runCleanup(node); // CROSS-EDGE L3->L2: dispose owned children before re-run
771
778
  if ((node.flags & FLAG_EFFECT) === 0) return;
772
779
 
773
780
  const prevObserver = currentObserver;
@@ -783,7 +790,7 @@ export function createRegistry(config) {
783
790
  // SELF-DISPOSE DETECTION: snapshot the gen. disposeNode bumps gen,
784
791
  // so if it advanced during the body the node was disposed (and may
785
792
  // already have been recycled into a different role). Skip the
786
- // dep-list / flag / version mutations in that case they would
793
+ // dep-list / flag / version mutations in that case -- they would
787
794
  // either crash on the freed link list or corrupt the new resident.
788
795
  const savedGen = node.gen;
789
796
  if (mutationHook !== null) mutationHook(5, node.id, 0);
@@ -812,7 +819,7 @@ export function createRegistry(config) {
812
819
  * Errors thrown by computeFn are captured in `node.value` with FLAG_HAS_ERROR;
813
820
  * subsequent reads re-throw until a dependency change re-runs computeFn.
814
821
  *
815
- * Same gen-snapshot self-dispose guard as executeEffect see #141 fix.
822
+ * Same gen-snapshot self-dispose guard as executeEffect -- see #141 fix.
816
823
  *
817
824
  * @private
818
825
  */
@@ -849,7 +856,7 @@ export function createRegistry(config) {
849
856
  if (shouldRun) {
850
857
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
851
858
  node.flags |= FLAG_COMPUTING;
852
- runCleanup(node); // CROSS-EDGE L3L2: dispose owned children before recompute
859
+ runCleanup(node); // CROSS-EDGE L3->L2: dispose owned children before recompute
853
860
 
854
861
  const prevObserver = currentObserver;
855
862
  const prevOwner = currentOwner;
@@ -861,7 +868,7 @@ export function createRegistry(config) {
861
868
  activeObserverCurrentDep = node.headDep;
862
869
  isTrackingDeps = true;
863
870
 
864
- // Same self-dispose detection as executeEffect see comment there.
871
+ // Same self-dispose detection as executeEffect -- see comment there.
865
872
  const savedGen = node.gen;
866
873
  if (mutationHook !== null) mutationHook(5, node.id, 0);
867
874
  try {
@@ -879,7 +886,7 @@ export function createRegistry(config) {
879
886
  node.version = globalVersion;
880
887
  } else {
881
888
  // The body disposed `node` and then threw. The error has
882
- // nowhere to land the caller of the read that triggered
889
+ // nowhere to land -- the caller of the read that triggered
883
890
  // this pull has already had its tracking state torn down.
884
891
  // Swallow rather than corrupt a recycled slot. The
885
892
  // canonical thrown-computed test (#168 / cached error)
@@ -904,9 +911,9 @@ export function createRegistry(config) {
904
911
  return node.value;
905
912
  }
906
913
 
907
- // ─── PUBLIC API ──────────────────────────────────────────────────
914
+ // --- PUBLIC API --------------------------------------------------
908
915
 
909
- // ─── shared accessor methods (one set per registry, not per primitive) ───────
916
+ // --- shared accessor methods (one set per registry, not per primitive) -------
910
917
  // update/subscribe are method-invoked (s.update(fn), s.subscribe(fn)), so `this`
911
918
  // is the read function and this[NODE_PTR] is the node. set() and peek() stay
912
919
  // closures: set() is the hot write path (a closure over `node` beats the
@@ -1085,7 +1092,7 @@ export function createRegistry(config) {
1085
1092
  const gen = node.gen | 0;
1086
1093
  // Cache the gen-bound thunk so re-schedules reuse the same closure.
1087
1094
  // The inline guard preserves ABA correctness across dispose+recycle
1088
- // (gen bumps on disposeNode stale thunk no-ops).
1095
+ // (gen bumps on disposeNode -> stale thunk no-ops).
1089
1096
  node.schedulerThunk = () => {
1090
1097
  if (node.gen === gen && (node.flags & FLAG_EFFECT) !== 0) executeEffect(node);
1091
1098
  };
@@ -1140,7 +1147,7 @@ export function createRegistry(config) {
1140
1147
 
1141
1148
  /**
1142
1149
  * Coalesce multiple synchronous writes into a single effect-flush pass.
1143
- * Nested batches are merged only the outermost close triggers the flush.
1150
+ * Nested batches are merged -- only the outermost close triggers the flush.
1144
1151
  *
1145
1152
  * Pre-batch revert (1.2.0): if a signal is set, then set back to its
1146
1153
  * pre-batch value (under its `equals`) before the outer close, the version
@@ -1219,7 +1226,7 @@ export function createRegistry(config) {
1219
1226
  }
1220
1227
 
1221
1228
  /**
1222
- * Snapshot of registry counters. Useful for diagnostics and tests
1229
+ * Snapshot of registry counters. Useful for diagnostics and tests --
1223
1230
  * e.g. asserting that `activeNodes` returns to a baseline after teardown.
1224
1231
  * @returns {RegistryStats}
1225
1232
  */
@@ -1339,7 +1346,7 @@ export function createRegistry(config) {
1339
1346
  const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1340
1347
  // Plain property assignment, not Object.defineProperty.
1341
1348
  // Object.keys() never includes symbol-keyed properties regardless of
1342
- // descriptor enumerable: false was defending nothing. Confirmed
1349
+ // descriptor -- enumerable: false was defending nothing. Confirmed
1343
1350
  // empirically: `o[Symbol()] = x; Object.keys(o)` returns only
1344
1351
  // string-keyed enumerable props.
1345
1352
  const d = {id: node.id, kind, value: node.value};
@@ -1399,9 +1406,9 @@ export function createRegistry(config) {
1399
1406
  return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, forEachOwned, ownerOf, nodeId, describe, onGraphMutation};
1400
1407
  }
1401
1408
 
1402
- // ─────────────────────────────────────────────────────────────────
1409
+ // -----------------------------------------------------------------
1403
1410
  // GLOBAL BINDINGS
1404
- // ─────────────────────────────────────────────────────────────────
1411
+ // -----------------------------------------------------------------
1405
1412
 
1406
1413
  let defaultRegistry = createRegistry();
1407
1414