@zakkster/lite-signal 1.1.4 → 1.2.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 CHANGED
@@ -1,61 +1,66 @@
1
1
  /**
2
- * @zakkster/lite-signal v1.1.4
2
+ * @zakkster/lite-signal v1.2.0
3
3
  * --------------------
4
- * Zero-GC reactive graph.
4
+ * Hybrid Doubly-Linked-List Reactive Graph Engine — decoupled (Signal1_3) base
5
+ * with the two 1.1.3 performance fixes ported in:
6
+ * 1. pullComputed clean short-circuit (markEpoch) — kills the dynamic-graph
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
9
+ * re-tracking is O(N) not O(N^2) (600-dep flip micro: 1373ms -> 62ms).
10
+ * Ownership tree + L1/L2/L3 layering + observer/owner split are UNCHANGED; they
11
+ * were never the regression. Same EDGE NOTE as 1.1.3 applies to fix (2): a nested
12
+ * re-read of the same source can retain one bounded, dispose-reclaimed link.
5
13
  *
6
- * CHANGES vs 1.1.2 (both perf-only; public surface and semantics unchanged):
7
- * 1. pullComputed clean short-circuit. markDownstream already stamps markEpoch
8
- * on every node in a changed signal's transitive cone; pullComputed now uses
9
- * it to return a cached value in O(1) when no mark landed since the last eval,
10
- * instead of walking (and recursively pulling) the whole dependency subtree.
11
- * Erases the dynamic-graph regression (batched read-after-write workloads):
12
- * "large web app" 4824ms -> 650ms, "wide dense" 4378ms -> 908ms locally.
13
- * 2. allocateLink: the O(N) linear headDep scan on a cursor miss is replaced by
14
- * an O(1) source.tailSub dedup. Dependency re-tracking is now O(N) regardless
15
- * of read order, not O(N^2). Wide reordering nodes: ~50x locally (600-dep
16
- * flip: 2810ms -> 46ms). First-track of a wide node: ~4.4x (48ms -> 10ms).
17
- * SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
18
- * BEFORE any new link is allocated, so peak link usage never exceeds steady
19
- * state (ZERO pool debt). A divergent re-track therefore cannot trigger a
20
- * mid-compute pool growth under tight maxLinks + "throw" — the zero-GC budget
21
- * holds. (The alternative "splice" variant is ~equal speed but transiently
22
- * holds up to one node's fan-in of extra links until end-of-frame severTail.)
14
+ * Original header:
15
+ * v1.3.2: Hybrid Doubly-Linked-List Reactive Graph Engine.
23
16
  *
24
- * EDGE NOTE (2): a node that reads the SAME source twice within one body, with a
25
- * nested computed that also reads that source evaluated in between, retains ONE
26
- * redundant dependency link for the node's lifetime. It is value-correct, bounded
27
- * (does not grow across re-tracks), and reclaimed on dispose/destroy. The previous
28
- * O(N) scan collapsed this to a single link; the O(1) dedup cannot see past a
29
- * nested re-link of the same source. This is a deliberate cost/correctness trade.
17
+ * Performance model:
18
+ * - ReactiveLink DLL object pool guarantees O(1) graph edge allocation.
19
+ * - Inlined O(1) cursor fast-path for stable steady-state reads.
20
+ * - Divergence triggers immediate tail-severing to bound worst-case complexity.
21
+ * - O(1) Owner Context Tree ensures automatic teardown of nested observers.
30
22
  *
31
- * Architecture: monomorphic object pool + versioned push-pull propagation
32
- * + SMI modular arithmetic for 32-bit version-wrap safety.
23
+ * ── ARCHITECTURE: three layers + a public API, with a strict dependency direction ──
33
24
  *
34
- * Performance characteristics:
35
- * - Object pool: nodes and links are allocated from preallocated arrays. Steady-state
36
- * operations (signal.set / computed.peek / effect re-run) perform zero allocations
37
- * after warmup.
38
- * - Stable read order: re-tracking dependencies in the same order yields O(1) link reuse
39
- * via the `activeObserverCurrentDep` cursor.
40
- * - Chaotic / randomized read order degrades to O(N) per dep (linear search of headDep
41
- * list) — see {@link allocateLink}.
42
- * - Computed resolution is recursive on the JS call stack. Maximum chain depth is bound
43
- * by the engine stack limit (~10,000 frames).
44
- * - 32-bit modular arithmetic for versioning: the engine is immune to integer-overflow
45
- * crashes regardless of uptime.
25
+ * L1 GRAPH TOPOLOGY allocateLink, freeLink, severTail
26
+ * Owns the ReactiveLink pool and the dep/sub doubly-linked lists.
27
+ * INVARIANT: never touches `owner`/`firstOwned`. Pure edge mechanics.
46
28
  *
47
- * Public surface: {@link signal}, {@link computed}, {@link effect}, {@link batch},
48
- * {@link untrack}, {@link onCleanup}, {@link stats}, {@link createRegistry},
49
- * {@link setDefaultRegistry}, {@link CapacityError}.
29
+ * L2 OWNERSHIP / LIFECYCLE createNode, disposeNode, runCleanup
30
+ * Owns the owner tree and node death + user cleanup.
31
+ * INVARIANT: never touches the `activeObserverCurrentDep` cursor.
32
+ * Sanctioned downward edge → L1: disposeNode walks a dying node's own
33
+ * dep/sub lists and calls freeLink to extract it from the graph.
34
+ *
35
+ * L3 PROPAGATION / EXECUTION markDownstream, flushEffects, executeEffect, pullComputed
36
+ * The engine. markDownstream is itself owner-free and cursor-free
37
+ * (a pure propagation primitive). executeEffect/pullComputed are the
38
+ * ORCHESTRATORS: they drive the cursor + severTail (L1) AND, before a
39
+ * re-run, call runCleanup (L2) to cascade-dispose owned children.
40
+ * Sanctioned upward call → L2: executeEffect/pullComputed → runCleanup.
41
+ *
42
+ * API signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy
43
+ *
44
+ * The only cross-layer edges are L3→runCleanup and L2→freeLink. The graph of
45
+ * dependencies is acyclic; nothing in L1 reaches up, nothing in L2 touches
46
+ * the cursor, and the engine is the single place the two subsystems meet.
47
+ *
48
+ * ── OWNER vs OBSERVER ──
49
+ * `currentObserver` = the node whose READS establish dependencies (tracking).
50
+ * `currentOwner` = the node that OWNS anything created right now (lifecycle).
51
+ * Today they move together, so behaviour is unchanged — but they are distinct
52
+ * pointers so future runWithOwner/createRoot can attach ownership without
53
+ * establishing reactive dependencies (and untrack can suppress tracking
54
+ * without orphaning created nodes). createNode and onCleanup key off the
55
+ * OWNER; the read fast-path and allocateLink key off the OBSERVER.
50
56
  */
51
57
 
52
- // ─── Node flag bits ────────────────────────────────────────────────────────────
53
58
  const FLAG_COMPUTED = 1 << 0;
54
59
  const FLAG_EFFECT = 1 << 1;
55
60
  const FLAG_QUEUED = 1 << 2;
56
61
  const FLAG_COMPUTING = 1 << 3;
57
62
  const FLAG_HAS_ERROR = 1 << 4;
58
- const FLAG_SIGNAL = 1 << 5; // Identifies signals for universal disposal
63
+ const FLAG_SIGNAL = 1 << 5;
59
64
 
60
65
  /**
61
66
  * Internal: a reactive node (signal, computed, or effect).
@@ -64,7 +69,7 @@ const FLAG_SIGNAL = 1 << 5; // Identifies signals for universal disposal
64
69
  */
