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