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