65
70
  class ReactiveNode {
66
71
  constructor() {
67
- /** Bitmask: FLAG_COMPUTED | FLAG_EFFECT | FLAG_QUEUED | FLAG_COMPUTING | FLAG_HAS_ERROR */
72
+ /** Bitmask: FLAG_SIGNAL | FLAG_COMPUTED | FLAG_EFFECT | FLAG_QUEUED | FLAG_COMPUTING | FLAG_HAS_ERROR */
68
73
  this.flags = 0;
69
74
  /** Current value (signal, computed) or error (when FLAG_HAS_ERROR is set). */
70
75
  this.value = undefined;
@@ -76,6 +81,9 @@ class ReactiveNode {
76
81
  this.equals = undefined;
77
82
  /** Optional effect scheduler. */
78
83
  this.scheduler = undefined;
84
+ /** Cached gen-bound trampoline that re-enters executeEffect under the scheduler.
85
+ * Allocated once on the first set with a scheduler; recycled with the slot. (1.2.0) */
86
+ this.schedulerThunk = undefined;
79
87
 
80
88
  /** Bumped on every change that mutates value. 32-bit modular. */
81
89
  this.version = 0;
@@ -83,10 +91,12 @@ class ReactiveNode {
83
91
  this.evalVersion = 0;
84
92
  /** Last globalVersion at which this node was marked dirty (de-duplicates traversal). */
85
93
  this.markEpoch = 0;
86
- /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures. */
94
+ /** Recycle generation: bumped on dispose, used to invalidate stale scheduler closures and disposer handles. */
87
95
  this.gen = 0;
96
+ /** Stable per-allocation id for introspection/devtools (1.1.5). Reassigned on each allocate-from-pool. */
97
+ this.id = 0;
88
98
 
89
- /** Captured value at first .set() inside the current batch (revert detection). */
99
+ /** Captured value at first .set() inside the current batch (pre-batch revert detection). */
90
100
  this.preBatchValue = undefined;
91
101
  /** Captured version at first .set() inside the current batch. */
92
102
  this.preBatchVersion = 0;
@@ -96,16 +106,22 @@ class ReactiveNode {
96
106
  // Doubly-linked dependency list (this node depends on these sources).
97
107
  this.headDep = null;
98
108
  this.tailDep = null;
99
- /** Cursor pointing into headDep during re-tracking. */
100
- this.currentDep = null;
101
109
  // Doubly-linked subscriber list (these targets depend on this node).
102
110
  this.headSub = null;
103
111
  this.tailSub = null;
104
112
 
113
+ // Owner Context Tree (Auto-Disposal of Nested Observers) — 1.2.0.
114
+ // An effect/computed created inside another effect/computed is "owned"
115
+ // by it. When the owner re-runs or is disposed, owned children are
116
+ // cascade-disposed before the new run. Plain signals are NOT adopted
117
+ // (so lazy-allocation wrappers like lite-store survive owner re-runs).
118
+ this.owner = null;
119
+ this.prevOwned = null;
120
+ this.nextOwned = null;
121
+ this.firstOwned = null;
122
+
105
123
  // Pool free-list pointer.
106
124
  this.nextFree = null;
107
-
108
- this.schedulerThunk = undefined;
109
125
  }
110
126
  }
111
127
 
@@ -151,7 +167,9 @@ export class CapacityError extends Error {
151
167
  * Create an isolated reactive registry.
152
168
  *
153
169
  * Use this when you need multiple independent reactive graphs (e.g. one per
154
- * Twitch Extension viewer, one per worker, one per test).
170
+ * Twitch Extension viewer, one per worker, one per test). The top-level
171
+ * helpers ({@link signal}, {@link effect}, …) delegate to a single shared
172
+ * default registry; call {@link setDefaultRegistry} to swap that for your own.
155
173
  *
156
174
  * @param {object} [config]
157
175
  * @param {number} [config.maxNodes=1024] Initial node-pool capacity.
@@ -160,155 +178,111 @@ export class CapacityError extends Error {
160
178
  * `"throw"` fails fast when pools are full.
161
179
  * `"grow"` doubles the pool (bounded by `maxLinks * 16` for links).
162
180
  * @param {number} [config.maxFlushPasses=100] Cycle-protection: max effect-queue
163
- * drain passes before throwing CycleError.
181
+ * drain passes before throwing an
182
+ * Error prefixed `"CycleError:"`.
164
183
  * @returns {Registry}
165
184
  */
166
- export function createRegistry(config = {}) {
167
- // Per-registry symbols. NODE_PTR carries a direct pool-slot reference;
168
- // NODE_GEN stamps the slot's generation at the moment of API creation
169
- // so dispose() can detect stale handles after the slot has been recycled.
185
+ export function createRegistry(config) {
170
186
  const NODE_PTR = Symbol("node_ptr");
171
187
  const NODE_GEN = Symbol("node_gen");
172
188
 
173
- let currentNodesCapacity = config.maxNodes ?? 1024;
174
- let currentLinkCapacity = config.maxLinks ?? currentNodesCapacity * 4;
175
- const policy = config.onCapacityExceeded ?? "throw";
176
- const maxFlushPasses = config.maxFlushPasses ?? 100;
189
+ let currentNodesCapacity = (config !== undefined && config.maxNodes !== undefined) ? config.maxNodes : 1024;
190
+ let currentLinkCapacity = (config !== undefined && config.maxLinks !== undefined) ? config.maxLinks : currentNodesCapacity * 4;
191
+ const policy = (config !== undefined && config.onCapacityExceeded !== undefined) ? config.onCapacityExceeded : "throw";
192
+ const maxFlushPasses = (config !== undefined && config.maxFlushPasses !== undefined) ? config.maxFlushPasses : 100;
177
193
  const maxLinkLimit = currentLinkCapacity * 16;
178
194
 
179
- // --- ZERO-GC OBJECT POOLS ---
180
195
  const nodePool = [];
181
-
182
196
  for (let i = 0; i < currentNodesCapacity; i++) nodePool[i] = new ReactiveNode();
183
-
184
197
  let freeNodeHead = nodePool[0];
185
-
186
198
  for (let i = 0; i < currentNodesCapacity - 1; i++) nodePool[i].nextFree = nodePool[i + 1];
187
199
 
188
200
  const linkPool = [];
189
-
190
201
  for (let i = 0; i < currentLinkCapacity; i++) linkPool[i] = new ReactiveLink();
191
-
192
202
  let freeLinkHead = linkPool[0];
193
-
194
203
  for (let i = 0; i < currentLinkCapacity - 1; i++) linkPool[i].nextFree = linkPool[i + 1];
195
204
 
196
- let activeNodes = 0 | 0;
197
- let activeLinks = 0 | 0;
198
- let statSignals = 0 | 0;
199
- let statComputeds = 0 | 0;
200
- let statEffects = 0 | 0;
205
+ let activeNodes = 0;
206
+ let activeLinks = 0;
207
+ let statSignals = 0;
208
+ let statComputeds = 0;
209
+ let statEffects = 0;
201
210
 
202
- // --- QUEUES & STACKS (Monomorphic arrays) ---
203
211
  const effectQueueA = [];
204
212
  const effectQueueB = [];
205
213
  const markStack = [];
206
-
207
- for (let i = 0; i < currentNodesCapacity; i++) {
208
- effectQueueA[i] = null;
209
- effectQueueB[i] = null;
210
- markStack[i] = null;
211
- }
212
-
213
214
  let activeQueue = effectQueueA;
214
- let activeQueueLen = 0 | 0;
215
+ let activeQueueLen = 0;
215
216
  let isQueueA = true;
216
217
 
217
- // --- GLOBAL STATE ---
218
- let globalVersion = 1 | 0; // Forced 32-bit SMI
219
- let batchEpoch = 1 | 0; // skip-zero sentinel; revertEpoch=0 means "no capture"
220
- let currentObserver = null;
218
+ let globalVersion = 1;
219
+ let batchEpoch = 1;
220
+ let currentObserver = null; // tracking context: whose reads link deps
221
+ let currentOwner = null; // lifecycle context: who owns nodes created now
221
222
  let activeObserverCurrentDep = null;
222
- let batchDepth = 0 | 0;
223
+ let batchDepth = 0;
223
224
  let isTrackingDeps = false;
224
- let isFlushing = false;
225
225
 
226
- // ── Observer-lifecycle introspection (opt-in; zero steady-state cost) ──
227
- // lifecycleCount gates the hot-path checks in allocateLink/freeLink: with no node
228
- // registered it stays 0 and each check folds to one predicted-false compare.
229
- // Callbacks live in a WeakMap, NOT on the node struct, so node creation/footprint
230
- // are untouched.
226
+ // ── Node identity + observer-lifecycle introspection (ported from 1.1.5) ──
227
+ let nodeSeq = 1 | 0;
231
228
  let lifecycleCount = 0 | 0;
232
229
  const lifecycleMap = new WeakMap();
233
-
234
230
  function fireConnect(node) {
235
231
  const e = lifecycleMap.get(node);
236
-
237
232
  if (e === undefined || e.onConnect === undefined) return;
238
-
239
233
  const po = currentObserver, pt = isTrackingDeps;
240
- currentObserver = null;
241
- isTrackingDeps = false;
242
-
243
- try {
244
- e.onConnect();
245
- } finally {
246
- currentObserver = po;
247
- isTrackingDeps = pt;
248
- }
234
+ currentObserver = null; isTrackingDeps = false;
235
+ try { e.onConnect(); } finally { currentObserver = po; isTrackingDeps = pt; }
249
236
  }
250
-
251
237
  function fireDisconnect(node) {
252
238
  const e = lifecycleMap.get(node);
253
-
254
239
  if (e === undefined || e.onDisconnect === undefined) return;
255
-
256
240
  const po = currentObserver, pt = isTrackingDeps;
257
- currentObserver = null;
258
- isTrackingDeps = false;
259
-
260
- try {
261
- e.onDisconnect();
262
- } finally {
263
- currentObserver = po;
264
- isTrackingDeps = pt;
265
- }
241
+ currentObserver = null; isTrackingDeps = false;
242
+ try { e.onDisconnect(); } finally { currentObserver = po; isTrackingDeps = pt; }
266
243
  }
244
+ let isFlushing = false;
267
245
 
268
- // Reused buffer for throw isolation during flush
269
246
  const flushErrorBuffer = [];
270
- let flushErrorCount = 0 | 0;
247
+ let flushErrorCount = 0;
271
248
 
272
- // --- ALLOCATORS ---
249
+ // ═══ L1 · GRAPH TOPOLOGY ══════════════════════════════════════
250
+ // Owns the ReactiveLink pool and the dep/sub lists. Pure edge mechanics:
251
+ // INVARIANT — must never touch node.owner / firstOwned.
252
+
253
+ // ─── HYBRID ALLOCATOR ─────────────────────────────────────────
273
254
 
274
255
  /**
275
256
  * Establish (or reuse) a dependency link from `source` → `target`.
276
257
  *
277
258
  * Fast path: cursor match (re-tracking same dep at same position) — O(1), no allocation.
278
- * Slow path: linear search of target.headDep for an existing link O(N) in N deps.
259
+ * Mid path: O(1) tailSub dedup (1.1.4 rewrite) divergent retracking stays O(N) overall,
260
+ * not O(N²).
279
261
  * Cold path: pool exhausted → grow or throw per policy.
280
262
  *
263
+ * SEVER-FIRST: on a cursor-miss divergence the unmatched dep tail is freed
264
+ * BEFORE any new link is allocated, so peak link usage never exceeds steady
265
+ * state (zero pool debt) and a divergent re-track cannot trigger mid-compute
266
+ * pool growth under tight maxLinks + "throw".
267
+ *
268
+ * EDGE NOTE: a node that reads the SAME source twice within one body, with a
269
+ * nested computed that also reads that source evaluated in between, retains
270
+ * one redundant link per intervening observer for the node's lifetime. Value-
271
+ * correct, bounded (does not grow across re-tracks), and reclaimed on dispose.
272
+ *
281
273
  * @private
282
274
  */
283
275
  function allocateLink(source, target) {
284
- // Eligibility gate (lite's analogue of alien-signals' shouldTrack): if the observer was
285
- // disposed mid-run — self-dispose, or an outer observer torn down while suspended its
286
- // flags are cleared to 0. Linking would splice a dead, pool-bound node back into `source`'s
287
- // subscriber list: a phantom edge that mis-targets the instant the slot is recycled. Skip it.
288
- // Cold path only (the inline cursor-hit fast path never reaches here) -> steady-state reads
289
- // pay nothing. Legit tracking always passes a live observer (flags != 0).
276
+ // Eligibility gate (restored from 1.1.5): an observer disposed mid-run (self-dispose, or
277
+ // 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.
290
279
  if (target.flags === 0) return null;
291
280
  let expected = activeObserverCurrentDep;
292
- // Dead on this build: the inline cursor fast-path inside every read
293
- // consumes a cursor *hit* before allocateLink runs, so on entry the
294
- // cursor never matches `source`. Kept for the direct-call contract.
295
- /* c8 ignore start */
296
- if (expected !== null && expected.source === source) {
297
- activeObserverCurrentDep = expected.nextDep;
298
- return expected;
299
- }
300
- /* c8 ignore stop */
301
281
 
302
- // SEVER-FIRST (zero pool debt): on any divergence, free the entire
303
- // unmatched tail BEFORE allocating, so peak link usage never exceeds
304
- // steady-state. Trades a little extra free/alloc churn on small reorders
305
- // for honoring the zero-GC budget under tight maxLinks + "throw".
306
282
  if (expected !== null) {
307
283
  let stale = expected;
308
284
  let prev = stale.prevDep;
309
-
310
285
  if (prev !== null) prev.nextDep = null; else target.headDep = null;
311
-
312
286
  target.tailDep = prev;
313
287
 
314
288
  while (stale !== null) {
@@ -316,74 +290,60 @@ export function createRegistry(config = {}) {
316
290
  freeLink(stale, target, stale.source);
317
291
  stale = next;
318
292
  }
319
-
320
293
  activeObserverCurrentDep = null;
321
294
  }
322
295
 
323
- // O(1) same-pass dedup (covers double-read of a still-linked source).
296
+ // O(1) same-pass dedup (ported from 1.1.3): replaces the O(N) prefix scan
297
+ // that made divergent re-tracking O(N^2). If this source was already
298
+ // linked to this target during THIS pass, its sub-list tail points at us.
324
299
  const lastSub = source.tailSub;
325
-
326
- if (lastSub !== null && lastSub.target === target) return lastSub;
300
+ if (lastSub !== null && lastSub.target === target) return;
327
301
 
328
302
  let link;
329
- {
330
- if (freeLinkHead === null) {
331
- if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
332
-
333
- const newCap = currentLinkCapacity * 2;
334
-
335
- if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
336
-
337
- const newLinks = new Array(newCap - currentLinkCapacity);
338
-
339
- for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
340
- for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
341
-
342
- const startIdx = linkPool.length;
343
- linkPool.length = newCap;
344
-
345
- for (let i = 0; i < newLinks.length; i++) linkPool[startIdx + i] = newLinks[i];
346
-
347
- freeLinkHead = newLinks[0];
348
- currentLinkCapacity = newCap;
349
- }
350
-
351
- link = freeLinkHead;
352
- freeLinkHead = link.nextFree;
353
- link.nextFree = null;
354
- activeLinks = (activeLinks + 1) | 0;
355
-
356
- link.source = source;
357
- link.target = target;
358
-
359
- link.nextSub = null;
360
- link.prevSub = source.tailSub;
361
- const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0→1 detect (pre-link)
303
+ if (freeLinkHead === null) {
304
+ if (policy === "throw") throw new CapacityError("links", currentLinkCapacity);
305
+ const newCap = currentLinkCapacity * 2;
306
+ if (newCap > maxLinkLimit) throw new CapacityError("links", maxLinkLimit);
307
+
308
+ const newLinks = new Array(newCap - currentLinkCapacity);
309
+ for (let i = 0; i < newLinks.length; i++) newLinks[i] = new ReactiveLink();
310
+ for (let i = 0; i < newLinks.length - 1; i++) newLinks[i].nextFree = newLinks[i + 1];
311
+
312
+ const startIdx = linkPool.length;
313
+ linkPool.length = newCap;
314
+ for (let i = 0; i < newLinks.length; i++) linkPool[startIdx + i] = newLinks[i];
315
+ freeLinkHead = newLinks[0];
316
+ currentLinkCapacity = newCap;
317
+ }
362
318
 
363
- if (source.tailSub !== null) source.tailSub.nextSub = link; else source.headSub = link;
319
+ link = freeLinkHead;
320
+ freeLinkHead = link.nextFree;
321
+ link.nextFree = null;
322
+ activeLinks = (activeLinks + 1) | 0;
364
323
 
365
- source.tailSub = link;
324
+ link.source = source;
325
+ link.target = target;
366
326
 
367
- if (_was0) fireConnect(source);
368
- }
327
+ link.nextSub = null;
328
+ link.prevSub = source.tailSub;
329
+ const _was0 = lifecycleCount !== 0 && source.headSub === null; // 0→1 detect (pre-link)
330
+ if (source.tailSub !== null) source.tailSub.nextSub = link;
331
+ else source.headSub = link;
332
+ source.tailSub = link;
333
+ if (_was0) fireConnect(source);
369
334
 
370
- // Append at the (post-sever) tail.
371
335
  let tail = target.tailDep;
372
336
  link.prevDep = tail;
373
337
  link.nextDep = null;
374
-
375
- if (tail !== null) tail.nextDep = link; else target.headDep = link;
376
-
338
+ if (tail !== null) tail.nextDep = link;
339
+ else target.headDep = link;
377
340
  target.tailDep = link;
378
-
379
- return link;
380
341
  }
381
342
 
382
343
  /** Return a link to the free pool and unlink it from the source's sub list. @private */
383
344
  function freeLink(link, target, source) {
384
345
  const pSub = link.prevSub;
385
346
  const nSub = link.nextSub;
386
-
387
347
  if (pSub !== null) pSub.nextSub = nSub; else source.headSub = nSub;
388
348
  if (nSub !== null) nSub.prevSub = pSub; else source.tailSub = pSub;
389
349
  if (lifecycleCount !== 0 && source.headSub === null) fireDisconnect(source); // 1→0
@@ -400,26 +360,88 @@ export function createRegistry(config = {}) {
400
360
  activeLinks = (activeLinks - 1) | 0;
401
361
  }
402
362
 
403
- // --- MANUAL MEMORY MANAGEMENT ---
363
+ /**
364
+ * Free any tail links not visited during the current re-tracking pass.
365
+ * Called from executeEffect / pullComputed after the body returns: anything
366
+ * still reachable from `activeObserverCurrentDep` is a stale dep from the
367
+ * previous run and gets returned to the pool.
368
+ * @private
369
+ */
370
+ function severTail(node) {
371
+ let stale = activeObserverCurrentDep;
372
+ if (stale !== null) {
373
+ let prev = stale.prevDep;
374
+ if (prev !== null) prev.nextDep = null; else node.headDep = null;
375
+ node.tailDep = prev;
376
+
377
+ while (stale !== null) {
378
+ let next = stale.nextDep;
379
+ freeLink(stale, node, stale.source);
380
+ stale = next;
381
+ }
382
+ }
383
+ }
384
+
385
+ // ═══ L2 · OWNERSHIP / LIFECYCLE ═══════════════════════════════
386
+ // 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
389
+ // dying node from the graph.
390
+
391
+ // ─── LIFECYCLE & OWNERSHIP ───────────────────────────────────────
404
392
 
405
393
  function disposeNode(node) {
406
- /* c8 ignore next -- redundant: both callers (effect handle, dispose(api)) pre-check flags!==0; destroy() resets slots inline */
407
- if (node.flags === 0) return; // Already freed
394
+ if (node.flags === 0) return;
395
+
396
+ // RACE WITH ACTIVE TRACKING: an effect/computed may call dispose on
397
+ // itself from inside its own body (#141). Once we tear the node down
398
+ // its dep-list, FLAG_COMPUTING, and cursor become stale immediately —
399
+ // any read() that runs in the REST of the body would otherwise try to
400
+ // hang a fresh link off a freed slot. Null the tracking state now so
401
+ // subsequent reads in this call stack become no-ops, and let
402
+ // executeEffect / pullComputed skip their finally-block bookkeeping
403
+ // via the gen-snapshot guard there.
404
+ if (currentObserver === node) {
405
+ currentObserver = null;
406
+ activeObserverCurrentDep = null;
407
+ isTrackingDeps = false;
408
+ }
409
+ if (currentOwner === node) {
410
+ currentOwner = null;
411
+ }
412
+
413
+ // Live per-kind count: decrement here -- the single chokepoint every teardown
414
+ // path funnels through (owner cascade at the firstOwned loop, the effect
415
+ // disposer, and dispose(api)). Keyed off flags BEFORE they are cleared lower
416
+ // in this function; the guard above makes it double-dispose-safe. This is what
417
+ // keeps stats() honest: signals + computeds + effects === activeNodes holds
418
+ // under owner-cascade disposal, not just explicit dispose.
419
+ const f = node.flags;
420
+ if ((f & FLAG_SIGNAL) !== 0) statSignals--;
421
+ else if ((f & FLAG_COMPUTED) !== 0) statComputeds--;
422
+ else if ((f & FLAG_EFFECT) !== 0) statEffects--;
423
+
424
+ // O(1) detach from parent to avoid modifying list during parent iteration
425
+ if (node.owner !== null) {
426
+ if (node.prevOwned !== null) node.prevOwned.nextOwned = node.nextOwned;
427
+ else node.owner.firstOwned = node.nextOwned;
428
+ if (node.nextOwned !== null) node.nextOwned.prevOwned = node.prevOwned;
429
+ node.owner = null;
430
+ node.prevOwned = null;
431
+ node.nextOwned = null;
432
+ }
408
433
 
409
434
  runCleanup(node);
410
435
 
411
- // 1. Unlink from dependencies
436
+ // CROSS-EDGE L2→L1: extract this node's own edges from the graph.
412
437
  let dLink = node.headDep;
413
-
414
438
  while (dLink !== null) {
415
439
  const next = dLink.nextDep;
416
440
  freeLink(dLink, node, dLink.source);
417
441
  dLink = next;
418
442
  }
419
443
 
420
- // 2. Unlink from subscribers
421
444
  let sLink = node.headSub;
422
-
423
445
  while (sLink !== null) {
424
446
  const target = sLink.target;
425
447
  const next = sLink.nextSub;
@@ -442,20 +464,17 @@ export function createRegistry(config = {}) {
442
464
  sLink = next;
443
465
  }
444
466
 
445
- // 3. Clear node state and return to pool
446
467
  node.computeFn = undefined;
447
468
  node.cleanupFn = undefined;
448
469
  node.scheduler = undefined;
449
- node.schedulerThunk = undefined;
470
+ node.schedulerThunk = undefined; // drop closure; recycle rebuilds it
450
471
  node.value = undefined;
451
472
  node.equals = undefined;
452
473
  node.flags = 0;
453
474
  node.headDep = null;
454
475
  node.tailDep = null;
455
- node.currentDep = null;
456
476
  node.headSub = null;
457
477
  node.tailSub = null;
458
-
459
478
  node.revertEpoch = 0;
460
479
  node.preBatchValue = undefined;
461
480
  node.preBatchVersion = 0;
@@ -468,30 +487,28 @@ export function createRegistry(config = {}) {
468
487
 
469
488
  /**
470
489
  * Claim a node from the free pool, reinitialise, and return it.
471
- * Grows pool per `policy` if exhausted.
490
+ * Grows pool per `policy` if exhausted (or throws CapacityError under "throw").
491
+ * 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
493
+ * comment on the owner tree).
472
494
  * @private
473
495
  */
474
496
  function createNode(value, flags) {
475
497
  if (freeNodeHead === null) {
476
498
  if (policy === "throw") throw new CapacityError("nodes", currentNodesCapacity);
477
499
  const newCap = currentNodesCapacity * 2;
478
-
479
500
  const newNodes = new Array(newCap - currentNodesCapacity);
480
501
  for (let i = 0; i < newNodes.length; i++) newNodes[i] = new ReactiveNode();
481
502
  for (let i = 0; i < newNodes.length - 1; i++) newNodes[i].nextFree = newNodes[i + 1];
482
503
 
483
504
  const startIdx = nodePool.length;
484
- nodePool.length = newCap; // Pre-allocate the new length
485
- for (let i = 0; i < newNodes.length; i++) {
486
- nodePool[startIdx + i] = newNodes[i];
487
- }
488
-
505
+ nodePool.length = newCap;
506
+ for (let i = 0; i < newNodes.length; i++) nodePool[startIdx + i] = newNodes[i];
489
507
  freeNodeHead = newNodes[0];
490
508
 
491
509
  effectQueueA.length = newCap;
492
510
  effectQueueB.length = newCap;
493
511
  markStack.length = newCap;
494
-
495
512
  currentNodesCapacity = newCap;
496
513
  }
497
514
 
@@ -504,7 +521,6 @@ export function createRegistry(config = {}) {
504
521
  node.flags = flags | 0;
505
522
  node.headDep = null;
506
523
  node.tailDep = null;
507
- node.currentDep = null;
508
524
  node.headSub = null;
509
525
  node.tailSub = null;
510
526
  node.version = 0;
@@ -513,34 +529,96 @@ export function createRegistry(config = {}) {
513
529
  node.revertEpoch = 0;
514
530
  node.preBatchValue = undefined;
515
531
  node.preBatchVersion = 0;
532
+ node.id = nodeSeq; nodeSeq = (nodeSeq + 1) | 0; // fresh identity per allocation (ported from 1.1.5)
533
+
534
+ // Wire into Owner Context (lifecycle, not tracking — keyed off currentOwner).
535
+ // ONLY observers (computed/effect) are adopted: a re-running owner disposes
536
+ // its nested observers (which would otherwise leak dep links), but plain
537
+ // signals have no deps to leak, and disposing them breaks lazy-allocation
538
+ // 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
540
+ // 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;
547
+ if (currentOwner !== null && (flags & (FLAG_COMPUTED | FLAG_EFFECT)) !== 0) {
548
+ node.owner = currentOwner;
549
+ node.prevOwned = null;
550
+ node.nextOwned = currentOwner.firstOwned;
551
+ if (currentOwner.firstOwned !== null) {
552
+ currentOwner.firstOwned.prevOwned = node;
553
+ }
554
+ currentOwner.firstOwned = node;
555
+ } else {
556
+ node.owner = null;
557
+ }
558
+
516
559
  return node;
517
560
  }
518
561
 
519
- /** Invoke registered cleanup function(s) on `node` and clear. @private */
562
+ /**
563
+ * Cascade-dispose owned children inside-out (deepest first), then invoke this
564
+ * 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
566
+ * so that a parent's cleanup still sees its own state intact.
567
+ * @private
568
+ */
520
569
  function runCleanup(node) {
521
- const cleanup = node.cleanupFn;
522
-
523
- if (cleanup === undefined) return;
524
-
525
- const prevObserver = currentObserver;
526
- const prevTracking = isTrackingDeps;
527
- currentObserver = null;
528
- isTrackingDeps = false;
570
+ // Cascade children FIRST — deepest cleanups fire before shallowest.
571
+ // This matches the universal invariant in the upstream conformance suite
572
+ // (#238 / #241 / #243): nested cleanups run inside-out on owner-tree
573
+ // disposal, mirroring the parent-knows-best assumption shared with
574
+ // React / Solid (children may rely on parent state being live at their
575
+ // cleanup time, but never the reverse).
576
+ let child = node.firstOwned;
577
+ while (child !== null) {
578
+ let next = child.nextOwned;
579
+ // Detach immediately to optimise disposeNode processing
580
+ child.owner = null;
581
+ child.prevOwned = null;
582
+ child.nextOwned = null;
583
+ disposeNode(child);
584
+ child = next;
585
+ }
586
+ node.firstOwned = null;
529
587
 
530
- try {
531
- if (typeof cleanup === "function") cleanup();
532
- else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
533
- } finally {
534
- node.cleanupFn = undefined;
535
- currentObserver = prevObserver;
536
- isTrackingDeps = prevTracking;
588
+ // Then this node's own cleanup.
589
+ const cleanup = node.cleanupFn;
590
+ if (cleanup !== undefined) {
591
+ const prevObserver = currentObserver;
592
+ const prevOwner = currentOwner;
593
+ const prevTracking = isTrackingDeps;
594
+ currentObserver = null;
595
+ currentOwner = null;
596
+ isTrackingDeps = false;
597
+ try {
598
+ if (typeof cleanup === "function") cleanup();
599
+ else for (let i = 0; i < cleanup.length; i++) cleanup[i]();
600
+ } finally {
601
+ node.cleanupFn = undefined;
602
+ currentObserver = prevObserver;
603
+ currentOwner = prevOwner;
604
+ isTrackingDeps = prevTracking;
605
+ }
537
606
  }
538
607
  }
539
608
 
609
+ // ═══ L3 · PROPAGATION / EXECUTION ═════════════════════════════
610
+ // markDownstream is owner-free AND cursor-free (a pure propagation
611
+ // primitive). executeEffect/pullComputed are the orchestrators: they drive
612
+ // the cursor + severTail (L1) and, before a re-run, call runCleanup (L2) to
613
+ // cascade-dispose owned children. Sanctioned upward call → L2: runCleanup.
614
+
615
+ // ─── EXECUTION ENGINE ─────────────────────────────────────────
616
+
540
617
  /**
541
618
  * Mark all transitive subscribers of `startNode` dirty.
542
619
  * Iterative DFS via the markStack to avoid call-stack growth.
543
- * Effects are enqueued for the flush phase; computeds are merely marked.
620
+ * Effects are enqueued for the flush phase; computeds are merely marked
621
+ * (their re-evaluation is lazy — triggered by the next read).
544
622
  * @private
545
623
  */
546
624
  function markDownstream(startNode) {
@@ -549,25 +627,15 @@ export function createRegistry(config = {}) {
549
627
 
550
628
  while (stackLen !== 0) {
551
629
  const n = markStack[--stackLen];
552
-
553
630
  let link = n.headSub;
631
+
554
632
  while (link !== null) {
555
633
  const t = link.target;
556
-
557
- if ((t.markEpoch | 0) !== (globalVersion | 0)) {
558
- t.markEpoch = globalVersion | 0;
559
- // flags read stays INSIDE the markEpoch guard on purpose: hoisting it
560
- // above the guard would load t.flags for every already-marked revisit
561
- // too, adding work on exactly the dedup-skip path that markEpoch exists
562
- // to make cheap (diamond / shared-computed fan-out).
563
- const flags = t.flags | 0;
634
+ if (t.markEpoch !== globalVersion) {
635
+ t.markEpoch = globalVersion;
636
+ const flags = t.flags;
564
637
 
565
638
  if ((flags & FLAG_EFFECT) !== 0) {
566
- // "No re-run" semantics for self-cycles: an effect that is currently
567
- // executing on the call stack must not be re-queued by its own body's
568
- // writes (directly or through computed chains). The effect's evalVersion
569
- // is bumped to the post-write globalVersion in its executeEffect finally,
570
- // so subsequent unrelated writes still propagate normally.
571
639
  if ((flags & (FLAG_QUEUED | FLAG_COMPUTING)) === 0) {
572
640
  t.flags = flags | FLAG_QUEUED;
573
641
  activeQueue[activeQueueLen++] = t;
@@ -576,218 +644,166 @@ export function createRegistry(config = {}) {
576
644
  markStack[stackLen++] = t;
577
645
  }
578
646
  }
579
-
580
647
  link = link.nextSub;
581
648
  }
582
649
  }
583
650
  }
584
651
 
585
652
  /**
586
- * Execute an effect through a scheduler trampoline. Guards against running
587
- * a stale node (disposed and recycled before the scheduler fired).
588
- * @private
589
- */
590
- function safeExecute(node, gen) {
591
- if ((node.gen | 0) !== (gen | 0)) return;
592
- /* c8 ignore next -- unreachable after the gen guard: every disposal bumps node.gen, so a non-effect slot fails the gen check above first */
593
- if ((node.flags & FLAG_EFFECT) === 0) return;
594
-
595
- executeEffect(node);
596
- }
597
-
598
- /**
599
- * Drain the effect queue. Double-buffered so new effects scheduled mid-flush
600
- * end up in the next pass. Individual effect throws are caught, buffered, and
601
- * re-thrown at the end of the flush (or wrapped in AggregateError if multiple).
653
+ * Drain the effect queue. Double-buffered (effectQueueA / effectQueueB) so
654
+ * effects scheduled mid-flush land in the next pass. Individual effect throws
655
+ * are caught and buffered; at end-of-flush a single throw is rethrown directly,
656
+ * multiple throws are aggregated into an `AggregateError` (1.2.0). Exceeds
657
+ * `maxFlushPasses` (default 100) → Error prefixed `"CycleError:"`.
602
658
  * @private
603
659
  */
604
660
  function flushEffects() {
605
661
  if (isFlushing) return;
606
662
  isFlushing = true;
607
- let passes = 0 | 0;
663
+ let passes = 0;
608
664
  let normalExit = false;
609
665
 
610
666
  try {
611
667
  while (activeQueueLen > 0) {
612
- passes = (passes + 1) | 0;
613
- if (passes > maxFlushPasses) {
614
- throw new Error("CycleError: flush passes exceeded");
615
- }
616
-
668
+ if (++passes > maxFlushPasses) throw new Error("CycleError: flush passes exceeded");
617
669
  const toRun = activeQueueLen | 0;
618
670
  const currentQueue = activeQueue;
619
671
 
620
672
  isQueueA = !isQueueA;
621
673
  activeQueue = isQueueA ? effectQueueA : effectQueueB;
622
- activeQueueLen = 0 | 0;
674
+ activeQueueLen = 0;
623
675
 
624
676
  for (let i = 0; i < toRun; i++) {
625
677
  const node = currentQueue[i];
626
-
627
678
  try {
628
679
  const scheduler = node.scheduler;
629
-
630
680
  if (scheduler) {
631
- scheduler(node.schedulerThunk);
681
+ scheduler(node.schedulerThunk); // reuse cached thunk
632
682
  } else {
633
- executeEffect(node);
683
+ if ((node.flags & FLAG_EFFECT) !== 0) executeEffect(node);
634
684
  }
635
685
  } catch (err) {
636
- // Buffer and continue. Effect's own try/finally inside
637
- // executeEffect already restored observer state and
638
- // severed tail deps before the throw landed here.
639
- flushErrorBuffer[flushErrorCount] = err;
640
- flushErrorCount = (flushErrorCount + 1) | 0;
686
+ flushErrorBuffer[flushErrorCount++] = err;
641
687
  }
642
688
  }
643
689
  }
644
690
  normalExit = true;
645
691
  } finally {
646
692
  isFlushing = false;
647
-
648
693
  if (!normalExit) {
649
- // Escaping via CycleError or any non-effect-body throw. Discard
650
- // buffered effect errors — the structural failure supersedes them
651
- // and prevents leaking stale errors to the next flush call.
652
694
  for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
653
-
654
- flushErrorCount = 0 | 0;
695
+ flushErrorCount = 0;
655
696
  }
656
697
  }
657
698
 
658
699
  if (flushErrorCount > 0) {
659
700
  if (flushErrorCount === 1) {
660
701
  const err = flushErrorBuffer[0];
661
- flushErrorBuffer[0] = null; // drop reference, retain backing store
662
- flushErrorCount = 0 | 0;
702
+ flushErrorBuffer[0] = null;
703
+ flushErrorCount = 0;
663
704
  throw err;
664
705
  }
665
- // 2+ errors: snapshot into a fresh array for AggregateError, then clear.
666
706
  const errs = flushErrorBuffer.slice(0, flushErrorCount);
667
-
668
707
  for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
669
-
670
- flushErrorCount = 0 | 0;
671
-
708
+ flushErrorCount = 0;
672
709
  throw new AggregateError(errs, "Effects threw during flush");
673
710
  }
674
711
  }
675
712
 
676
- /**
677
- * Free any tail links not visited during the current re-tracking pass.
678
- * Called after computeFn returns: anything still hanging off the cursor is stale.
679
- * @private
680
- */
681
- function severTail(node) {
682
- let stale = activeObserverCurrentDep;
683
-
684
- if (stale !== null) {
685
- let prev = stale.prevDep;
686
-
687
- if (prev !== null) prev.nextDep = null; else node.headDep = null;
688
-
689
- node.tailDep = prev;
690
-
691
- while (stale !== null) {
692
- let next = stale.nextDep;
693
- freeLink(stale, node, stale.source);
694
- stale = next;
695
- }
696
- }
697
- }
698
-
699
713
  /**
700
714
  * Run an effect's compute body, re-tracking dependencies.
701
715
  * Short-circuits if no dependency has bumped its version since last eval.
716
+ * If the body self-disposes (node.gen advances during the body), skips the
717
+ * post-body bookkeeping (severTail, flag clear, evalVersion bump) — that
718
+ * gen-snapshot guard is the v1.2 conformance fix for #141.
702
719
  * @private
703
720
  */
704
721
  function executeEffect(node) {
705
- /* c8 ignore next -- defense-in-depth: writes during a flush re-queue for the next pass, so an effect cannot synchronously re-enter executeEffect */
706
722
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Infinite effect loop detected.");
707
723
 
708
- const isFirst = node.evalVersion === 0;
709
-
710
- if (!isFirst) {
724
+ if (node.evalVersion !== 0) {
711
725
  let link = node.headDep;
712
726
  const evalVer = node.evalVersion | 0;
713
727
  let needsRun = false;
714
728
 
715
729
  while (link !== null) {
716
730
  const dep = link.source;
717
-
718
731
  if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
719
-
720
- // Overflow-safe modular arithmetic version check
721
732
  if (((dep.version - evalVer) | 0) > 0) {
722
733
  needsRun = true;
723
734
  break;
724
735
  }
725
736
  link = link.nextDep;
726
737
  }
738
+
727
739
  if (!needsRun) {
728
- node.flags = node.flags & ~FLAG_QUEUED;
729
- node.evalVersion = globalVersion | 0;
740
+ node.flags &= ~FLAG_QUEUED;
741
+ node.evalVersion = globalVersion;
730
742
  return;
731
743
  }
732
744
  }
733
745
 
734
746
  node.flags = (node.flags & ~FLAG_QUEUED) | FLAG_COMPUTING;
735
-
736
- runCleanup(node);
737
-
738
- // Cleanup may have disposed us (e.g. via a synchronous dispose() call from
739
- // within the user's cleanup body). disposeNode clears flags to 0 and nulls
740
- // computeFn; bailing here prevents a TypeError on computeFn() below and
741
- // avoids reinitialising observer state on a freed slot.
747
+ runCleanup(node); // CROSS-EDGE L3→L2: dispose owned children before re-run
742
748
  if ((node.flags & FLAG_EFFECT) === 0) return;
743
749
 
744
750
  const prevObserver = currentObserver;
751
+ const prevOwner = currentOwner;
745
752
  const prevActiveDep = activeObserverCurrentDep;
746
753
  const prevTracking = isTrackingDeps;
747
754
 
748
755
  currentObserver = node;
756
+ currentOwner = node;
749
757
  activeObserverCurrentDep = node.headDep;
750
758
  isTrackingDeps = true;
751
759
 
760
+ // SELF-DISPOSE DETECTION: snapshot the gen. disposeNode bumps gen,
761
+ // so if it advanced during the body the node was disposed (and may
762
+ // already have been recycled into a different role). Skip the
763
+ // dep-list / flag / version mutations in that case — they would
764
+ // either crash on the freed link list or corrupt the new resident.
765
+ const savedGen = node.gen;
752
766
  try {
753
767
  node.computeFn();
754
768
  } finally {
755
- severTail(node);
756
- node.currentDep = activeObserverCurrentDep;
757
-
769
+ if (node.gen === savedGen) {
770
+ severTail(node);
771
+ node.flags &= ~FLAG_COMPUTING;
772
+ node.evalVersion = globalVersion;
773
+ }
758
774
  currentObserver = prevObserver;
775
+ currentOwner = prevOwner;
759
776
  activeObserverCurrentDep = prevActiveDep;
760
777
  isTrackingDeps = prevTracking;
761
-
762
- node.flags = node.flags & ~FLAG_COMPUTING;
763
- node.evalVersion = globalVersion | 0;
764
778
  }
765
779
  }
766
780
 
767
781
  /**
768
- * Resolve a computed node's current value: re-run if a dependency has
769
- * changed since last evaluation, else return cached value.
782
+ * Resolve a computed node's current value: re-run if a dependency has changed
783
+ * since last evaluation, else return cached value. The clean-read short-circuit
784
+ * via markEpoch (1.1.4) returns the cached value in O(1) when no mark landed
785
+ * in this node's transitive cone since the last eval, instead of walking the
786
+ * whole dependency subtree.
770
787
  *
771
788
  * Errors thrown by computeFn are captured in `node.value` with FLAG_HAS_ERROR;
772
789
  * subsequent reads re-throw until a dependency change re-runs computeFn.
773
790
  *
791
+ * Same gen-snapshot self-dispose guard as executeEffect — see #141 fix.
792
+ *
774
793
  * @private
775
794
  */
776
795
  function pullComputed(node) {
777
- if ((node.evalVersion | 0) === (globalVersion | 0)) {
796
+ if (node.evalVersion === globalVersion) {
778
797
  if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
779
798
  return node.value;
780
799
  }
781
800
 
782
- // CLEAN SHORT-CIRCUIT: markDownstream stamps markEpoch=globalVersion on
783
- // every node in a changed signal's transitive cone. If no mark landed
784
- // at-or-after our last eval, nothing we depend on changed -> cached value
785
- // is still valid; skip the (recursive) dependency walk entirely. O(1).
801
+ // CLEAN SHORT-CIRCUIT (ported from 1.1.3): markDownstream already stamps
802
+ // markEpoch on the changed signal's whole cone; if no mark landed since
803
+ // our last eval, the cached value is valid -> skip the dep walk. O(1).
786
804
  if (node.evalVersion !== 0 && ((node.markEpoch - node.evalVersion) | 0) <= 0) {
787
805
  node.evalVersion = globalVersion | 0;
788
-
789
806
  if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
790
-
791
807
  return node.value;
792
808
  }
793
809
 
@@ -795,71 +811,103 @@ export function createRegistry(config = {}) {
795
811
  if (!shouldRun) {
796
812
  let link = node.headDep;
797
813
  const evalVer = node.evalVersion | 0;
798
-
799
814
  while (link !== null) {
800
815
  const dep = link.source;
801
-
802
816
  if ((dep.flags & FLAG_COMPUTED) !== 0) pullComputed(dep);
803
- // Modular Arithmetic 32-bit Wrap Check
804
817
  if (((dep.version - evalVer) | 0) > 0) {
805
818
  shouldRun = true;
806
819
  break;
807
820
  }
808
-
809
821
  link = link.nextDep;
810
822
  }
811
823
  }
812
824
 
813
825
  if (shouldRun) {
814
826
  if ((node.flags & FLAG_COMPUTING) !== 0) throw new Error("CycleError: Circular dependency detected.");
815
-
816
- node.flags = node.flags | FLAG_COMPUTING;
817
-
818
- // Run cleanups registered during the previous compute pass before re-tracking.
819
- // Mirrors effect semantics so `onCleanup` works in both kinds of observer.
820
- runCleanup(node);
827
+ node.flags |= FLAG_COMPUTING;
828
+ runCleanup(node); // CROSS-EDGE L3→L2: dispose owned children before recompute
821
829
 
822
830
  const prevObserver = currentObserver;
831
+ const prevOwner = currentOwner;
823
832
  const prevActiveDep = activeObserverCurrentDep;
824
833
  const prevTracking = isTrackingDeps;
825
834
 
826
835
  currentObserver = node;
836
+ currentOwner = node;
827
837
  activeObserverCurrentDep = node.headDep;
828
838
  isTrackingDeps = true;
829
839
 
840
+ // Same self-dispose detection as executeEffect — see comment there.
841
+ const savedGen = node.gen;
830
842
  try {
831
843
  const newValue = node.computeFn();
832
844
  const eq = node.equals;
833
-
834
845
  if (node.evalVersion === 0 || !eq || !eq(node.value, newValue)) {
835
846
  node.value = newValue;
836
- node.version = globalVersion | 0;
847
+ node.version = globalVersion;
837
848
  }
838
- node.flags = node.flags & ~FLAG_HAS_ERROR;
849
+ node.flags &= ~FLAG_HAS_ERROR;
839
850
  } catch (err) {
840
- node.value = err;
841
- node.flags = node.flags | FLAG_HAS_ERROR;
842
- node.version = globalVersion | 0;
851
+ if (node.gen === savedGen) {
852
+ node.value = err;
853
+ node.flags |= FLAG_HAS_ERROR;
854
+ node.version = globalVersion;
855
+ } else {
856
+ // The body disposed `node` and then threw. The error has
857
+ // nowhere to land — the caller of the read that triggered
858
+ // this pull has already had its tracking state torn down.
859
+ // Swallow rather than corrupt a recycled slot. The
860
+ // canonical thrown-computed test (#168 / cached error)
861
+ // does NOT self-dispose, so this branch isn't reachable
862
+ // from the conformance set.
863
+ }
843
864
  } finally {
844
- severTail(node);
845
- node.currentDep = activeObserverCurrentDep;
846
-
865
+ if (node.gen === savedGen) {
866
+ severTail(node);
867
+ node.flags &= ~FLAG_COMPUTING;
868
+ }
847
869
  currentObserver = prevObserver;
870
+ currentOwner = prevOwner;
848
871
  activeObserverCurrentDep = prevActiveDep;
849
872
  isTrackingDeps = prevTracking;
850
-
851
- node.flags = node.flags & ~FLAG_COMPUTING;
852
873
  }
853
874
  }
854
875
 
855
- node.evalVersion = globalVersion | 0;
856
-
876
+ if (node.flags === 0) return undefined; // disposed during body
877
+ node.evalVersion = globalVersion;
857
878
  if ((node.flags & FLAG_HAS_ERROR) !== 0) throw node.value;
858
-
859
879
  return node.value;
860
880
  }
861
881
 
862
- // --- PUBLIC API SURFACE ---
882
+ // ─── PUBLIC API ──────────────────────────────────────────────────
883
+
884
+ // ─── shared accessor methods (one set per registry, not per primitive) ───────
885
+ // update/subscribe are method-invoked (s.update(fn), s.subscribe(fn)), so `this`
886
+ // is the read function and this[NODE_PTR] is the node. set() and peek() stay
887
+ // closures: set() is the hot write path (a closure over `node` beats the
888
+ // this[NODE_PTR] load and keeps `const {set} = signal()` working), and peek()'s
889
+ // body is too cheap to absorb the node recovery.
890
+ function sharedUpdate(fn) { return this.set(fn(this[NODE_PTR].value)); }
891
+ function sharedSubscribe(fn) {
892
+ const read = this;
893
+ return effect(() => {
894
+ const val = read();
895
+ const prevTracking = isTrackingDeps;
896
+ isTrackingDeps = false;
897
+ try {
898
+ fn(val);
899
+ } finally {
900
+ isTrackingDeps = prevTracking;
901
+ }
902
+ });
903
+ }
904
+ // Shared peeks (one per registry, not per primitive). Save one closure
905
+ // allocation per signal/computed creation versus the previous per-instance
906
+ // arrows. Method-invoked, so `this` is the read function and this[NODE_PTR]
907
+ // is the node. Signal: direct value read. Computed: pull (still respects
908
+ // 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]); }
863
911
 
864
912
  /**
865
913
  * Create a reactive signal.
@@ -873,21 +921,13 @@ export function createRegistry(config = {}) {
873
921
  */
874
922
  function signal(initial, opts) {
875
923
  const node = createNode(initial, FLAG_SIGNAL);
876
- // Read opts defensively instead of defaulting to `= {}`: the default allocates
877
- // a throwaway object on every no-opts call (the common path) — pure nursery
878
- // garbage when mounting many signals.
879
924
  node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
880
- node.version = globalVersion | 0;
881
- statSignals = (statSignals + 1) | 0;
925
+ node.version = globalVersion;
926
+ statSignals++;
882
927
 
883
928
  const read = () => {
884
929
  if (isTrackingDeps && currentObserver !== null) {
885
- // Inlined cursor fast-path: on stable read order this matches every
886
- // time, so we skip a call into the (large, non-inlinable) allocateLink
887
- // frame entirely. Only a cursor miss falls through to the cold path,
888
- // where allocateLink re-reads the cursor for its relink logic.
889
- const expected = activeObserverCurrentDep;
890
-
930
+ let expected = activeObserverCurrentDep;
891
931
  if (expected !== null && expected.source === node) {
892
932
  activeObserverCurrentDep = expected.nextDep;
893
933
  } else {
@@ -897,62 +937,33 @@ export function createRegistry(config = {}) {
897
937
  return node.value;
898
938
  };
899
939
 
900
- read.peek = () => node.value;
940
+ read.peek = sharedSignalPeek;
941
+ // set stays a CLOSURE (byte-identical to 1.2.0): its call path is the hot
942
+ // path, and a closure over `node` beats a shared method's this[NODE_PTR]
943
+ // load. Keeping it a closure also restores detached `const {set}=signal()`.
901
944
  read.set = (value) => {
902
945
  const eq = node.equals;
903
-
904
946
  if (eq && eq(node.value, value)) return;
905
-
906
- // Revert capture: first .set() of this signal inside the current batch.
907
- // Guarded by batchDepth so out-of-batch writes pay zero added cost.
908
947
  if (batchDepth > 0 && node.revertEpoch !== batchEpoch) {
909
948
  node.preBatchValue = node.value;
910
- node.preBatchVersion = node.version | 0;
911
- node.revertEpoch = batchEpoch | 0;
949
+ node.preBatchVersion = node.version;
950
+ node.revertEpoch = batchEpoch;
912
951
  }
913
-
914
952
  node.value = value;
915
-
916
- // Revert detection: in-batch write whose value matches the pre-batch
917
- // capture. Restore version (without bumping globalVersion) and skip
918
- // propagation. Subscribers already queued by an earlier mid-batch set
919
- // will dirty-check against this restored version at flush and bail.
920
953
  if (batchDepth > 0 && node.revertEpoch === batchEpoch && eq && eq(node.preBatchValue, value)) {
921
- node.version = node.preBatchVersion | 0;
954
+ node.version = node.preBatchVersion;
922
955
  return;
923
956
  }
924
-
925
957
  globalVersion = (globalVersion + 1) | 0;
926
- node.version = globalVersion | 0;
958
+ node.version = globalVersion;
927
959
  markDownstream(node);
928
960
  if (batchDepth === 0) flushEffects();
929
961
  };
930
- read.update = (fn) => read.set(fn(node.value));
931
-
932
- read.subscribe = (fn) => {
933
- // Single-closure subscription: the read is tracked (it establishes the
934
- // dependency), then fn runs untracked. untrack() is inlined here to (a) drop
935
- // the second per-subscription closure and (b) save an untrack()+wrapper call
936
- // on every fire, not just at setup.
937
- // COUPLING: this duplicates untrack()'s isTrackingDeps save/restore. If
938
- // untrack ever manages additional state (e.g. currentObserver), mirror it here.
939
- return effect(() => {
940
- const val = read();
941
- const prevTracking = isTrackingDeps;
942
- isTrackingDeps = false;
943
-
944
- try {
945
- fn(val);
946
- } finally {
947
- isTrackingDeps = prevTracking;
948
- }
949
- });
950
- };
962
+ read.update = sharedUpdate; // shared: cold path, calls this.set (the closure above)
963
+ read.subscribe = sharedSubscribe; // shared: cold path, recovers via `this`
951
964
 
952
- // Secret pointer for safe, isolated disposal without allocating closures
953
965
  read[NODE_PTR] = node;
954
- read[NODE_GEN] = node.gen | 0;
955
-
966
+ read[NODE_GEN] = node.gen;
956
967
  return read;
957
968
  }
958
969
 
@@ -971,15 +982,12 @@ export function createRegistry(config = {}) {
971
982
  function computed(fn, opts) {
972
983
  const node = createNode(undefined, FLAG_COMPUTED);
973
984
  node.computeFn = fn;
974
- // Defensive opts read; avoids the `= {}` per-call allocation. See signal().
975
985
  node.equals = (opts !== undefined && opts.equals !== undefined) ? opts.equals : Object.is;
976
- statComputeds = (statComputeds + 1) | 0;
986
+ statComputeds++;
977
987
 
978
988
  const read = () => {
979
989
  if (isTrackingDeps && currentObserver !== null) {
980
- // Inlined cursor fast-path — see signal() read for rationale.
981
- const expected = activeObserverCurrentDep;
982
-
990
+ let expected = activeObserverCurrentDep;
983
991
  if (expected !== null && expected.source === node) {
984
992
  activeObserverCurrentDep = expected.nextDep;
985
993
  } else {
@@ -989,87 +997,69 @@ export function createRegistry(config = {}) {
989
997
  return pullComputed(node);
990
998
  };
991
999
 
992
- read.peek = () => pullComputed(node);
993
-
994
- read.subscribe = (fn) => {
995
- // Single-closure subscription — see signal() subscribe for rationale and
996
- // the untrack-inlining coupling note.
997
- return effect(() => {
998
- const val = read();
999
- const prevTracking = isTrackingDeps;
1000
- isTrackingDeps = false;
1001
-
1002
- try {
1003
- fn(val);
1004
- } finally {
1005
- isTrackingDeps = prevTracking;
1006
- }
1007
- });
1008
- };
1000
+ read.peek = sharedComputedPeek;
1001
+ read.subscribe = sharedSubscribe;
1009
1002
 
1010
1003
  read[NODE_PTR] = node;
1011
- read[NODE_GEN] = node.gen | 0;
1012
-
1004
+ read[NODE_GEN] = node.gen;
1013
1005
  return read;
1014
1006
  }
1015
1007
 
1016
1008
  /**
1017
1009
  * Create an eagerly-run side effect that re-executes whenever its tracked
1018
- * dependencies change.
1010
+ * dependencies change. The body runs synchronously on creation.
1011
+ *
1012
+ * An effect that creates nested effects/computeds in its body owns them via
1013
+ * the v1.2 owner tree: when this effect re-runs or is disposed, owned
1014
+ * children are cascade-disposed before the new run.
1019
1015
  *
1020
1016
  * Errors thrown by the effect body propagate to the caller of `set()` (or
1021
1017
  * to the scheduler trampoline). The effect's dependency state is fully
1022
- * restored before the error propagates.
1018
+ * restored before the error propagates. Multiple throws in the same flush
1019
+ * pass aggregate into an `AggregateError` at the trigger.
1023
1020
  *
1024
1021
  * @param {() => void} fn Effect body.
1025
1022
  * @param {object} [opts]
1026
1023
  * @param {(run:()=>void)=>void} [opts.scheduler]
1027
1024
  * Optional trampoline (e.g. queueMicrotask, requestAnimationFrame).
1028
1025
  * Receives a `run` callback that the scheduler must eventually invoke.
1026
+ * The thunk is cached per-node and gen-bound, so a stale schedule
1027
+ * fired post-dispose against a recycled slot is a guaranteed no-op.
1029
1028
  * @returns {() => void} Dispose function. Idempotent. Safe to call
1030
1029
  * after registry.destroy().
1031
1030
  */
1032
1031
  function effect(fn, opts) {
1033
1032
  const node = createNode(undefined, FLAG_EFFECT);
1034
1033
  node.computeFn = fn;
1035
- // Defensive opts read; avoids the `= {}` per-call allocation. Read scheduler once
1036
- // and reuse the local for the trampoline check below.
1037
- const scheduler = opts !== undefined ? opts.scheduler : undefined;
1038
- node.scheduler = scheduler;
1039
- statEffects = (statEffects + 1) | 0;
1034
+ node.scheduler = (opts !== undefined) ? opts.scheduler : undefined;
1035
+ statEffects++;
1040
1036
 
1041
1037
  let firstRunError = null;
1042
-
1043
- if (scheduler) {
1038
+ if (node.scheduler) {
1044
1039
  const gen = node.gen | 0;
1045
- node.schedulerThunk = () => safeExecute(node, gen);
1046
- scheduler(node.schedulerThunk);
1040
+ // Cache the gen-bound thunk so re-schedules reuse the same closure.
1041
+ // The inline guard preserves ABA correctness across dispose+recycle
1042
+ // (gen bumps on disposeNode → stale thunk no-ops).
1043
+ node.schedulerThunk = () => {
1044
+ if (node.gen === gen && (node.flags & FLAG_EFFECT) !== 0) executeEffect(node);
1045
+ };
1046
+ node.scheduler(node.schedulerThunk);
1047
1047
  } else {
1048
1048
  try {
1049
1049
  executeEffect(node);
1050
1050
  } catch (err) {
1051
- // First-run failure: dispose the node so we don't leak a half-initialised
1052
- // effect in the registry. Propagate the error to the caller.
1053
1051
  firstRunError = err;
1054
1052
  }
1055
1053
  }
1056
1054
 
1057
1055
  let disposed = false;
1058
- const birthGen = node.gen | 0;
1059
-
1056
+ const birthGen = node.gen;
1060
1057
  const disposeFn = function dispose() {
1061
1058
  if (disposed) return;
1062
1059
  disposed = true;
1063
- // Generation guard: if destroy() (or a future direct disposal)
1064
- // recycled this slot to a different node, the stale closure must
1065
- // NOT operate on it — without this check, the closure would call
1066
- // disposeNode() on whatever now lives in the slot and desync
1067
- // statEffects (it would decrement even though the node is no
1068
- // longer an effect). Mirrors the NODE_GEN stamp on signals/computeds.
1069
- if ((node.gen | 0) !== birthGen) return;
1060
+ if (node.gen !== birthGen) return;
1070
1061
  if (node.flags !== 0) {
1071
1062
  disposeNode(node);
1072
- statEffects = (statEffects - 1) | 0;
1073
1063
  }
1074
1064
  };
1075
1065
 
@@ -1077,47 +1067,32 @@ export function createRegistry(config = {}) {
1077
1067
  disposeFn();
1078
1068
  throw firstRunError;
1079
1069
  }
1080
-
1081
1070
  return disposeFn;
1082
1071
  }
1083
1072
 
1084
1073
  function dispose(api) {
1085
- // Safe read: foreign APIs or effects return undefined for NODE_PTR.
1086
1074
  const node = api?.[NODE_PTR];
1087
-
1088
1075
  if (!node) {
1089
- // Plain functions self-execute (effect dispose handles and the
1090
- // dispose returned by .subscribe()). BUT we must not invoke a
1091
- // FOREIGN signal/computed here — they are also functions, and
1092
- // calling one would (1) read the value if untracked, or worse,
1093
- // (2) cross-link the foreign node into our observer if called
1094
- // inside a tracking context. Duck-type on `.peek`: every reactive
1095
- // primitive carries it; plain dispose handles do not.
1096
1076
  if (typeof api === "function" && typeof api.peek !== "function") api();
1097
1077
  return;
1098
1078
  }
1099
-
1100
- // Generation guard: if the slot has been recycled since this handle
1101
- // was issued, the handle is stale — silently no-op. Without this,
1102
- // a second dispose() call after the slot has been reallocated to a
1103
- // different signal/computed would free the new occupant.
1104
- const stamp = api[NODE_GEN] | 0;
1105
- if (stamp !== (node.gen | 0)) return;
1106
-
1079
+ if (api[NODE_GEN] !== node.gen) return;
1107
1080
  if (node.flags !== 0) {
1108
- const isSig = (node.flags & FLAG_SIGNAL) !== 0;
1109
- const isComp = (node.flags & FLAG_COMPUTED) !== 0;
1110
-
1111
1081
  disposeNode(node);
1112
-
1113
- if (isSig) statSignals = (statSignals - 1) | 0;
1114
- if (isComp) statComputeds = (statComputeds - 1) | 0;
1115
1082
  }
1116
1083
  }
1117
1084
 
1118
1085
  /**
1119
1086
  * Coalesce multiple synchronous writes into a single effect-flush pass.
1120
- * Nested batches are merged.
1087
+ * Nested batches are merged — only the outermost close triggers the flush.
1088
+ *
1089
+ * Pre-batch revert (1.2.0): if a signal is set, then set back to its
1090
+ * pre-batch value (under its `equals`) before the outer close, the version
1091
+ * bump is reverted and downstream effects/computeds do not fire.
1092
+ *
1093
+ * NOT transactional: an exception inside the body does NOT roll back applied
1094
+ * writes. Effects that have not yet fired for the pending writes do still
1095
+ * run on batch close with the post-throw values.
1121
1096
  *
1122
1097
  * @template T
1123
1098
  * @param {() => T} fn
@@ -1126,12 +1101,9 @@ export function createRegistry(config = {}) {
1126
1101
  function batch(fn) {
1127
1102
  if (batchDepth === 0) {
1128
1103
  batchEpoch = (batchEpoch + 1) | 0;
1129
- /* c8 ignore next -- 2^32 wraparound sentinel; unreachable without ~4e9 batches */
1130
- if (batchEpoch === 0) batchEpoch = 1 | 0; // preserve the 0 sentinel
1104
+ if (batchEpoch === 0) batchEpoch = 1;
1131
1105
  }
1132
-
1133
1106
  batchDepth = (batchDepth + 1) | 0;
1134
-
1135
1107
  try {
1136
1108
  return fn();
1137
1109
  } finally {
@@ -1141,12 +1113,13 @@ export function createRegistry(config = {}) {
1141
1113
  }
1142
1114
 
1143
1115
  /**
1144
- * Run `fn` without recording any signal/computed reads as dependencies.
1145
- * Useful inside effects to peek at signals you don't want to react to.
1146
- *
1147
- * @template T
1148
- * @param {() => T} fn
1149
- * @returns {T}
1116
+ * Returns true iff a read RIGHT NOW would record a dependency on this
1117
+ * registry. Mirrors the engine's own read-trap predicate (both flags).
1118
+ * False inside untrack(), subscribe callbacks, onCleanup bodies,
1119
+ * watch/when callbacks, and outside any observer. For wrapper libraries
1120
+ * (lite-store, lite-query, lite-form) that lazily allocate signals on
1121
+ * property reads. Per-registry. ~1-2 ns.
1122
+ * @returns {boolean}
1150
1123
  */
1151
1124
  /**
1152
1125
  * Returns true iff a read RIGHT NOW would record a dependency on this
@@ -1164,7 +1137,6 @@ export function createRegistry(config = {}) {
1164
1137
  function untrack(fn) {
1165
1138
  const prev = isTrackingDeps;
1166
1139
  isTrackingDeps = false;
1167
-
1168
1140
  try {
1169
1141
  return fn();
1170
1142
  } finally {
@@ -1173,24 +1145,26 @@ export function createRegistry(config = {}) {
1173
1145
  }
1174
1146
 
1175
1147
  /**
1176
- * Register a function to run when the enclosing effect re-runs or is disposed.
1148
+ * Register a function to run when the enclosing effect/computed re-runs or
1149
+ * is disposed. Cascade order on disposal is inside-out: an effect's owned
1150
+ * children's cleanups run BEFORE this one (#238 / #241 / #243).
1177
1151
  *
1178
1152
  * No-op if called outside an effect / computed body.
1179
1153
  *
1180
1154
  * @param {() => void} fn
1181
1155
  */
1182
1156
  function onCleanup(fn) {
1183
- if (currentObserver !== null) {
1184
- const existing = currentObserver.cleanupFn;
1185
-
1186
- if (existing === undefined) currentObserver.cleanupFn = fn;
1187
- else if (typeof existing === "function") currentObserver.cleanupFn = [existing, fn];
1157
+ if (currentOwner !== null) {
1158
+ const existing = currentOwner.cleanupFn;
1159
+ if (existing === undefined) currentOwner.cleanupFn = fn;
1160
+ else if (typeof existing === "function") currentOwner.cleanupFn = [existing, fn];
1188
1161
  else existing.push(fn);
1189
1162
  }
1190
1163
  }
1191
1164
 
1192
1165
  /**
1193
- * Snapshot of registry counters. Useful for diagnostics and tests.
1166
+ * Snapshot of registry counters. Useful for diagnostics and tests
1167
+ * e.g. asserting that `activeNodes` returns to a baseline after teardown.
1194
1168
  * @returns {RegistryStats}
1195
1169
  */
1196
1170
  function stats() {
@@ -1208,8 +1182,8 @@ export function createRegistry(config = {}) {
1208
1182
 
1209
1183
  /**
1210
1184
  * Reset the entire registry: clear every node, every link, every queue, the
1211
- * global clock. All previously-issued read/set/dispose closures become
1212
- * no-ops (they're guarded internally they will not corrupt the pool).
1185
+ * global clock. All previously-issued read/set/dispose closures become safe
1186
+ * no-ops (every node's `gen` bump invalidates any outstanding handle).
1213
1187
  */
1214
1188
  function destroy() {
1215
1189
  for (let i = 0; i < currentNodesCapacity; i++) {
@@ -1219,11 +1193,9 @@ export function createRegistry(config = {}) {
1219
1193
  n.cleanupFn = undefined;
1220
1194
  n.equals = undefined;
1221
1195
  n.scheduler = undefined;
1222
- n.schedulerThunk = undefined;
1223
1196
  n.flags = 0;
1224
1197
  n.headDep = null;
1225
1198
  n.tailDep = null;
1226
- n.currentDep = null;
1227
1199
  n.headSub = null;
1228
1200
  n.tailSub = null;
1229
1201
  n.version = 0;
@@ -1232,17 +1204,23 @@ export function createRegistry(config = {}) {
1232
1204
  n.revertEpoch = 0;
1233
1205
  n.preBatchValue = undefined;
1234
1206
  n.preBatchVersion = 0;
1235
- // Bump gen so any scheduler trampolines holding a stale node ref bail.
1207
+
1208
+ n.owner = null;
1209
+ n.prevOwned = null;
1210
+ n.nextOwned = null;
1211
+ n.firstOwned = null;
1212
+
1236
1213
  n.gen = (n.gen + 1) | 0;
1237
1214
 
1215
+ effectQueueA[i] = null;
1216
+ effectQueueB[i] = null;
1217
+ markStack[i] = null;
1218
+
1238
1219
  if (i < currentNodesCapacity - 1) n.nextFree = nodePool[i + 1];
1239
1220
  }
1240
-
1241
1221
  nodePool[currentNodesCapacity - 1].nextFree = null;
1242
1222
  freeNodeHead = nodePool[0];
1243
1223
 
1244
- // ... remainder of destroy() stays exactly the same
1245
-
1246
1224
  for (let i = 0; i < currentLinkCapacity; i++) {
1247
1225
  const l = linkPool[i];
1248
1226
  l.source = null;
@@ -1253,35 +1231,33 @@ export function createRegistry(config = {}) {
1253
1231
  l.nextSub = null;
1254
1232
  if (i < currentLinkCapacity - 1) l.nextFree = linkPool[i + 1];
1255
1233
  }
1256
-
1257
1234
  linkPool[currentLinkCapacity - 1].nextFree = null;
1258
1235
  freeLinkHead = linkPool[0];
1259
1236
 
1260
- activeNodes = 0 | 0;
1261
- activeLinks = 0 | 0;
1262
- activeQueueLen = 0 | 0;
1237
+ activeNodes = 0;
1238
+ activeLinks = 0;
1239
+ activeQueueLen = 0;
1263
1240
  isFlushing = false;
1264
- batchDepth = 0 | 0;
1241
+ batchDepth = 0;
1265
1242
  currentObserver = null;
1243
+ currentOwner = null;
1266
1244
  activeObserverCurrentDep = null;
1267
1245
  isTrackingDeps = false;
1268
- globalVersion = 1 | 0;
1269
- batchEpoch = 1 | 0;
1270
- statSignals = 0 | 0;
1271
- statComputeds = 0 | 0;
1272
- statEffects = 0 | 0;
1246
+ globalVersion = 1;
1247
+ batchEpoch = 1;
1248
+ statSignals = 0;
1249
+ statComputeds = 0;
1250
+ statEffects = 0;
1273
1251
 
1274
1252
  for (let i = 0; i < flushErrorCount; i++) flushErrorBuffer[i] = null;
1275
-
1276
- flushErrorCount = 0 | 0;
1277
- flushErrorBuffer.length = 0; // release the backing array
1253
+ flushErrorCount = 0;
1254
+ flushErrorBuffer.length = 0;
1278
1255
  }
1279
1256
 
1280
1257
  function hasObservers(handle) {
1281
1258
  const node = handle != null ? handle[NODE_PTR] : undefined;
1282
1259
  return node !== undefined && node.headSub !== null;
1283
1260
  }
1284
-
1285
1261
  function observeObservers(handle, opts) {
1286
1262
  const node = handle != null ? handle[NODE_PTR] : undefined;
1287
1263
  if (node === undefined) throw new TypeError("observeObservers: argument is not a reactive handle");
@@ -1302,51 +1278,35 @@ export function createRegistry(config = {}) {
1302
1278
  if (lifecycleMap.delete(node)) lifecycleCount = (lifecycleCount - 1) | 0;
1303
1279
  };
1304
1280
  }
1305
-
1306
1281
  function describeNode(node) {
1307
1282
  const fl = node.flags;
1308
1283
  const kind = (fl & FLAG_EFFECT) !== 0 ? "effect" : (fl & FLAG_COMPUTED) !== 0 ? "computed" : "signal";
1309
- return {kind, value: node.value};
1284
+ const d = {id: node.id, kind, value: node.value};
1285
+ Object.defineProperty(d, NODE_PTR, {value: node, enumerable: false});
1286
+ return d;
1287
+ }
1288
+ function nodeId(handle) {
1289
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1290
+ return node !== undefined ? node.id : undefined;
1291
+ }
1292
+ function describe(handle) {
1293
+ const node = handle != null ? handle[NODE_PTR] : undefined;
1294
+ return node !== undefined ? describeNode(node) : undefined;
1310
1295
  }
1311
-
1312
1296
  function forEachObserver(handle, fn) {
1313
1297
  const node = handle != null ? handle[NODE_PTR] : undefined;
1314
1298
  if (node === undefined) return;
1315
1299
  let l = node.headSub;
1316
- while (l !== null) {
1317
- const nx = l.nextSub;
1318
- fn(describeNode(l.target));
1319
- l = nx;
1320
- }
1300
+ while (l !== null) { const nx = l.nextSub; fn(describeNode(l.target)); l = nx; }
1321
1301
  }
1322
-
1323
1302
  function forEachSource(handle, fn) {
1324
1303
  const node = handle != null ? handle[NODE_PTR] : undefined;
1325
1304
  if (node === undefined) return;
1326
1305
  let l = node.headDep;
1327
- while (l !== null) {
1328
- const nx = l.nextDep;
1329
- fn(describeNode(l.source));
1330
- l = nx;
1331
- }
1306
+ while (l !== null) { const nx = l.nextDep; fn(describeNode(l.source)); l = nx; }
1332
1307
  }
1333
1308
 
1334
- return {
1335
- signal,
1336
- computed,
1337
- effect,
1338
- dispose,
1339
- batch,
1340
- untrack,
1341
- onCleanup,
1342
- stats,
1343
- destroy,
1344
- isTracking,
1345
- hasObservers,
1346
- observeObservers,
1347
- forEachObserver,
1348
- forEachSource
1349
- };
1309
+ return {signal, computed, effect, dispose, batch, untrack, onCleanup, stats, destroy, isTracking, hasObservers, observeObservers, forEachObserver, forEachSource, nodeId, describe};
1350
1310
  }
1351
1311
 
1352
1312
  // ─────────────────────────────────────────────────────────────────
@@ -1355,28 +1315,18 @@ export function createRegistry(config = {}) {
1355
1315
 
1356
1316
  let defaultRegistry = createRegistry();
1357
1317
 
1358
- /**
1359
- * Replace the registry backing the top-level {@link signal} / {@link computed} /
1360
- * {@link effect} / {@link batch} / {@link untrack} / {@link onCleanup} / {@link stats}
1361
- * exports.
1362
- *
1363
- * @param {Registry} registry
1364
- */
1365
1318
  export function setDefaultRegistry(registry) {
1366
1319
  defaultRegistry = registry;
1367
1320
  }
1368
1321
 
1369
- /** @type {Registry["signal"]} */
1370
1322
  export function signal(initial, opts) {
1371
1323
  return defaultRegistry.signal(initial, opts);
1372
1324
  }
1373
1325
 
1374
- /** @type {Registry["computed"]} */
1375
1326
  export function computed(fn, opts) {
1376
1327
  return defaultRegistry.computed(fn, opts);
1377
1328
  }
1378
1329
 
1379
- /** @type {Registry["effect"]} */
1380
1330
  export function effect(fn, opts) {
1381
1331
  return defaultRegistry.effect(fn, opts);
1382
1332
  }
@@ -1385,12 +1335,10 @@ export function dispose(api) {
1385
1335
  return defaultRegistry.dispose(api);
1386
1336
  }
1387
1337
 
1388
- /** @type {Registry["batch"]} */
1389
1338
  export function batch(fn) {
1390
1339
  return defaultRegistry.batch(fn);
1391
1340
  }
1392
1341
 
1393
- /** @type {Registry["untrack"]} */
1394
1342
  export function untrack(fn) {
1395
1343
  return defaultRegistry.untrack(fn);
1396
1344
  }
@@ -1403,41 +1351,35 @@ export function isTracking() {
1403
1351
  return defaultRegistry.isTracking();
1404
1352
  }
1405
1353
 
1354
+ export function onCleanup(fn) {
1355
+ return defaultRegistry.onCleanup(fn);
1356
+ }
1357
+
1358
+ export function stats() {
1359
+ return defaultRegistry.stats();
1360
+ }
1361
+
1362
+ export function destroy() {
1363
+ return defaultRegistry.destroy();
1364
+ }
1365
+
1406
1366
  export function hasObservers(handle) {
1407
1367
  return defaultRegistry.hasObservers(handle);
1408
1368
  }
1409
-
1410
1369
  export function observeObservers(handle, opts) {
1411
1370
  return defaultRegistry.observeObservers(handle, opts);
1412
1371
  }
1413
-
1414
1372
  export function forEachObserver(handle, fn) {
1415
1373
  return defaultRegistry.forEachObserver(handle, fn);
1416
1374
  }
1417
-
1418
1375
  export function forEachSource(handle, fn) {
1419
1376
  return defaultRegistry.forEachSource(handle, fn);
1420
1377
  }
1421
-
1422
- /** @type {Registry["onCleanup"]} */
1423
- export function onCleanup(fn) {
1424
- return defaultRegistry.onCleanup(fn);
1425
- }
1426
-
1427
- /** @type {Registry["stats"]} */
1428
- export function stats() {
1429
- return defaultRegistry.stats();
1378
+ export function nodeId(handle) {
1379
+ return defaultRegistry.nodeId(handle);
1430
1380
  }
1431
-
1432
- /** * Wipe the default registry. strictly for test-suite isolation.
1433
- * @private
1434
- */
1435
- export function destroy() {
1436
- return defaultRegistry.destroy();
1381
+ export function describe(handle) {
1382
+ return defaultRegistry.describe(handle);
1437
1383
  }
1438
1384
 
1439
- /**
1440
- * Re-export of the user-land watch utility.
1441
- * @see {@link watch} in Watch.js for full implementation details.
1442
- */
1443
- export {watch, when, whenAsync} from "./Watch.js"
1385
+ export {watch, when, whenAsync} from "./Watch.js";