@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/CHANGELOG.md +415 -0
- package/README.md +144 -54
- package/Signal.d.ts +12 -0
- package/Signal.js +536 -594
- package/llms.txt +65 -18
- package/package.json +3 -2
package/Signal.js
CHANGED
|
@@ -1,61 +1,66 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal v1.
|
|
2
|
+
* @zakkster/lite-signal v1.2.0
|
|
3
3
|
* --------------------
|
|
4
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* O(
|
|
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
|
-
*
|
|
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
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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;
|
|
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
|
|
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
|
|
174
|
-
let currentLinkCapacity = config.maxLinks
|
|
175
|
-
const policy = config.onCapacityExceeded
|
|
176
|
-
const maxFlushPasses = config.maxFlushPasses
|
|
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
|
|
197
|
-
let activeLinks = 0
|
|
198
|
-
let statSignals = 0
|
|
199
|
-
let statComputeds = 0
|
|
200
|
-
let statEffects = 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
|
|
215
|
+
let activeQueueLen = 0;
|
|
215
216
|
let isQueueA = true;
|
|
216
217
|
|
|
217
|
-
|
|
218
|
-
let
|
|
219
|
-
let
|
|
220
|
-
let
|
|
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
|
|
223
|
+
let batchDepth = 0;
|
|
223
224
|
let isTrackingDeps = false;
|
|
224
|
-
let isFlushing = false;
|
|
225
225
|
|
|
226
|
-
// ──
|
|
227
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
247
|
+
let flushErrorCount = 0;
|
|
271
248
|
|
|
272
|
-
//
|
|
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
|
-
*
|
|
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 (
|
|
285
|
-
//
|
|
286
|
-
//
|
|
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 (
|
|
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 (
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
319
|
+
link = freeLinkHead;
|
|
320
|
+
freeLinkHead = link.nextFree;
|
|
321
|
+
link.nextFree = null;
|
|
322
|
+
activeLinks = (activeLinks + 1) | 0;
|
|
364
323
|
|
|
365
|
-
|
|
324
|
+
link.source = source;
|
|
325
|
+
link.target = target;
|
|
366
326
|
|
|
367
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
407
|
-
|
|
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
|
-
//
|
|
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;
|
|
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
|
-
/**
|
|
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
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
558
|
-
|
|
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
|
-
*
|
|
587
|
-
*
|
|
588
|
-
*
|
|
589
|
-
|
|
590
|
-
|
|
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
|
|
663
|
+
let passes = 0;
|
|
608
664
|
let normalExit = false;
|
|
609
665
|
|
|
610
666
|
try {
|
|
611
667
|
while (activeQueueLen > 0) {
|
|
612
|
-
|
|
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
|
|
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
|
-
|
|
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;
|
|
662
|
-
flushErrorCount = 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
|
-
|
|
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
|
|
729
|
-
node.evalVersion = globalVersion
|
|
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
|
-
|
|
756
|
-
|
|
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
|
-
*
|
|
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 (
|
|
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
|
|
783
|
-
//
|
|
784
|
-
//
|
|
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
|
|
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
|
|
847
|
+
node.version = globalVersion;
|
|
837
848
|
}
|
|
838
|
-
node.flags
|
|
849
|
+
node.flags &= ~FLAG_HAS_ERROR;
|
|
839
850
|
} catch (err) {
|
|
840
|
-
node.
|
|
841
|
-
|
|
842
|
-
|
|
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
|
-
|
|
845
|
-
|
|
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.
|
|
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
|
-
//
|
|
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
|
|
881
|
-
statSignals
|
|
925
|
+
node.version = globalVersion;
|
|
926
|
+
statSignals++;
|
|
882
927
|
|
|
883
928
|
const read = () => {
|
|
884
929
|
if (isTrackingDeps && currentObserver !== null) {
|
|
885
|
-
|
|
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 =
|
|
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
|
|
911
|
-
node.revertEpoch = batchEpoch
|
|
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
|
|
954
|
+
node.version = node.preBatchVersion;
|
|
922
955
|
return;
|
|
923
956
|
}
|
|
924
|
-
|
|
925
957
|
globalVersion = (globalVersion + 1) | 0;
|
|
926
|
-
node.version = globalVersion
|
|
958
|
+
node.version = globalVersion;
|
|
927
959
|
markDownstream(node);
|
|
928
960
|
if (batchDepth === 0) flushEffects();
|
|
929
961
|
};
|
|
930
|
-
read.update =
|
|
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
|
|
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
|
|
986
|
+
statComputeds++;
|
|
977
987
|
|
|
978
988
|
const read = () => {
|
|
979
989
|
if (isTrackingDeps && currentObserver !== null) {
|
|
980
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1036
|
-
|
|
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
|
-
|
|
1046
|
-
|
|
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
|
|
1059
|
-
|
|
1056
|
+
const birthGen = node.gen;
|
|
1060
1057
|
const disposeFn = function dispose() {
|
|
1061
1058
|
if (disposed) return;
|
|
1062
1059
|
disposed = true;
|
|
1063
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
1145
|
-
*
|
|
1146
|
-
*
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
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
|
|
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 (
|
|
1184
|
-
const existing =
|
|
1185
|
-
|
|
1186
|
-
if (existing ===
|
|
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 (
|
|
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
|
-
|
|
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
|
|
1261
|
-
activeLinks = 0
|
|
1262
|
-
activeQueueLen = 0
|
|
1237
|
+
activeNodes = 0;
|
|
1238
|
+
activeLinks = 0;
|
|
1239
|
+
activeQueueLen = 0;
|
|
1263
1240
|
isFlushing = false;
|
|
1264
|
-
batchDepth = 0
|
|
1241
|
+
batchDepth = 0;
|
|
1265
1242
|
currentObserver = null;
|
|
1243
|
+
currentOwner = null;
|
|
1266
1244
|
activeObserverCurrentDep = null;
|
|
1267
1245
|
isTrackingDeps = false;
|
|
1268
|
-
globalVersion = 1
|
|
1269
|
-
batchEpoch = 1
|
|
1270
|
-
statSignals = 0
|
|
1271
|
-
statComputeds = 0
|
|
1272
|
-
statEffects = 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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";
|