@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/README.md CHANGED
@@ -5,6 +5,7 @@
5
5
  [![npm version](https://img.shields.io/npm/v/@zakkster/lite-signal.svg?style=for-the-badge&color=latest)](https://www.npmjs.com/package/@zakkster/lite-signal)
6
6
  [![sponsor](https://img.shields.io/badge/sponsor-PeshoVurtoleta-ea4aaa.svg?logo=github)](https://github.com/sponsors/PeshoVurtoleta)
7
7
  ![Zero-GC](https://img.shields.io/badge/Zero--GC-Engine-00C853?style=for-the-badge&logo=leaf&logoColor=white)
8
+ ![Benchmark](https://img.shields.io/badge/js--reactivity--benchmark-4th%20of%2015-00C853?style=for-the-badge)
8
9
  [![npm bundle size](https://img.shields.io/bundlephobia/minzip/@zakkster/lite-signal?style=for-the-badge)](https://bundlephobia.com/result?p=@zakkster/lite-signal)
9
10
  [![npm downloads](https://img.shields.io/npm/dm/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
10
11
  [![npm total downloads](https://img.shields.io/npm/dt/@zakkster/lite-signal?style=for-the-badge&color=blue)](https://www.npmjs.com/package/@zakkster/lite-signal)
@@ -12,6 +13,24 @@
12
13
  ![Dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
13
14
  [![license](https://img.shields.io/badge/license-MIT-blue?style=flat-square)](./LICENSE)
14
15
 
16
+ ## 4th of 15 on the community reactivity benchmark -- and the only zero-GC engine in the field
17
+
18
+ On the independent [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark) (Andrii Volynets' fork; 15 reactive libraries, 47 tests), `lite-signal` places **4th overall by geomean (81.6)** -- within noise of 5th-place Preact Signals (83.0, a 1.7% gap), behind only three push-eager engines: alien-signals, reflex, and @reactively.
19
+
20
+ It is the **only object-pooled, zero-GC engine in the entire field**, and it gets that result without giving up glitch-freedom or lazy evaluation. Against the mainstream reactivity libraries it leads decisively:
21
+
22
+ | vs | lite-signal is |
23
+ | ---------------------- | -------------- |
24
+ | **@vue/reactivity** | **1.6x faster** |
25
+ | **Signia** | **1.7x faster** |
26
+ | **MobX** | **2.3x faster** |
27
+ | **@solidjs/signals** | **2.6x faster** |
28
+ | **SolidJS** | **3.8x faster** |
29
+ | Preact Signals | ~even (+1.7%) |
30
+ | alien-signals | 0.56x (the field leader) |
31
+
32
+ `lite-signal` finishes **top-3 on 18 of the 47 tests** and is the **outright fastest of all 15** on `manyEffectsFromOneSource` (1 source -> many effects, fan-out) and `manySourcesIntoOneComputedEffectWithDirect` (many sources -> one computed, fan-in) -- the aggregation shapes that dominate live dashboards, scoreboards, and HUDs. The three engines ahead of it are all push-eager designs that allocate on the hot path; `lite-signal` is the only top-4 finisher that allocates **nothing** in steady state. (Note: this suite measures reactivity *libraries* -- Vue's reactivity core, MobX, Solid, Preact Signals, etc. -- not full UI frameworks like React or Angular.)
33
+
15
34
  ```bash
16
35
  npm install @zakkster/lite-signal
17
36
  ```
@@ -23,10 +42,10 @@ const count = signal(0);
23
42
  const double = computed(() => count() * 2);
24
43
 
25
44
  effect(() => console.log("double is", double()));
26
- // double is 0
45
+ // -> double is 0
27
46
 
28
47
  count.set(21);
29
- // double is 42
48
+ // -> double is 42
30
49
  ```
31
50
 
32
51
  Synchronous, glitch-free, push-pull. No microtask queue, no allocations after warm-up, no surprises.
@@ -64,7 +83,7 @@ Reactive graph libraries are now table-stakes for UI work. They all do the same
64
83
 
65
84
  1. **No allocation after warm-up.** A 60fps Twitch overlay can't tolerate GC pauses. `set`, `peek`, and re-runs touch no heap.
66
85
  2. **Zero microtasks.** Effects flush synchronously in the same call stack as `set()`. There is no scheduler queue. Predictable cause-and-effect makes debugging tractable.
67
- 3. **Survive forever.** A multi-day extension session can issue billions of writes. Internal versions use 32-bit modular arithmetic the engine never overflows.
86
+ 3. **Survive forever.** A multi-day extension session can issue billions of writes. Internal versions use 32-bit modular arithmetic -- the engine never overflows.
68
87
 
69
88
  Other libraries hit two of three. None of the ones I measured hit all three.
70
89
 
@@ -86,17 +105,17 @@ No microtask between `B` and `I`. No promise, no `queueMicrotask`. Just call sta
86
105
 
87
106
  ## What you get
88
107
 
89
- - **`signal(value, { equals? })`** root reactive cell. `set`, `peek`, `update`, `subscribe`.
90
- - **`computed(fn, { equals? })`** memoized derivation. Lazy. Pulls deps on read.
91
- - **`effect(fn, { scheduler? })`** side-effect runner. Returns a dispose function.
92
- - **`dispose(api)`** universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
93
- - **`batch(fn)`** defer effect flush until the outermost batch closes.
94
- - **`untrack(fn)`** read without subscribing.
95
- - **`isTracking()`** `true` iff a read right now would subscribe (for lazy-allocation wrappers).
96
- - **`onCleanup(fn)`** register teardown for the current computation. Works in effects *and* computeds.
97
- - **`createRegistry(config)`** isolated pool for tests, plugins, sandboxing.
98
- - **`stats()`** pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
99
- - **`CapacityError`** thrown when a fixed-size pool is exhausted under the `"throw"` policy.
108
+ - **`signal(value, { equals? })`** -- root reactive cell. `set`, `peek`, `update`, `subscribe`.
109
+ - **`computed(fn, { equals? })`** -- memoized derivation. Lazy. Pulls deps on read.
110
+ - **`effect(fn, { scheduler? })`** -- side-effect runner. Returns a dispose function.
111
+ - **`dispose(api)`** -- universal disposal for signals, computeds, and effect handles. Cross-registry calls are silent no-ops.
112
+ - **`batch(fn)`** -- defer effect flush until the outermost batch closes.
113
+ - **`untrack(fn)`** -- read without subscribing.
114
+ - **`isTracking()`** -- `true` iff a read right now would subscribe (for lazy-allocation wrappers).
115
+ - **`onCleanup(fn)`** -- register teardown for the current computation. Works in effects *and* computeds.
116
+ - **`createRegistry(config)`** -- isolated pool for tests, plugins, sandboxing.
117
+ - **`stats()`** -- pool occupancy snapshot. Used by the demo and easy to wire into perf overlays.
118
+ - **`CapacityError`** -- thrown when a fixed-size pool is exhausted under the `"throw"` policy.
100
119
 
101
120
  Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced from `package.json`. Every public symbol has JSDoc.
102
121
 
@@ -109,7 +128,7 @@ Full type definitions ship in [`Signal.d.ts`](./Signal.d.ts) and are referenced
109
128
 
110
129
  A naive reactive library allocates one object per dependency edge, one per subscription, one per queued effect. With 1000 computeds × 1 update / frame × 60 fps, that's 60,000 short-lived objects per second. The major GC will catch up with you.
111
130
 
112
- `lite-signal` solves this by pre-allocating two pools at startup **nodes** (one per signal/computed/effect) and **links** (one per dependency edge) and reusing them indefinitely. After the warm-up frames, the hot path performs zero allocations:
131
+ `lite-signal` solves this by pre-allocating two pools at startup -- **nodes** (one per signal/computed/effect) and **links** (one per dependency edge) -- and reusing them indefinitely. After the warm-up frames, the hot path performs zero allocations:
113
132
 
114
133
  | Op | Allocations | Notes |
115
134
  | ------------------- | ----------- | ------------------------------------------------------------------------------ |
@@ -119,7 +138,7 @@ A naive reactive library allocates one object per dependency edge, one per subsc
119
138
  | `computed()` read | **0** (steady-state) | Cache hit on `evalVersion === globalVersion` |
120
139
  | Dispose | **0** | Returns nodes and links to the free lists |
121
140
 
122
- The free lists are singly-linked through a `nextFree` field on each pool object `O(1)` pop, `O(1)` push, no fragmentation.
141
+ The free lists are singly-linked through a `nextFree` field on each pool object -- `O(1)` pop, `O(1)` push, no fragmentation.
123
142
 
124
143
  </details>
125
144
 
@@ -166,8 +185,8 @@ flowchart TB
166
185
 
167
186
  Every reactive entity is a `ReactiveNode` with bit flags (`COMPUTED`, `EFFECT`, `QUEUED`, `COMPUTING`, `HAS_ERROR`). Every edge between two nodes is a `ReactiveLink`, doubly-linked along two axes:
168
187
 
169
- - **`dep` axis:** `prevDep` / `nextDep` the list of dependencies on the *target* node (so a computed/effect can iterate its inputs in stable order).
170
- - **`sub` axis:** `prevSub` / `nextSub` the list of subscribers on the *source* node (so a signal can iterate downstream observers during mark phase).
188
+ - **`dep` axis:** `prevDep` / `nextDep` -- the list of dependencies on the *target* node (so a computed/effect can iterate its inputs in stable order).
189
+ - **`sub` axis:** `prevSub` / `nextSub` -- the list of subscribers on the *source* node (so a signal can iterate downstream observers during mark phase).
171
190
 
172
191
  Doubly-linked on both axes means `O(1)` unlink during the cursor-based reconciliation that happens at the end of every computed/effect re-run.
173
192
 
@@ -178,7 +197,7 @@ Doubly-linked on both axes means `O(1)` unlink during the cursor-based reconcili
178
197
  ## How a write propagates
179
198
 
180
199
  <details>
181
- <summary>The set mark flush sequence, and why computeds stay pull-based.</summary>
200
+ <summary>The set -> mark -> flush sequence, and why computeds stay pull-based.</summary>
182
201
 
183
202
  ```mermaid
184
203
  sequenceDiagram
@@ -199,16 +218,16 @@ sequenceDiagram
199
218
  loop until queue empty
200
219
  Flush->>Eff: for each effect: re-pull deps,<br/>compare versions, run if dirty
201
220
  Eff-->>Flush: maybe re-queue (handled by buffer B)
202
- Flush->>Flush: swap buffers AB, repeat
221
+ Flush->>Flush: swap buffers A<->B, repeat
203
222
  end
204
223
  Note over Flush: maxFlushPasses=100<br/>guards against runaway loops
205
224
  ```
206
225
 
207
- The mark phase is **iterative**, not recursive it uses a pre-allocated `markStack` array so a 10,000-node fan-out can't blow the JS call stack.
226
+ The mark phase is **iterative**, not recursive -- it uses a pre-allocated `markStack` array so a 10,000-node fan-out can't blow the JS call stack.
208
227
 
209
228
  The flush phase uses **two queue buffers** (`effectQueueA` / `effectQueueB`) alternating each pass. An effect that writes during its own re-run gets re-queued into the *other* buffer, which is then processed in the next pass. After `maxFlushPasses` (default 100), the loop throws `CycleError`.
210
229
 
211
- Computeds are **pull-based** they're not in the effect queue. Reading a computed walks its dep list, recursively pulls upstream computeds, and only re-runs if any dep's version is greater than its own `evalVersion`. The version comparison uses 32-bit modular arithmetic: `((dep.version - evalVer) | 0) > 0`. This is the trick that makes the engine immune to integer overflow during long-running sessions.
230
+ Computeds are **pull-based** -- they're not in the effect queue. Reading a computed walks its dep list, recursively pulls upstream computeds, and only re-runs if any dep's version is greater than its own `evalVersion`. The version comparison uses 32-bit modular arithmetic: `((dep.version - evalVer) | 0) > 0`. This is the trick that makes the engine immune to integer overflow during long-running sessions.
212
231
 
213
232
  </details>
214
233
 
@@ -265,7 +284,7 @@ c.peek(); // untracked read, may still compute
265
284
  const off = c.subscribe(value => { ... });
266
285
  ```
267
286
 
268
- Computeds **cache by version**, not by value. Reading a clean computed (one whose dependencies haven't changed since its `evalVersion`) is `O(deps)` it still walks the dep list to check versions, then returns the cached value. The `equals` option short-circuits downstream propagation when the new computed value matches the old.
287
+ Computeds **cache by version**, not by value. Reading a clean computed (one whose dependencies haven't changed since its `evalVersion`) is `O(deps)` -- it still walks the dep list to check versions, then returns the cached value. The `equals` option short-circuits downstream propagation when the new computed value matches the old.
269
288
 
270
289
  ### Effect
271
290
 
@@ -280,7 +299,7 @@ const dispose = effect(() => {
280
299
  dispose();
281
300
  ```
282
301
 
283
- Effects run **once eagerly** on creation, then again whenever any tracked dependency changes. Dispose returns the node to the pool. If a scheduler is provided, the runner is handed to the scheduler instead of executing inline useful for batching reactive updates into requestAnimationFrame, microtasks, or your own frame loop.
302
+ Effects run **once eagerly** on creation, then again whenever any tracked dependency changes. Dispose returns the node to the pool. If a scheduler is provided, the runner is handed to the scheduler instead of executing inline -- useful for batching reactive updates into requestAnimationFrame, microtasks, or your own frame loop.
284
303
 
285
304
  ### Batch
286
305
 
@@ -320,9 +339,9 @@ function makeLazyField(initial) {
320
339
  }
321
340
  ```
322
341
 
323
- Returns `true` iff a read right now would record a dependency on the current registry an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
342
+ Returns `true` iff a read right now would record a dependency on the current registry -- an observer body is on the stack AND tracking is enabled. Mirrors the engine's own read-trap check (both flags), so it correctly returns `false` inside `untrack`, inside `subscribe` callbacks, inside `onCleanup` bodies, inside `watch` / `when` callbacks, and outside any observer.
324
343
 
325
- For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation on the read path. Per-registry call `registry.isTracking()` if your signals live in a non-default registry.
344
+ For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation on the read path. Per-registry -- call `registry.isTracking()` if your signals live in a non-default registry.
326
345
 
327
346
  ### Observer-lifecycle introspection
328
347
 
@@ -330,8 +349,8 @@ For wrapper libraries (lite-store, lite-query, lite-form) gating lazy allocation
330
349
  // Start a ticker only while something is actually watching a derived value.
331
350
  const now = signal(performance.now());
332
351
  const unobserve = observeObservers(now, {
333
- onConnect: () => startRAF(), // 0 1 observers
334
- onDisconnect: () => stopRAF(), // 1 0 observers
352
+ onConnect: () => startRAF(), // 0 -> 1 observers
353
+ onDisconnect: () => stopRAF(), // 1 -> 0 observers
335
354
  });
336
355
 
337
356
  hasObservers(now); // O(1): is anyone subscribed right now? (a peek doesn't count)
@@ -345,25 +364,25 @@ forEachOwned(effectHandle, d => console.log(d.kind, d.id)); // observers this o
345
364
  ownerOf(innerComputedDesc); // descriptor of the enclosing effect/computed
346
365
  ```
347
366
 
348
- Eight functions (top-level + per-registry) four in 1.1.4, two in 1.1.5, two more in 1.2.1 for auto-pausing wrappers and graph inspection:
367
+ Eight functions (top-level + per-registry) -- four in 1.1.4, two in 1.1.5, two more in 1.2.1 -- for auto-pausing wrappers and graph inspection:
349
368
 
350
- - **`hasObservers(handle)` `boolean`** O(1) (`node.headSub !== null`). The auto-pause predicate.
351
- - **`observeObservers(handle, { onConnect?, onDisconnect? })` `unobserve`** fires on the 01 and 10 observer transitions *after* registration (transition-only no immediate fire if already observed). Re-tracking a persistently-read source does **not** churn. This is the hook `lite-time` / `lite-raf` use to run a clock only while a derived value is watched. Throws `TypeError` on a non-handle.
352
- - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** walk subscribers / dependencies; `fn` gets a `{ id, kind, value }` descriptor (`kind` `"signal" | "computed" | "effect"`; `id` added in 1.1.5). No-op on a non-handle.
353
- - **`nodeId(handle)` `number | undefined`** *(1.1.5)* the node's stable per-allocation id; the dedupe key for graph traversal. `undefined` on a non-handle.
354
- - **`describe(handle)` `{ id, kind, value } | undefined`** *(1.1.5)* the handle's own descriptor. **Re-walkable**: pass it back into any `forEach*` to recurse the graph. `undefined` on a non-handle.
355
- - **`forEachOwned(handle, fn)`** *(1.2.1)* walk this node's owned children (lifetime-binding edges from the 1.2 owner tree). The dep/sub edges show DATA FLOW; the owner edges show LIFETIME BINDING when this handle re-runs or is disposed, every owned child is cascade-disposed. No-op on a non-handle, top-level handle with no children, or stale handle.
356
- - **`ownerOf(handle)` `{ id, kind, value } | undefined`** *(1.2.1)* descriptor of `handle`'s owner, or `undefined` for top-level / stale handles. The inverse of `forEachOwned`: walks UP the owner tree.
369
+ - **`hasObservers(handle)` -> `boolean`** -- O(1) (`node.headSub !== null`). The auto-pause predicate.
370
+ - **`observeObservers(handle, { onConnect?, onDisconnect? })` -> `unobserve`** -- fires on the 0->1 and 1->0 observer transitions *after* registration (transition-only -- no immediate fire if already observed). Re-tracking a persistently-read source does **not** churn. This is the hook `lite-time` / `lite-raf` use to run a clock only while a derived value is watched. Throws `TypeError` on a non-handle.
371
+ - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`** -- walk subscribers / dependencies; `fn` gets a `{ id, kind, value }` descriptor (`kind` in `"signal" | "computed" | "effect"`; `id` added in 1.1.5). No-op on a non-handle.
372
+ - **`nodeId(handle)` -> `number | undefined`** *(1.1.5)* -- the node's stable per-allocation id; the dedupe key for graph traversal. `undefined` on a non-handle.
373
+ - **`describe(handle)` -> `{ id, kind, value } | undefined`** *(1.1.5)* -- the handle's own descriptor. **Re-walkable**: pass it back into any `forEach*` to recurse the graph. `undefined` on a non-handle.
374
+ - **`forEachOwned(handle, fn)`** *(1.2.1)* -- walk this node's owned children (lifetime-binding edges from the 1.2 owner tree). The dep/sub edges show DATA FLOW; the owner edges show LIFETIME BINDING -- when this handle re-runs or is disposed, every owned child is cascade-disposed. No-op on a non-handle, top-level handle with no children, or stale handle.
375
+ - **`ownerOf(handle)` -> `{ id, kind, value } | undefined`** *(1.2.1)* -- descriptor of `handle`'s owner, or `undefined` for top-level / stale handles. The inverse of `forEachOwned`: walks UP the owner tree.
357
376
 
358
- The surface is gated by an internal lifecycle counter: when nothing is being observed, the hot path adds a single branch-predicted `count !== 0` check in link alloc/free and nothing else **zero steady-state cost when unused**.
377
+ The surface is gated by an internal lifecycle counter: when nothing is being observed, the hot path adds a single branch-predicted `count !== 0` check in link alloc/free and nothing else -- **zero steady-state cost when unused**.
359
378
 
360
379
  #### Stale-handle guard (1.2.1)
361
380
 
362
- The 1.2.0 owner tree makes the engine recycle pool slots autonomously: when an effect or computed re-runs, every observer it created in its previous body is cascade-disposed. **Holding a stale handle stopped being a user error and became routine.** Pre-1.2.1, the introspection surface plus `peek()` resolved `NODE_PTR` ungated and would happily report the recycled slot's NEW resident wrong id, wrong value, wrong edges.
381
+ The 1.2.0 owner tree makes the engine recycle pool slots autonomously: when an effect or computed re-runs, every observer it created in its previous body is cascade-disposed. **Holding a stale handle stopped being a user error and became routine.** Pre-1.2.1, the introspection surface plus `peek()` resolved `NODE_PTR` ungated and would happily report the recycled slot's NEW resident -- wrong id, wrong value, wrong edges.
363
382
 
364
383
  1.2.1 generation-checks every entry point that resolves a handle (the same ABA discipline `dispose()` always had):
365
384
 
366
- - `nodeId`, `describe`, `hasObservers`, `forEachObserver`, `forEachSource`, `forEachOwned`, `ownerOf`, `signal.peek()`, `computed.peek()`, `signal()`/`computed()` read, `signal.set()` return `undefined` / are no-ops on stale handles.
385
+ - `nodeId`, `describe`, `hasObservers`, `forEachObserver`, `forEachSource`, `forEachOwned`, `ownerOf`, `signal.peek()`, `computed.peek()`, `signal()`/`computed()` read, `signal.set()` -> return `undefined` / are no-ops on stale handles.
367
386
  - `observeObservers` throws `TypeError` (matching the existing non-handle contract).
368
387
 
369
388
  Descriptors returned by `describe()` and the `forEach*` walkers are themselves gen-stamped, so the documented "descriptors are re-walkable handles" contract survives the guard: a fresh descriptor walks, one held across a recycle correctly goes stale.
@@ -382,17 +401,17 @@ const unsub = onGraphMutation((opcode, intA, intB) => {
382
401
  }
383
402
  });
384
403
 
385
- // Stop listening restores the previous registration (or null), engine returns to zero-cost state.
404
+ // Stop listening -- restores the previous registration (or null), engine returns to zero-cost state.
386
405
  unsub();
387
406
  ```
388
407
 
389
- A registry-level (and top-level) debug hook for push-based tooling the connection point lite-devtools 1.1 and lite-studio 1.1 use to walk away from polling. Single nullable listener; every fire point in the engine is one `if (mutationHook !== null) mutationHook(opcode, intA, intB)`:
408
+ A registry-level (and top-level) debug hook for push-based tooling -- the connection point lite-devtools 1.1 and lite-studio 1.1 use to walk away from polling. Single nullable listener; every fire point in the engine is one `if (mutationHook !== null) mutationHook(opcode, intA, intB)`:
390
409
 
391
- - **Zero cost when unregistered** branch-predicted null check per mutation point, same as the lifecycle counter pattern.
392
- - **Allocation-free when registered** three integers, no objects, no closures. Worst-case measured cost on a dynamic-retracking torture loop (11.4M events over 400K writes) is +29% a debug-mode tax proportional to event volume, paid only while a consumer is attached.
393
- - **LIFO stacking** `onGraphMutation(a); onGraphMutation(b); unsubB()` restores `a`. Used by lite-devtools 1.1 to multiplex multiple consumers behind one engine registration.
410
+ - **Zero cost when unregistered** -- branch-predicted null check per mutation point, same as the lifecycle counter pattern.
411
+ - **Allocation-free when registered** -- three integers, no objects, no closures. Worst-case measured cost on a dynamic-retracking torture loop (11.4M events over 400K writes) is +29% -- a debug-mode tax proportional to event volume, paid only while a consumer is attached.
412
+ - **LIFO stacking** -- `onGraphMutation(a); onGraphMutation(b); unsubB()` restores `a`. Used by lite-devtools 1.1 to multiplex multiple consumers behind one engine registration.
394
413
 
395
- **Listener contract: observe only never throw, never mutate the graph from inside.** The hook fires synchronously inside mutation points; mutating from the callback corrupts the in-flight operation. Wrap any downstream work that could touch the registry in a microtask.
414
+ **Listener contract: observe only -- never throw, never mutate the graph from inside.** The hook fires synchronously inside mutation points; mutating from the callback corrupts the in-flight operation. Wrap any downstream work that could touch the registry in a microtask.
396
415
 
397
416
  ### onCleanup
398
417
 
@@ -403,7 +422,7 @@ effect(() => {
403
422
  });
404
423
  ```
405
424
 
406
- Registers a teardown for the *current* computation. Fires before every re-run and once on dispose. Supports multiple cleanups per scope (they're stored as a flat list, run in registration order). Works inside computeds too useful for canceling async work when memos become stale.
425
+ Registers a teardown for the *current* computation. Fires before every re-run and once on dispose. Supports multiple cleanups per scope (they're stored as a flat list, run in registration order). Works inside computeds too -- useful for canceling async work when memos become stale.
407
426
 
408
427
  ### dispose
409
428
 
@@ -412,14 +431,14 @@ const s = signal(0);
412
431
  const c = computed(() => s() * 2);
413
432
  const e = effect(() => { /* ... */ });
414
433
 
415
- dispose(s); // signal returns the node to the pool
416
- dispose(c); // computed same, also unlinks its upstreams
417
- dispose(e); // effect handle identical to calling e()
434
+ dispose(s); // signal -> returns the node to the pool
435
+ dispose(c); // computed -> same, also unlinks its upstreams
436
+ dispose(e); // effect handle -> identical to calling e()
418
437
  ```
419
438
 
420
- One function for all three primitives. Idempotent. Cross-registry calls are silent no-ops each registry holds a private `Symbol("node_ptr")` keyed on its own nodes, so passing a signal from registry A to `registry B.dispose()` won't corrupt either pool. Passing an unrelated value (`null`, `42`, `{}`) is also a safe no-op. Passing an arbitrary function invokes it (the effect-handle contract).
439
+ One function for all three primitives. Idempotent. Cross-registry calls are silent no-ops -- each registry holds a private `Symbol("node_ptr")` keyed on its own nodes, so passing a signal from registry A to `registry B.dispose()` won't corrupt either pool. Passing an unrelated value (`null`, `42`, `{}`) is also a safe no-op. Passing an arbitrary function invokes it (the effect-handle contract).
421
440
 
422
- The effect dispose handle (`const dispose = effect(...)`) is still a plain function you can call it directly. `dispose()` exists to unify the call site when you're managing a heterogeneous bag of reactive resources, which is the common case for component teardown and tests.
441
+ The effect dispose handle (`const dispose = effect(...)`) is still a plain function -- you can call it directly. `dispose()` exists to unify the call site when you're managing a heterogeneous bag of reactive resources, which is the common case for component teardown and tests.
423
442
 
424
443
  ### createRegistry
425
444
 
@@ -436,7 +455,7 @@ const e = r.effect(() => s());
436
455
  r.destroy(); // reset all pools, invalidate generations
437
456
  ```
438
457
 
439
- `createRegistry` is the unit of isolation. Two registries share no state useful for multi-tenant code, plugin sandboxes, and tests that need a fresh world.
458
+ `createRegistry` is the unit of isolation. Two registries share no state -- useful for multi-tenant code, plugin sandboxes, and tests that need a fresh world.
440
459
 
441
460
  `setDefaultRegistry(r)` swaps the registry used by top-level helpers. Use sparingly; intended for test setup.
442
461
 
@@ -459,13 +478,13 @@ flowchart LR
459
478
  E -- no --> G[allocate, continue]
460
479
  ```
461
480
 
462
- Why a ceiling? Unbounded growth hides leaks. If your app reaches 16× its starting link capacity, something is wrong and you want to know `CapacityError` is louder than a slow OOM crash four hours later.
481
+ Why a ceiling? Unbounded growth hides leaks. If your app reaches 16× its starting link capacity, something is wrong and you want to know -- `CapacityError` is louder than a slow OOM crash four hours later.
463
482
 
464
483
  Default sizing for a Twitch-extension-style budget:
465
484
 
466
485
  | Workload | maxNodes | maxLinks | policy |
467
486
  | ----------------------------------- | -------- | -------- | -------- |
468
- | Tiny widget (50 reactive cells) | 256 | 1024 | `"throw"` |
487
+ | Tiny widget (<=50 reactive cells) | 256 | 1024 | `"throw"` |
469
488
  | Standard overlay (~500 cells) | 1024 | 4096 | `"throw"` |
470
489
  | Heavy dashboard (variable scale) | 2048 | 16384 | `"grow"` |
471
490
 
@@ -477,34 +496,34 @@ Default sizing for a Twitch-extension-style budget:
477
496
 
478
497
  ## Watchers
479
498
 
480
- `@zakkster/lite-signal` ships three composable watcher primitives, all built from `effect` + `untrack` no engine extensions, no per-watcher flag in `ReactiveNode`. The core stays small; the surface stays useful.
499
+ `@zakkster/lite-signal` ships three composable watcher primitives, all built from `effect` + `untrack` -- no engine extensions, no per-watcher flag in `ReactiveNode`. The core stays small; the surface stays useful.
481
500
 
482
501
  | API | Use case | Lifecycle | Hot-path safe? |
483
502
  |---|---|---|---|
484
503
  | `watch(source, cb)` | observe value changes over time | manual `stop()` | ✅ zero-GC per fire |
485
- | `watch(source, (v, p, stop) => )` | observe until a condition | self-dispose via callback arg | ✅ zero-GC per fire |
504
+ | `watch(source, (v, p, stop) => ...)` | observe until a condition | self-dispose via callback arg | ✅ zero-GC per fire |
486
505
  | `when(predicate, cb)` | one-shot trigger when condition first true | auto-dispose | ✅ zero-GC per check |
487
- | `whenAsync(predicate)` | await a condition | auto-dispose | ⚠️ allocates Promise see below |
506
+ | `whenAsync(predicate)` | await a condition | auto-dispose | ! allocates Promise -- see below |
488
507
 
489
508
  ### `watch(source, callback, options?)`
490
509
 
491
- Fires the callback whenever the source's projected value changes. The callback receives `(newValue, oldValue, stop)` calling `stop()` from inside the callback disposes the watcher.
510
+ Fires the callback whenever the source's projected value changes. The callback receives `(newValue, oldValue, stop)` -- calling `stop()` from inside the callback disposes the watcher.
492
511
 
493
512
  ```js
494
513
  import { signal, watch } from "@zakkster/lite-signal";
495
514
 
496
515
  const count = signal(0);
497
516
 
498
- // Basic observe forever
517
+ // Basic -- observe forever
499
518
  const stop = watch(count, (next, prev) => {
500
- console.log(`${prev} ${next}`);
519
+ console.log(`${prev} -> ${next}`);
501
520
  });
502
521
 
503
- count.set(1); // logs: 0 1
522
+ count.set(1); // logs: 0 -> 1
504
523
  stop(); // manual dispose
505
524
  ```
506
525
 
507
- **Self-disposing watcher** declarative termination from inside the callback:
526
+ **Self-disposing watcher** -- declarative termination from inside the callback:
508
527
 
509
528
  ```js
510
529
  watch(status, (next, prev, stop) => {
@@ -515,25 +534,25 @@ watch(status, (next, prev, stop) => {
515
534
  });
516
535
  ```
517
536
 
518
- **Immediate option** fires once on registration with `oldValue = undefined`:
537
+ **Immediate option** -- fires once on registration with `oldValue = undefined`:
519
538
 
520
539
  ```js
521
540
  watch(theme, (v) => applyTheme(v), { immediate: true });
522
541
  ```
523
542
 
524
- **Raw getter equality** `watch` uses `Object.is` internally to avoid spurious fires when a dep mutation produces the same projected value:
543
+ **Raw getter equality** -- `watch` uses `Object.is` internally to avoid spurious fires when a dep mutation produces the same projected value:
525
544
 
526
545
  ```js
527
546
  const health = signal(10);
528
547
  let deathLog = 0;
529
548
  watch(() => health() <= 0, (isDead) => { deathLog++; });
530
549
 
531
- health.set(9); // isDead is still false no fire
532
- health.set(8); // same no fire
533
- health.set(0); // crossed fires once with (true, false)
550
+ health.set(9); // isDead is still false -- no fire
551
+ health.set(8); // same -- no fire
552
+ health.set(0); // crossed -- fires once with (true, false)
534
553
  ```
535
554
 
536
- Without this guard, the callback would fire on every `health` mutation regardless of whether `isDead` changed. Wrapping the source in `computed()` would achieve the same via the computed's own equality check the guard makes that wrapping optional.
555
+ Without this guard, the callback would fire on every `health` mutation regardless of whether `isDead` changed. Wrapping the source in `computed()` would achieve the same via the computed's own equality check -- the guard makes that wrapping optional.
537
556
 
538
557
  ### `when(predicate, callback)`
539
558
 
@@ -556,9 +575,9 @@ if (userBacked) cancel();
556
575
 
557
576
  ### `whenAsync(predicate)`
558
577
 
559
- > ### ⚠️ Hot-path warning
578
+ > ### ! Hot-path warning
560
579
  >
561
- > `whenAsync` calls `new Promise(...)` internally **this is a heap allocation**. Every call allocates a Promise object, an executor closure, and Promise infrastructure (resolve function, microtask state). Promises require heap allocation by the language spec; this cost is unavoidable.
580
+ > `whenAsync` calls `new Promise(...)` internally -- **this is a heap allocation**. Every call allocates a Promise object, an executor closure, and Promise infrastructure (resolve function, microtask state). Promises require heap allocation by the language spec; this cost is unavoidable.
562
581
  >
563
582
  > **Use for:** high-level scene/UI orchestration, boot sequences, awaiting user input, level transitions. Anything that runs once or rarely.
564
583
  >
@@ -579,7 +598,7 @@ async function bootSequence() {
579
598
  }
580
599
  ```
581
600
 
582
- The promise never rejects on its own if the predicate never becomes truthy, the promise never settles. For timeout semantics use `Promise.race`:
601
+ The promise never rejects on its own -- if the predicate never becomes truthy, the promise never settles. For timeout semantics use `Promise.race`:
583
602
 
584
603
  ```js
585
604
  await Promise.race([
@@ -598,34 +617,34 @@ Honest accounting of where memory is spent in each primitive:
598
617
  | `when(predicate, cb)` | 2 closures (stop, effect body) | **0** |
599
618
  | `whenAsync(predicate)` | 1 Promise + 1 executor closure + Promise internals + 2 closures from `when` | **0** (after registration) |
600
619
 
601
- The "0 per fire" property for `watch` is deliberate engineering the inner `untrack` callback is hoisted to a single closure allocated once at registration, with `currentNewValue` as shared mutable state. If you read the source and wonder why we don't use a clean inline arrow function inside the effect body, this is the answer: doing it inline would allocate a fresh closure on every dep change, at 7,200 allocs per minute per watcher at 120 fps.
620
+ The "0 per fire" property for `watch` is deliberate engineering -- the inner `untrack` callback is hoisted to a single closure allocated once at registration, with `currentNewValue` as shared mutable state. If you read the source and wonder why we don't use a clean inline arrow function inside the effect body, this is the answer: doing it inline would allocate a fresh closure on every dep change, at 7,200 allocs per minute per watcher at 120 fps.
602
621
 
603
622
  ### Tree-shaking
604
623
 
605
- All three primitives live in a separate module (`Watch.js`) and are re-exported from the main entry (which binds them to its own `effect`/`untrack`, so there is exactly one engine instance). If your bundle doesn't import them, they won't appear in the output modern ESM tree-shaking (Vite, Rollup, esbuild) handles this reliably.
624
+ All three primitives live in a separate module (`Watch.js`) and are re-exported from the main entry (which binds them to its own `effect`/`untrack`, so there is exactly one engine instance). If your bundle doesn't import them, they won't appear in the output -- modern ESM tree-shaking (Vite, Rollup, esbuild) handles this reliably.
606
625
 
607
626
  ---
608
627
 
609
628
  ## Edge cases pinned down
610
629
 
611
630
  <details>
612
- <summary>Diamonds, self-feedback, nested-effect ownership (v1.2), pre-batch revert (v1.2), multi-throw AggregateError (v1.2), NaN0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
631
+ <summary>Diamonds, self-feedback, nested-effect ownership (v1.2), pre-batch revert (v1.2), multi-throw AggregateError (v1.2), NaN/+/-0, throwing bodies, 32-bit version wrap, deep-chain limits.</summary>
613
632
 
614
633
  These are the questions you'd ask in a code review, with the answers:
615
634
 
616
635
  - **Diamond dependency.** Glitch-free. The mark phase walks the graph once; computeds are pulled lazily on read, so each one re-runs at most once per propagation regardless of how many paths reach it.
617
- - **Writing to a signal during its own effect (self-feedback loop).** The new value re-queues the effect into the alternate buffer. After 100 flush passes (configurable), `CycleError` is thrown you have a real loop, not just a deep update.
618
- - **Writing to a signal *inside its computed*.** Throws `CycleError` immediately at the inner `set` this is a structural cycle, not a deep update, and the engine refuses to attempt it.
619
- - **Nested effects (v1.2 owner tree).** An effect or computed that creates nested observers (effect/computed) **owns** them. When the owner re-runs or is disposed, those owned children cascade-dispose before the new run no leaked nested subscriptions, no manual bookkeeping. Plain signals are deliberately NOT owner-adopted so lazy-allocation wrappers (lite-store keys, lite-form fields) continue to survive their allocating computed's re-runs.
636
+ - **Writing to a signal during its own effect (self-feedback loop).** The new value re-queues the effect into the alternate buffer. After 100 flush passes (configurable), `CycleError` is thrown -- you have a real loop, not just a deep update.
637
+ - **Writing to a signal *inside its computed*.** Throws `CycleError` immediately at the inner `set` -- this is a structural cycle, not a deep update, and the engine refuses to attempt it.
638
+ - **Nested effects (v1.2 owner tree).** An effect or computed that creates nested observers (effect/computed) **owns** them. When the owner re-runs or is disposed, those owned children cascade-dispose before the new run -- no leaked nested subscriptions, no manual bookkeeping. Plain signals are deliberately NOT owner-adopted so lazy-allocation wrappers (lite-store keys, lite-form fields) continue to survive their allocating computed's re-runs.
620
639
  - **Pre-batch revert (v1.2).** Inside `batch(...)`, if a signal is set and then set back to its pre-batch value (under its own `equals`), the version bump is reverted and downstream effects/computeds do not fire. Eliminates a class of spurious re-runs from temporary state mutations.
621
640
  - **Multi-throw in one flush (v1.2).** Two or more effects throwing in the same flush pass aggregate to `AggregateError` at the triggering `set()` / batch boundary; effects that don't throw still run. A single thrown error is rethrown unwrapped (no API change for the common case).
622
641
  - **NaN, -0, +0.** Default `equals` is `Object.is`. `NaN === NaN` is true for our purposes (so setting NaN twice doesn't re-fire). `-0` and `+0` are distinct.
623
642
  - **First-run effect throws.** The half-initialised node is disposed cleanly, deps unlinked, then the error propagates to the caller. No leaked dangling subscriptions.
624
643
  - **Computed throws.** The error is cached on the node (`FLAG_HAS_ERROR`) and re-thrown on every subsequent read until a dependency changes. This is symmetric with successful caching.
625
- - **Dispose during flush.** Effects re-check their generation (`gen`) before running through a scheduler trampoline. If `dispose()` bumped the gen between schedule and execute, the trampoline becomes a no-op. The trampoline closure is cached on the node (v1.2) so repeated re-schedules reuse the same function ABA safe under async schedulers.
626
- - **32-bit version wrap.** Versions are `(... + 1) | 0`, so after 2^31 writes they wrap to a negative number. The comparison `((dep.version - evalVer) | 0) > 0` is wrap-safe it works on the *modular distance*, not raw integer ordering.
627
- - **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~5,000 deep risk `RangeError: Maximum call stack size exceeded`. Effects use an iterative mark phase, so signal effect fan-out has no depth limit other than memory.
628
- - **`destroy()` after dispose.** `destroy()` bumps every node's generation, so any in-flight scheduled trampolines from before destruction are silently dropped. Closures returned to user code from disposed effects guard with `if (node.flags === 0) return;` calling `dispose()` again is a no-op.
644
+ - **Dispose during flush.** Effects re-check their generation (`gen`) before running through a scheduler trampoline. If `dispose()` bumped the gen between schedule and execute, the trampoline becomes a no-op. The trampoline closure is cached on the node (v1.2) so repeated re-schedules reuse the same function -- ABA safe under async schedulers.
645
+ - **32-bit version wrap.** Versions are `(... + 1) | 0`, so after 2^31 writes they wrap to a negative number. The comparison `((dep.version - evalVer) | 0) > 0` is wrap-safe -- it works on the *modular distance*, not raw integer ordering.
646
+ - **Deep chain depth.** Computed resolution is recursive in the JS call stack. Chains beyond ~5,000 deep risk `RangeError: Maximum call stack size exceeded`. Effects use an iterative mark phase, so signal -> effect fan-out has no depth limit other than memory.
647
+ - **`destroy()` after dispose.** `destroy()` bumps every node's generation, so any in-flight scheduled trampolines from before destruction are silently dropped. Closures returned to user code from disposed effects guard with `if (node.flags === 0) return;` -- calling `dispose()` again is a no-op.
629
648
 
630
649
  </details>
631
650
 
@@ -633,31 +652,33 @@ These are the questions you'd ask in a code review, with the answers:
633
652
 
634
653
  ## Benchmarks
635
654
 
636
- Honest numbers, against the same workload, with anti-DCE sinks and verified effect execution. All measurements: Node 22, **2016-era Intel MacBook Pro (4 cores, ~10 yr old hardware)**, 20K iterations × 5 inner runs × 10 outer invocations (median reported). Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.2.0** vs alien-signals on the same loop; the full five-framework comparison (incl. preact, vue-reactivity, solid across 34 reactive-suite tests) is in [`resultsReactive.txt`](./resultsReactive.txt). *(Both halves are now @1.2.0 this throughput table median-of-10, the cross-framework reactivity suite median-of-10 in [`resultsReactive.txt`](./resultsReactive.txt). 1.2.0 is drop-in over 1.1.5; the hot paths are byte-identical, so steady-state numbers are within run-to-run noise.)*
655
+ Honest numbers, against the same workload, with anti-DCE sinks and verified effect execution. All measurements: Node 22, **2016-era Intel MacBook Pro (4 cores, ~10 yr old hardware)**, 20K iterations, **one engine per cold process**, median of 10 isolated runs. Newer/faster machines shift all libs up proportionally; the relative ordering between libs is what matters. Numbers below are lite-signal **@1.2.2** vs alien-signals on the same loop; the full five-framework comparison (incl. preact, vue-reactivity, solid across 34 reactive-suite tests) is in [`resultsReactive.txt`](./resultsReactive.txt). *(These numbers use the corrected one-engine-per-process protocol -- the prior 1.2.0 table ran several engines in one process, which let nursery-allocating engines borrow a warm heap and polluted shared inline caches. 1.2.2 is drop-in over 1.2.0; the hot paths are byte-identical, so the table moves here are the measurement correction, not engine changes. The 1.2.0 single-process table is in git history.)*
637
656
 
638
657
  | Scenario | What it stresses | lite-signal | alien-signals | lite vs alien |
639
658
  | ---------- | -------------------------------- | ----------- | ------------- | ------------- |
640
- | **MUX** | 256 signals 1 sum 1 effect (fan-in) | **318K ops/s** | 203K | **+38%** |
641
- | **KAIROS** | 1 signal 1000 computeds 1 effect | **15K** | 13K | **+18%** |
642
- | **BROADCAST** | 1 signal 1000 effects (fan-out) | **25K** | 24K | **+9%** |
643
- | **DEEP CHAIN** | 256-deep computed chain 1 effect | 52K | **66K** | −21% |
644
- | **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **2K** | 2K | **+9%** |
645
- | **LARGE WEB APP** | 12 layers × ~80 wide, conditional reads | **7K** | 7K | **+2%** |
646
- | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | **7K** | 7K | **+4%** |
647
- | **Δheap MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 7,780 KB | — |
648
- | **Retained MUX** | state surviving forced GC | **−9 KB** (none) | −2 KB | |
659
+ | **MUX** | 256 signals -> 1 sum -> 1 effect (fan-in) | **293K ops/s** | 190K | **+35%** |
660
+ | **DYNAMIC DAG** | sqrt-layered, FAN=6, read flips each iter | **2K** | 1K | **+44%** |
661
+ | **SELECTIVE DAG** | sqrt-layered, set churn, 2 read/iter | **4K** | 2K | **+48%** |
662
+ | **SMALL SELECTIVE** | 6 layers × 64 wide, 6 cand / 3 read | **10K** | 7K | **+31%** |
663
+ | **KAIROS** | 1 signal -> 1000 computeds -> 1 effect | 15K | 16K | -4% |
664
+ | **BROADCAST** | 1 signal -> 1000 effects (fan-out) | 18K | 19K | -7% |
665
+ | **WIDE DENSE** | 5 layers × ~200 wide, dense fan-in | 7K | 7K | -5% |
666
+ | **LARGE WEB APP** | 12 layers × ~80 wide, conditional reads | 7K | 7K | -7% |
667
+ | **DEEP CHAIN** | 256-deep computed chain -> 1 effect | 49K | **59K** | -19% |
668
+ | **heap-delta MUX** | transient alloc pressure, 20K iters | **0.3 KB** | 7,780 KB | -- |
669
+ | **Retained MUX** | state surviving forced GC | **-9 KB** (none) | -2 KB | -- |
649
670
 
650
- **Reading the table:** `lite-signal` wins **MUX** (fan-in aggregation) by **+38%**, **KAIROS** (one source feeding a wide layer of memos) by **+18%**, **BROADCAST** (fan-out) by **+9%**, and the three dynamic-topology shapes (**DYNAMIC DAG**, **LARGE WEB APP**, **WIDE DENSE**) the patterns that dominate real UI workloads: dashboards, scoreboards, HUDs, leaderboards, and any view that aggregates many inputs into a single computed slice. `alien-signals` retains its **−21% lead on DEEP CHAIN** (256-deep computed pipelines), where a flatter internal representation pays off when the propagation path is long rather than wide. The two narrower-than-1.1.5 shapes SELECTIVE DAG and SMALL SELECTIVE — are construction-bound; see the `S: createComputations*` rows in [`resultsReactive.txt`](./resultsReactive.txt) for the underlying cost.
671
+ **Reading the table:** lite-signal's wins cluster exactly where its zero-GC design pays off -- the **allocation-heavy dynamic shapes** (**DYNAMIC DAG +44%**, **SELECTIVE DAG +48%**, **SMALL SELECTIVE +31%**), where alien-signals churns the nursery and lite's object pool allocates nothing, plus **MUX +35%** (fan-in aggregation). These are the patterns that dominate live UI workloads under input churn: dashboards, scoreboards, HUDs, leaderboards. On the cheap, low-allocation **stable** shapes (KAIROS, BROADCAST, wide app/dense) lite runs at **parity** with alien -- within a 4-7% band that is inside this old host's run-to-run noise. The one structural loss is **DEEP CHAIN (-19%)**: on a 256-deep computed pipeline alien's flatter representation wins because the propagation path is long rather than wide.
651
672
 
652
- On allocation pressure, `lite-signal` is alone in the zero-Δheap band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on SMALL SELECTIVE lite-signal 0.3 KB vs alien-signals **~15 MB** in the same loop. preact ranges from ~220 KB to low-single-digit MB per loop, solid runs into single-digit megabytes, and alien-signals — which earlier shared the zero-GC band with lite-signal — allocates from near-zero (BROADCAST) up to ~6 MB (MUX) per stable scenario in this run. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC no leaks anywhere.
673
+ On allocation pressure, `lite-signal` is alone in the zero-alloc band: ~0.3 KB of transient garbage on stable shapes across 20,000 iterations. The contrast is starkest on the dynamic DAGs -- lite allocates 9-13 MB (genuine retracking re-links) where alien-signals allocates 39-42 MB on the same shapes, and that allocation gap is the mechanism behind lite's +44-48% wins there once each engine is measured in isolation. preact ranges from ~220 KB to low-single-digit MB per stable loop, solid runs into single-digit megabytes. Negative "retained" numbers mean V8 reclaimed memory below the pre-bench baseline during the post-run forced GC -- no leaks anywhere.
653
674
 
654
- > Note on the +70.8 KB retained that lite-signal shows on KAIROS specifically: that's the pre-allocated pool sitting in memory holding the live graph (1002 nodes + ~2000 links). The pool *is* the working memory see the [Case for object pooling](#case-for-object-pooling) section. On the other benches the graph is small enough that the same pool floats below baseline after GC.
675
+ > Note on the +70.8 KB retained that lite-signal shows on KAIROS specifically: that's the pre-allocated pool sitting in memory holding the live graph (1002 nodes + ~2000 links). The pool *is* the working memory -- see the [Case for object pooling](#case-for-object-pooling) section. On the other benches the graph is small enough that the same pool floats below baseline after GC.
655
676
 
656
- The benchmark harness is in [`bench/benchmark.mjs`](./bench/benchmark.mjs); a full methodology write-up including the anti-DCE design, workload diagrams, variance discipline, reproducibility recipe, and a self-validation procedure for the harness itself lives in [`bench/README.md`](./bench/README.md). It:
677
+ The benchmark harness is in [`bench/benchmark.mjs`](./bench/benchmark.mjs); a full methodology write-up -- including the anti-DCE design, workload diagrams, variance discipline, reproducibility recipe, and a self-validation procedure for the harness itself -- lives in [`bench/README.md`](./bench/README.md). It:
657
678
 
658
- 1. Writes every effect's output to a shared `Float64Array(4096)` exposed on `globalThis` V8 cannot prove these writes are dead.
679
+ 1. Writes every effect's output to a shared `Float64Array(4096)` exposed on `globalThis` -- V8 cannot prove these writes are dead.
659
680
  2. Uses the **client** Solid runtime (`solid-js/dist/solid.js`), not the SSR stub Node resolves to by default. The default Node resolution silently no-ops effects, which is how earlier benchmarks across the ecosystem have reported Solid at ~50 GHz throughput.
660
- 3. Validates each lib's sink slot is non-zero after the timed loop and prints `sink=✓` for each line. If you ever see `sink=✗`, the run is invalid.
681
+ 3. Validates each lib's sink slot is non-zero after the timed loop and prints `sink=[x]` for each line. If you ever see `sink=[ ]`, the run is invalid.
661
682
 
662
683
  Run it yourself:
663
684
 
@@ -672,38 +693,41 @@ npm run bench
672
693
 
673
694
  Three tiers, all reproducible.
674
695
 
675
- ### Tier 1 Behavior (unit tests, fast)
696
+ ### Tier 1 -- Behavior (unit tests, fast)
676
697
 
677
698
  `npm test` runs the suite in `test/`, covering:
678
699
 
679
- - **`01-core_test.mjs`** signal/computed/effect basics, equality semantics, NaN0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
680
- - **`02-topology_test.mjs`** diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
681
- - **`03-pool_test.mjs`** `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
682
- - **`05-scheduler_test.mjs`** scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
683
- - **`06-nested-objects_test.mjs`** array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
684
- - **`07-dispose_test.mjs`** unified `dispose(api)` across signals, computeds and effect handles, idempotency, cross-registry isolation (per-registry Symbol prevents pool corruption), foreign-value safety, top-level helper routing, 500-cycle balanced churn leaving pool and stats stable.
685
- - **`08-watch_test.mjs`** Validates the user-land observer utilities (watch, when, whenAsync). Covers lifecycle teardown, old/new value tracking, and Promise-based asynchronous state resolution.
686
- - **`09-conformance_test.mjs`** Industry-standard conformance tests. Validates the engine against extreme edge cases from the johnsoncodehk reactive test suite, ensuring strict zero-GC invariants, correct cleanup isolation, and re-entrant stability.
687
- - **`10-is-tracking_test.mjs`** The `isTracking()` observer-context predicate. 11 tests across 5 describe blocks: true inside effect/computed bodies; false inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and `watch` callbacks (the untracked-window cases that catch an observer-only misimplementation); false outside any observer including at the call site of an unobserved computed read; state-restoration after a thrown body; per-registry isolation; top-level binding.
688
- - **`11-adopted-reactive_test.mjs`** 24 engine-agnostic edge cases adopted from across the ecosystem: alien-signals' parent-child link-integrity regression (#226228), equality-predicate corners (preact/solid/vue), `signal.update(fn)` functional setter (vue/solid), `peek()` non-subscription depth (preact/vue), and the `subscribe` behavioral contract (preact/mobx).
689
- - **`12-coverage_test.mjs`** 18 targeted exercises for public surface and hot-path branches the behavioral suites don't incidentally hit: top-level routing to the default registry, the computed clean-read short-circuit (`markEpoch` O(1) skip), dependency-set shrink severing the stale tail, error/structural edge paths, scheduler ABA across a recycled pool slot, and the v1.2 owner-tree paths (direct-child detach, cascade tolerates an already-freed child). Capability-gated via a runtime probe, so the same file runs unchanged across engines.
690
- - **`13-introspection_test.mjs`** The observer-lifecycle surface (1.1.4). 10 tests across 3 describe blocks: `hasObservers` (live observation reflects; a peek doesn't count), `observeObservers` auto-pause lifecycle (start-on-first / stop-on-last, no extra connect for a 2nd observer, re-observe fires again, no churn on re-track, conditional reads toggle honestly, transition-only registration, works for computeds), and `forEachObserver`/`forEachSource` enumeration (both directions; descriptor carries kind + value).
691
- - **`14-lifecycle-teardown_test.mjs`** Effect-teardown guards against the alien-signals@3.2.1 regressions (4 tests). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link (clean `activeLinks`); a throwing setup must leave no live subscription; normal and dynamic re-tracking stay unaffected by the `allocateLink` eligibility gate.
692
- - **`15-owner-lazy-alloc_test.mjs`** Owner-adoption contract for the 1.2.0 owner tree (5 tests). A signal allocated lazily *inside* a computed/effect must **not** be owner-adopted (it survives the owner's re-run the lite-store/lite-form lazy-field shape) and sibling lazy signals must not cross-wire, while observers (nested effect/computed) *are* still auto-disposed on the owner's re-run.
693
- - **`16-alien-parity_test.mjs`** Differential regression guards (3 tests) reproducing the *properties* behind alien-signals@3.2.0 fixed bugs: reads inside a cleanup create no spurious dependencies (the dispose-cleanup fix); an inner-effect write does not block later propagation through a computed chain (#112); a dynamic dependency-set change stays correct under dirty-check (#109/#110).
694
- - **`17-reactivity_test.mjs`** Behavioral suite (30 tests across 11 groups) mirroring universal signal-system bug classes: subscription lifecycle, cleanup ordering, stale-dependency tracking, batching/timing (incl. set-then-revert), equality cutoff (NaN0/custom), nested invalidation + glitch-free diamond, memory/retained nodes, the synchronous async-boundary, scheduler & loops (self-write termination, self-reading computed), and differential-review additions (cached computed errors, mid-batch pull, self-disposing getter, pooled-slot return). SSR hydration is a documented N/A lite has no DOM layer.
695
- - **`18-identity_test.mjs`** Node identity (1.1.5; 5 tests). Unique/stable ids; `nodeId`/`describe` return `undefined` for a non-handle; the descriptor's visible shape is `{ id, kind, value }`; `forEach*` descriptors carry `id` and are **re-walkable** (`nodeId`/`forEachSource` accept a descriptor); identity walks are non-perturbing (add no observers).
696
- - **`19-v12-additions_test.mjs`** v1.2.0 release-prep regressions (24 tests across 8 suites). Shared `peek` (one closure per registry, identical reference across primitives, no tracking, two registries hold independent peeks). Owner-adoption rule (signals not adopted, computeds/effects adopted, cascade drains correctly). Pre-batch revert (signal-level, propagates through computeds, respects custom `equals`, nested batches, final-different-value still fires). Multi-throw aggregation (`AggregateError` with both errors carried, single-throw unwrapped, engine survives). `CycleError` via `maxFlushPasses` (default + custom). `maxLinks` config branch under `throw` and `grow`. Documented disposed-signal semantics (read undefined, set silent no-op, dispose idempotent). Scheduler-thunk ABA guard across a recycled pool slot.
697
- - **`20-axis-stress_test.mjs`** engine-invariant regression guards along eight orthogonal "axes" (16 tests across 9 suites). Pins lite-signal's actual contract on: batch semantics under exception (writes commit; pre-batch revert holds; effects see the post-throw value), connect/disconnect lifecycle re-entrancy (`observeObservers` from inside an `onConnect`, transition-only registration), untrack does NOT suppress owner adoption (a nested effect created via `untrack` is still owner-cascaded), untrack inside a computed body (no hidden dep leaks; tracked source re-evaluates), queue safety under self-dispose mid-flush (no UAF), value-dependent cycle detection (computed graph closes a cycle, `CycleError` thrown), nested-effect creation order (effects run synchronously on creation; immediately-stopped one still ran), synchronous flush (no scheduler in the default path; batch coalesces). Plus a bonus suite: 1,000 effect-create-then-dispose cycles return pool to baseline; `dispose()` idempotent; `dispose()` on foreign values safe.
698
- - **`21-perf-pins_test.mjs`** v1.2.1 construction-shape pins (6 tests). Locks the canonical handle shapes (`signal` 6 own props: peek/set/update/subscribe + NODE_PTR/NODE_GEN; `computed` 4: peek/subscribe + NODE_PTR/NODE_GEN) so a future "let's unify them" change has to be explicit. Locks the 1.2.1 ABA guards: detached `const {set} = signal()` keeps working on a LIVE signal; `read()` returns `undefined` and skips dep-tracking on a stale handle (no phantom subscription to the recycled slot); `set()` on a stale handle is a no-op across three corruption tiers (disposed slot, recycled slot, downstream propagation); `peek()` returns `undefined` for stale signal and computed handles.
699
- - **`22-mutation-hook_test.mjs`** 1.2.1 `onGraphMutation` semantics (12 tests across 2 suites). Registration: unsubscribe returns a function; `null` argument clears and the unsub restores the prior listener; non-function/non-null throws `TypeError`; multiple registrations stack LIFO; registries are isolated (no cross-talk). Opcode emission: `1` node-create fires with `(id, flags)` for signal (32) / computed (1) / effect (2); `2` node-dispose fires for cascade-disposed owned children; `3` link-add fires with `(source.id, target.id)` on dependency record; `4` link-remove fires when a dep-set flip severs the tail; `5` recompute fires on initial eval AND re-eval; the hook fires synchronously inside the mutation (listener sees its own event before the caller returns); payload is always three plain numbers no objects, no closures.
700
- - **`23-owner-introspection_test.mjs`** 1.2.1 owner-tree introspection + effect-disposer regression (22 tests across 4 suites). `ownerOf`: undefined for top-level / garbage input / stale handle; returns the enclosing effect's descriptor for a child created inside an effect body. `forEachOwned`: no-op for handles with no owned children / garbage input / stale handle; iterates owned children as `{id, kind, value}` descriptors. Gen-guarded introspection (ABA fix): `nodeId` / `describe` / `hasObservers` return undefined / false for stale handles; `observeObservers` throws `TypeError`; `forEachObserver` / `forEachSource` are no-ops; descriptors returned by `describeNode` are themselves gen-stamped so a descriptor obtained pre-recycle correctly walks as a no-op post-recycle (the "descriptors are re-walkable handles" contract survives the guard). Plus the 1.2.1 effect-dispose-handle fix: passing the effect's disposer directly to `describe` / `nodeId` / `forEachSource` / `forEachOwned` / `ownerOf` / `hasObservers` works as a first-class introspection handle (pre-fix it was a bare closure and returned `undefined` for a *live* effect); after `fx()` dispose the same handle correctly goes stale on every entry point; the disposer's `NODE_GEN` mirrors the effect node's birthGen exactly.
700
+ - **`01-core_test.mjs`** -- signal/computed/effect basics, equality semantics, NaN/+/-0, subscribe/peek/update, untrack, batch, cleanup ordering, first-run error recovery, nested object reference-identity gotchas.
701
+ - **`02-topology_test.mjs`** -- diamond glitch-freedom, 256-deep and 1024-deep computed chains, wide fan-out (1000 effects from one signal), dynamic dependency switching, conditional fan-out, nested effects, cycle detection (`CycleError`).
702
+ - **`03-pool_test.mjs`** -- `CapacityError` under both `"throw"` and `"grow"` policies, the 16× link ceiling, stable pool reuse across thousands of create/dispose cycles, registry isolation.
703
+ - **`05-scheduler_test.mjs`** -- scheduler-deferred effects, dispose-during-schedule races, microtask integration, 32-bit version wrap (simulated), `setDefaultRegistry`, `onCleanup` inside computeds.
704
+ - **`06-nested-objects_test.mjs`** -- array mutation patterns (push/splice/spread), deep nested paths, Map/Set/Date inside signals, custom structural equality, computed memoisation cutoffs over object slices, signal-of-signals composition, high-frequency object updates, batched immutable updates.
705
+ - **`07-dispose_test.mjs`** -- unified `dispose(api)` across signals, computeds and effect handles, idempotency, cross-registry isolation (per-registry Symbol prevents pool corruption), foreign-value safety, top-level helper routing, 500-cycle balanced churn leaving pool and stats stable.
706
+ - **`08-watch_test.mjs`** -- Validates the user-land observer utilities (watch, when, whenAsync). Covers lifecycle teardown, old/new value tracking, and Promise-based asynchronous state resolution.
707
+ - **`09-conformance_test.mjs`** -- Industry-standard conformance tests. Validates the engine against extreme edge cases from the johnsoncodehk reactive test suite, ensuring strict zero-GC invariants, correct cleanup isolation, and re-entrant stability.
708
+ - **`10-is-tracking_test.mjs`** -- The `isTracking()` observer-context predicate. 11 tests across 5 describe blocks: true inside effect/computed bodies; false inside `untrack`, `subscribe` callbacks, `onCleanup` bodies, and `watch` callbacks (the untracked-window cases that catch an observer-only misimplementation); false outside any observer including at the call site of an unobserved computed read; state-restoration after a thrown body; per-registry isolation; top-level binding.
709
+ - **`11-adopted-reactive_test.mjs`** -- 24 engine-agnostic edge cases adopted from across the ecosystem: alien-signals' parent-child link-integrity regression (#226-228), equality-predicate corners (preact/solid/vue), `signal.update(fn)` functional setter (vue/solid), `peek()` non-subscription depth (preact/vue), and the `subscribe` behavioral contract (preact/mobx).
710
+ - **`12-coverage_test.mjs`** -- 18 targeted exercises for public surface and hot-path branches the behavioral suites don't incidentally hit: top-level routing to the default registry, the computed clean-read short-circuit (`markEpoch` O(1) skip), dependency-set shrink severing the stale tail, error/structural edge paths, scheduler ABA across a recycled pool slot, and the v1.2 owner-tree paths (direct-child detach, cascade tolerates an already-freed child). Capability-gated via a runtime probe, so the same file runs unchanged across engines.
711
+ - **`13-introspection_test.mjs`** -- The observer-lifecycle surface (1.1.4). 10 tests across 3 describe blocks: `hasObservers` (live observation reflects; a peek doesn't count), `observeObservers` auto-pause lifecycle (start-on-first / stop-on-last, no extra connect for a 2nd observer, re-observe fires again, no churn on re-track, conditional reads toggle honestly, transition-only registration, works for computeds), and `forEachObserver`/`forEachSource` enumeration (both directions; descriptor carries kind + value).
712
+ - **`14-lifecycle-teardown_test.mjs`** -- Effect-teardown guards against the alien-signals@3.2.1 regressions (4 tests). A stopped effect must not re-subscribe to a signal read later in the same run; self-dispose must leave no orphaned link (clean `activeLinks`); a throwing setup must leave no live subscription; normal and dynamic re-tracking stay unaffected by the `allocateLink` eligibility gate.
713
+ - **`15-owner-lazy-alloc_test.mjs`** -- Owner-adoption contract for the 1.2.0 owner tree (5 tests). A signal allocated lazily *inside* a computed/effect must **not** be owner-adopted (it survives the owner's re-run -- the lite-store/lite-form lazy-field shape) and sibling lazy signals must not cross-wire, while observers (nested effect/computed) *are* still auto-disposed on the owner's re-run.
714
+ - **`16-alien-parity_test.mjs`** -- Differential regression guards (3 tests) reproducing the *properties* behind alien-signals@3.2.0 fixed bugs: reads inside a cleanup create no spurious dependencies (the dispose-cleanup fix); an inner-effect write does not block later propagation through a computed chain (#112); a dynamic dependency-set change stays correct under dirty-check (#109/#110).
715
+ - **`17-reactivity_test.mjs`** -- Behavioral suite (~30 tests across 11 groups) mirroring universal signal-system bug classes: subscription lifecycle, cleanup ordering, stale-dependency tracking, batching/timing (incl. set-then-revert), equality cutoff (NaN/+/-0/custom), nested invalidation + glitch-free diamond, memory/retained nodes, the synchronous async-boundary, scheduler & loops (self-write termination, self-reading computed), and differential-review additions (cached computed errors, mid-batch pull, self-disposing getter, pooled-slot return). SSR hydration is a documented N/A -- lite has no DOM layer.
716
+ - **`18-identity_test.mjs`** -- Node identity (1.1.5; 5 tests). Unique/stable ids; `nodeId`/`describe` return `undefined` for a non-handle; the descriptor's visible shape is `{ id, kind, value }`; `forEach*` descriptors carry `id` and are **re-walkable** (`nodeId`/`forEachSource` accept a descriptor); identity walks are non-perturbing (add no observers).
717
+ - **`19-v12-additions_test.mjs`** -- v1.2.0 release-prep regressions (24 tests across 8 suites). Shared `peek` (one closure per registry, identical reference across primitives, no tracking, two registries hold independent peeks). Owner-adoption rule (signals not adopted, computeds/effects adopted, cascade drains correctly). Pre-batch revert (signal-level, propagates through computeds, respects custom `equals`, nested batches, final-different-value still fires). Multi-throw aggregation (`AggregateError` with both errors carried, single-throw unwrapped, engine survives). `CycleError` via `maxFlushPasses` (default + custom). `maxLinks` config branch under `throw` and `grow`. Documented disposed-signal semantics (read undefined, set silent no-op, dispose idempotent). Scheduler-thunk ABA guard across a recycled pool slot.
718
+ - **`20-axis-stress_test.mjs`** -- engine-invariant regression guards along eight orthogonal "axes" (16 tests across 9 suites). Pins lite-signal's actual contract on: batch semantics under exception (writes commit; pre-batch revert holds; effects see the post-throw value), connect/disconnect lifecycle re-entrancy (`observeObservers` from inside an `onConnect`, transition-only registration), untrack does NOT suppress owner adoption (a nested effect created via `untrack` is still owner-cascaded), untrack inside a computed body (no hidden dep leaks; tracked source re-evaluates), queue safety under self-dispose mid-flush (no UAF), value-dependent cycle detection (computed graph closes a cycle, `CycleError` thrown), nested-effect creation order (effects run synchronously on creation; immediately-stopped one still ran), synchronous flush (no scheduler in the default path; batch coalesces). Plus a bonus suite: 1,000 effect-create-then-dispose cycles return pool to baseline; `dispose()` idempotent; `dispose()` on foreign values safe.
719
+ - **`21-perf-pins_test.mjs`** -- v1.2.1 construction-shape pins (6 tests). Locks the canonical handle shapes (`signal` 6 own props: peek/set/update/subscribe + NODE_PTR/NODE_GEN; `computed` 4: peek/subscribe + NODE_PTR/NODE_GEN) so a future "let's unify them" change has to be explicit. Locks the 1.2.1 ABA guards: detached `const {set} = signal()` keeps working on a LIVE signal; `read()` returns `undefined` and skips dep-tracking on a stale handle (no phantom subscription to the recycled slot); `set()` on a stale handle is a no-op across three corruption tiers (disposed slot, recycled slot, downstream propagation); `peek()` returns `undefined` for stale signal and computed handles.
720
+ - **`22-mutation-hook_test.mjs`** -- 1.2.1 `onGraphMutation` semantics (12 tests across 2 suites). Registration: unsubscribe returns a function; `null` argument clears and the unsub restores the prior listener; non-function/non-null throws `TypeError`; multiple registrations stack LIFO; registries are isolated (no cross-talk). Opcode emission: `1` node-create fires with `(id, flags)` for signal (32) / computed (1) / effect (2); `2` node-dispose fires for cascade-disposed owned children; `3` link-add fires with `(source.id, target.id)` on dependency record; `4` link-remove fires when a dep-set flip severs the tail; `5` recompute fires on initial eval AND re-eval; the hook fires synchronously inside the mutation (listener sees its own event before the caller returns); payload is always three plain numbers -- no objects, no closures.
721
+ - **`23-owner-introspection_test.mjs`** -- 1.2.1 owner-tree introspection + effect-disposer regression (22 tests across 4 suites). `ownerOf`: undefined for top-level / garbage input / stale handle; returns the enclosing effect's descriptor for a child created inside an effect body. `forEachOwned`: no-op for handles with no owned children / garbage input / stale handle; iterates owned children as `{id, kind, value}` descriptors. Gen-guarded introspection (ABA fix): `nodeId` / `describe` / `hasObservers` return undefined / false for stale handles; `observeObservers` throws `TypeError`; `forEachObserver` / `forEachSource` are no-ops; descriptors returned by `describeNode` are themselves gen-stamped so a descriptor obtained pre-recycle correctly walks as a no-op post-recycle (the "descriptors are re-walkable handles" contract survives the guard). Plus the 1.2.1 effect-dispose-handle fix: passing the effect's disposer directly to `describe` / `nodeId` / `forEachSource` / `forEachOwned` / `ownerOf` / `hasObservers` works as a first-class introspection handle (pre-fix it was a bare closure and returned `undefined` for a *live* effect); after `fx()` dispose the same handle correctly goes stale on every entry point; the disposer's `NODE_GEN` mirrors the effect node's birthGen exactly.
722
+ - **`24-signalbox_test.mjs`** -- staged for v1.5.0; all 9 tests `{skip: true}` on 1.2.x. The `signalBox` / `computedBox` allocation-light handle API lands in 1.5.0; the suite is committed early so the surface is pinned and the skips are visible in the test count (the 10 skips on 1.2.2 are these 9 plus 1 architecturally-N/A SSR case in `17-reactivity`).
723
+ - **`25-devtools-real-boot_test.mjs`** -- Devtools/Studio contract (10 tests). Boots the actual `Devtools.js` against the 1.2.2 engine and exercises all 19 Devtools exports plus the 10 symbols Studio imports from Devtools. Pins the ghost contract: heavy introspection (graph walk, owner-tree, observer descriptors) adds **zero** nodes to the live graph. Catches the real-rig failure mode where importing the package by its own name from a repo whose `package.json` declares `name: "@zakkster/lite-signal"` resolves to the published build instead of the local engine.
724
+ - **`26-free-list-invariant_test.mjs`** -- the 1.2.2 audit's cleanliness pins (3 invariant tests + 1 targeted coverage test). Asserts directly -- by inspecting freshly-allocated nodes through the documented `describe()` -> `NODE_PTR` introspection protocol -- that the `ReactiveNode` constructor and the fresh-pool-growth path initialize the ten fields the audit removed from `createNode` to identical values, so the deleted writes were defending against a state the engine cannot produce on a clean free list. The 4th test covers the swallow-on-self-dispose-then-throw branch in `pullComputed` (the path that lifted branch coverage from 98.07% to 98.43%).
701
725
 
702
726
  ```bash
703
727
  npm test
704
728
  ```
705
729
 
706
- ### Tier 2 Memory (allocation-free verification)
730
+ ### Tier 2 -- Memory (allocation-free verification)
707
731
 
708
732
  `npm run test:gc` runs `test/04-zero-gc_test.mjs` with `--expose-gc`:
709
733
 
@@ -718,17 +742,17 @@ If these fail, something allocates in the hot path and we want to find it before
718
742
  npm run test:gc
719
743
  ```
720
744
 
721
- ### Tier 3 Performance (comparative benchmark)
745
+ ### Tier 3 -- Performance (comparative benchmark)
722
746
 
723
- `npm run bench` runs the comparative benchmark (9 scenarios stable fan-in/out + dynamic/layered DAGs) against alien-signals (results.txt). `npm run bench-reactive` runs the cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, and solid (resultsReactive.txt). Output is plain text easy to copy into PRs and changelogs.
747
+ `npm run bench` runs the comparative benchmark (9 scenarios -- stable fan-in/out + dynamic/layered DAGs) against alien-signals (results.txt). `npm run bench-reactive` runs the cross-framework reactivity suite vs alien-signals, preact, vue-reactivity, and solid (resultsReactive.txt). Output is plain text -- easy to copy into PRs and changelogs.
724
748
 
725
749
  ```bash
726
750
  npm run bench
727
751
  ```
728
752
 
729
- ### Tier 4 Torture soaks (crash detection under chaos)
753
+ ### Tier 4 -- Torture soaks (crash detection under chaos)
730
754
 
731
- `bench/torture/` contains three soak harnesses that build large randomised graphs (1,500 / 7,500 / 3,300 nodes) and run mixed fuzz workloads leaf writes, batched writes, computed rewires, effect rewires, nested-batch + untrack reads, and microtask-scheduled async flushes for 510 seconds. These are not perf benchmarks. The numbers they print (ops/sec) reflect random workload composition, not engine throughput; the existing `bench/benchmark.mjs` is the canonical perf harness. What the soaks DO assert, with a non-zero exit code on failure:
755
+ `bench/torture/` contains three soak harnesses that build large randomised graphs (1,500 / 7,500 / 3,300 nodes) and run mixed fuzz workloads -- leaf writes, batched writes, computed rewires, effect rewires, nested-batch + untrack reads, and microtask-scheduled async flushes -- for 5-10 seconds. These are not perf benchmarks. The numbers they print (ops/sec) reflect random workload composition, not engine throughput; the existing `bench/benchmark.mjs` is the canonical perf harness. What the soaks DO assert, with a non-zero exit code on failure:
732
756
 
733
757
  - zero thrown exceptions during the run, and
734
758
  - after teardown, `activeNodes` / `activeLinks` return to the leaf-only baseline (the dispose path is sound under sustained churn).
@@ -760,39 +784,47 @@ npm run verify # test + test:gc + a sanity bench
760
784
 
761
785
  `lite-signal` was built with a strict mandate: **absolute zero garbage collection**. By packing the dependency graph into a flat, pre-allocated memory arena, we eliminate the Scavenger GC pauses that plague 120fps Canvas/WebGL loops.
762
786
 
763
- Through **v1.1.2**, that came with a mathematical trade-off: while memory allocation is $O(1)$, the cursor-based retracking degraded to $O(N)$ linear scans under chaotic, high-fan-in, batched read-after-write the shape of large DOM-style apps with heavy branch switching. **v1.1.4 closed that gap.** A version-stamped $O(1)$ reconciliation plus a `markEpoch` clean-read short-circuit on the pull replaced the cursor degradation; stable read order is unchanged (still $O(1)$, still zero-alloc).
787
+ Through **v1.1.2**, that came with a mathematical trade-off: while memory allocation is $O(1)$, the cursor-based retracking degraded to $O(N)$ linear scans under chaotic, high-fan-in, batched read-after-write -- the shape of large DOM-style apps with heavy branch switching. **v1.1.4 closed that gap.** A version-stamped $O(1)$ reconciliation plus a `markEpoch` clean-read short-circuit on the pull replaced the cursor degradation; stable read order is unchanged (still $O(1)$, still zero-alloc).
764
788
 
765
- **Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix on the **v1.1.2** engine. Those numbers the *pre-rewrite baseline* are below, followed by the 1.1.4 result.
789
+ **Andrii Volynets** (author of the phenomenal [Alien Signals](https://github.com/stackblitz/alien-signals)) generously ran `lite-signal` through his advanced topology matrix on the **v1.1.2** engine. Those numbers -- the *pre-rewrite baseline* -- are below, followed by the 1.1.4 result.
766
790
 
767
791
  #### 1. Stable Topologies (Fan-in / Fan-out / Broadcast)
768
- In stable environments (game engines, particle systems, visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat unchanged through 1.1.4.
792
+ In stable environments (game engines, particle systems, visualizers), `lite-signal` is blisteringly fast and maintains a near-zero allocation profile, keeping frame times perfectly flat -- unchanged through 1.1.4.
769
793
 
770
- #### 2. Dynamic Topologies (Web Apps / Layered DAGs) closed in 1.1.4
771
- *Andrii's v1.1.2 baseline (his host) where the cursor retracking lost:*
794
+ #### 2. Dynamic Topologies (Web Apps / Layered DAGs) -- closed in 1.1.4
795
+ *Andrii's v1.1.2 baseline (his host) -- where the cursor retracking lost:*
772
796
  | Scenario | alien-signals | reflex | lite-signal (1.1.2) |
773
797
  | :--- | :--- | :--- | :--- |
774
798
  | **1000x12 (4 sources, dynamic)** | 184ms | 194ms | 2031ms |
775
799
  | **1000x5 (25 sources, wide/dense)** | 304ms | 303ms | 1746ms |
776
800
  | **64x6 (selective dynamic DAG)** | 181ms | 196ms | 559ms |
777
801
 
778
- *1.2.0 on the local harness (slow 2016 MacBook compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
779
- | Scenario | alien-signals | lite-signal (1.2.0) | result |
802
+ *1.2.2 on the local harness (slow 2016 MacBook, one engine per cold process -- compare within-column, lite vs alien; the approximating scenarios from `bench/benchmark.mjs`):*
803
+ | Scenario | alien-signals | lite-signal (1.2.2) | result |
780
804
  | :--- | :--- | :--- | :--- |
781
- | **LARGE WEB APP** ( 1000x12) | 2711ms | 2666ms | **lite +2%** |
782
- | **WIDE DENSE** ( 1000x5) | 2781ms | 2678ms | **lite +4%** |
783
- | **DYNAMIC DAG** (sqrt-layered, FAN=6) | 9987ms | 9113ms | **lite +9%** |
784
- | **SMALL SELECTIVE** ( 64x6) | 1716ms | 1860ms | alien +8% |
805
+ | **DYNAMIC DAG** (sqrt-layered, FAN=6) | 17558ms | 9821ms | **lite +44%** |
806
+ | **SELECTIVE DAG** (sqrt-layered, set churn) | 9229ms | 4797ms | **lite +48%** |
807
+ | **SMALL SELECTIVE** (~ 64x6) | 2780ms | 1918ms | **lite +31%** |
808
+ | **LARGE WEB APP** (~ 1000x12) | 2671ms | 2846ms | alien +7% |
809
+ | **WIDE DENSE** (~ 1000x5) | 2729ms | 2876ms | alien +5% |
785
810
 
786
- > **Honest note (1.2.0 run):** the dynamic-DAG win held vs 1.1.5 (+9% vs alien at +10% before); LARGE WEB APP and WIDE DENSE narrowed slightly but lite-signal still leads. SMALL SELECTIVE went the other way (+8% in 1.1.5 → −8% in 1.2.0) on this host — that scenario is construction-bound, and the construction path's host noise is visible in the `S: createComputations*` rows of [`resultsReactive.txt`](./resultsReactive.txt). Within this run lite remains the only zero-Δheap library on every stable scenario (see [`results.txt`](./results.txt)).
811
+ > **Honest note (1.2.2 isolated run):** measured one-engine-per-process, lite-signal's
812
+ > wins are on the **allocation-heavy** dynamic shapes (DYNAMIC DAG +44%, SELECTIVE DAG
813
+ > +48%, SMALL SELECTIVE +31%) -- exactly where alien churns the nursery and lite's pool
814
+ > allocates nothing. The cheaper wide-app/dense shapes land within a few percent either
815
+ > way (host noise on this old machine). The prior 1.2.0 table ran all engines in one
816
+ > process, which understated these dynamic-shape gaps (alien borrowed lite's warm heap);
817
+ > the isolated numbers here are the correct comparison. lite remains the only zero-alloc
818
+ > library on every stable scenario (see [`results.txt`](./results.txt)).
787
819
 
788
- The cross-framework reactivity suite agrees independently and was re-run on **1.2.0** (median-of-10): `dyn: large web app` **544ms** (+9% vs alien-signals' 599ms) and `dyn: wide dense` **838ms** (+11% vs 941ms) are wins there too lite-signal is the fastest of five frameworks on both, with preact ~7–19× slower and vue ~16–31× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). The retracking is verified correct by `retracking.difftest.mjs` 20,000 direct + 10,000 batched writes, 0 disagreements against the **published 1.1.5** reference (re-pinned for v1.2).
820
+ The cross-framework reactivity suite agrees independently, re-run on **1.2.2** (median-of-10, isolated): `dyn: large web app` **555ms** (+7% vs alien-signals' 600ms) and `dyn: wide dense` **922ms** (+0.4% vs 926ms) are wins there too -- lite-signal is the fastest of five frameworks on both, with preact ~14-19× slower and vue ~14-31× slower (see [`resultsReactive.txt`](./resultsReactive.txt)). lite also leads alien on **every** `S: updateComputations` row (+5% to +22%) and all five `dyn` rows -- the steady-state hot path. The retracking is verified correct by `retracking.difftest.mjs` -- 20,000 direct + 10,000 batched writes, 0 disagreements against the **published 1.1.5** reference (re-pinned for v1.2).
789
821
 
790
- **The Takeaway:** as of 1.1.4 you no longer have to choose. `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** holds parity-or-ahead of alien-signals on dynamic, high-fan-in web-app topologies. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, −21% on the 1.2.0 run).
822
+ **The Takeaway:** as of 1.1.4 you no longer have to choose. `lite-signal` keeps the zero-GC, flat-arena profile for 120fps Canvas/WebGL **and** wins decisively on the high-churn dynamic and fan-in topologies that dominate live UI -- the shapes where zero allocation pays off most. It runs at parity with alien-signals on cheap stable shapes. The one shape where alien's flatter representation still leads is the 256-deep computed pipeline (DEEP CHAIN, -19% on the 1.2.2 isolated run).
791
823
 
792
824
  ### Roadmap
793
- - **1.1.5** additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering). *Shipped.*
794
- - **1.2.0** the **ownership hybrid**: an owner tree so nested effects/computeds auto-dispose with their parent (closes conformance #209 / #210, matching Solid's `createRoot` ergonomics). Plus three additive features built on the same internal split: pre-batch revert (`batch(() => { a.set(99); a.set(10); })` doesn't re-fire), multi-throw `AggregateError`, and scheduler-thunk caching with an ABA gen guard. *Shipped.*
795
- - **1.3** next engine work after the owner-tree validation. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item.
825
+ - **1.1.5** -- additions in service of `lite-devtools` (node identity/traversability on the introspection walkers, for full auto-discovered graph rendering). *Shipped.*
826
+ - **1.2.0** -- the **ownership hybrid**: an owner tree so nested effects/computeds auto-dispose with their parent (closes conformance #209 / #210, matching Solid's `createRoot` ergonomics). Plus three additive features built on the same internal split: pre-batch revert (`batch(() => { a.set(99); a.set(10); })` doesn't re-fire), multi-throw `AggregateError`, and scheduler-thunk caching with an ABA gen guard. *Shipped.*
827
+ - **1.3** -- next engine work after the owner-tree validation. The pull-mode recursion depth limit (~5,000 chained computeds) is the main outstanding architectural item.
796
828
 
797
829
  > Note: the retracking rewrite that closes the dynamic-topology gap shipped in **1.1.4**, not a future release. The earlier roadmap that listed it under "v1.2" is superseded.
798
830
 
@@ -804,7 +836,7 @@ The cross-framework reactivity suite agrees independently and was re-run on **1.
804
836
 
805
837
  - **A virtual DOM, JSX runtime, or rendering library.** It's the substrate. Plug it under whatever rendering layer you like.
806
838
  - **A general-purpose state container.** No time-travel, no devtools integration, no serialization. (Build those on top if you need them.)
807
- - **A perfect fit for every workload.** On *256-deep computed pipelines* (DEEP CHAIN) `alien-signals` is still a bit faster its flatter representation pays off when the propagation path is long rather than wide. (Through 1.1.2 this caveat also covered chaotic, high-fan-in read order; 1.1.4's retracking rewrite closed that those shapes are now parity-or-ahead.) `lite-signal` is at its best on the fan-in / fan-out / wide-memo and dynamic-churn patterns that dominate animation loops, HUDs, and dashboards.
839
+ - **A perfect fit for every workload.** On *256-deep computed pipelines* (DEEP CHAIN) `alien-signals` is still a bit faster -- its flatter representation pays off when the propagation path is long rather than wide. (Through 1.1.2 this caveat also covered chaotic, high-fan-in read order; 1.1.4's retracking rewrite closed that -- those shapes are now parity-or-ahead.) `lite-signal` is at its best on the fan-in / fan-out / wide-memo and dynamic-churn patterns that dominate animation loops, HUDs, and dashboards.
808
840
  - **A library for the server.** It works in Node, but there's no SSR story. Use it on the client.
809
841
 
810
842
  ---
@@ -814,21 +846,21 @@ The cross-framework reactivity suite agrees independently and was re-run on **1.
814
846
  A growing family of zero-GC, ESM-only, sub-2KB packages built on `lite-signal`. All MIT, all by [@zakkster](https://www.npmjs.com/~zakkster).
815
847
 
816
848
  **State & data**
817
- - [`@zakkster/lite-store`](https://www.npmjs.com/package/@zakkster/lite-store) Fine-grained reactivity for objects & arrays via Proxy. Direct mutation; lazy per-key signals (allocated only on first tracked read); proxy identity preserved across reads; cycle-safe disposal walk.
818
- - [`@zakkster/lite-resource`](https://www.npmjs.com/package/@zakkster/lite-resource) Async state as a signal. `resource(source, fetcher)` exposes data/error/loading/state with race-safe commits (generation guard), AbortSignal, stale-while-revalidate, and optimistic mutate.
819
- - [`@zakkster/lite-form`](https://www.npmjs.com/package/@zakkster/lite-form) Headless reactive forms. One validator per keystroke, hoisted Zod/Yup schema, ~1.5M keystrokes/sec on a 100-field form (8× the hand-written pattern). No DOM, no VDOM, no compiler.
820
- - [`@zakkster/lite-router`](https://www.npmjs.com/package/@zakkster/lite-router) Zero-GC sub-2KB SPA router. URL pathname, query params, and route matches as fine-grained signals components re-render only when their slice of the URL changes.
821
- - [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) Zero-GC reactive persistence. Debounced, coalesced localStorage/sessionStorage sync with cross-tab mirroring a burst of writes becomes one storage write.
822
- - [`@zakkster/lite-channel`](https://www.npmjs.com/package/@zakkster/lite-channel) Cross-tab synchronization over BroadcastChannel. Multiplexed per-key sync, last-writer-wins (Lamport clock + tab-id tiebreak), reactive presence (peers, status, leader election as signals).
849
+ - [`@zakkster/lite-store`](https://www.npmjs.com/package/@zakkster/lite-store) -- Fine-grained reactivity for objects & arrays via Proxy. Direct mutation; lazy per-key signals (allocated only on first tracked read); proxy identity preserved across reads; cycle-safe disposal walk.
850
+ - [`@zakkster/lite-resource`](https://www.npmjs.com/package/@zakkster/lite-resource) -- Async state as a signal. `resource(source, fetcher)` exposes data/error/loading/state with race-safe commits (generation guard), AbortSignal, stale-while-revalidate, and optimistic mutate.
851
+ - [`@zakkster/lite-form`](https://www.npmjs.com/package/@zakkster/lite-form) -- Headless reactive forms. One validator per keystroke, hoisted Zod/Yup schema, ~1.5M keystrokes/sec on a 100-field form (8× the hand-written pattern). No DOM, no VDOM, no compiler.
852
+ - [`@zakkster/lite-router`](https://www.npmjs.com/package/@zakkster/lite-router) -- Zero-GC sub-2KB SPA router. URL pathname, query params, and route matches as fine-grained signals -- components re-render only when their slice of the URL changes.
853
+ - [`@zakkster/lite-persist`](https://www.npmjs.com/package/@zakkster/lite-persist) -- Zero-GC reactive persistence. Debounced, coalesced localStorage/sessionStorage sync with cross-tab mirroring -- a burst of writes becomes one storage write.
854
+ - [`@zakkster/lite-channel`](https://www.npmjs.com/package/@zakkster/lite-channel) -- Cross-tab synchronization over BroadcastChannel. Multiplexed per-key sync, last-writer-wins (Lamport clock + tab-id tiebreak), reactive presence (peers, status, leader election as signals).
823
855
 
824
856
  **Rendering (DOM / Canvas)**
825
- - [`@zakkster/lite-element`](https://www.npmjs.com/package/@zakkster/lite-element) Zero-GC reactive Custom Elements, no virtual DOM or templating. Component state survives synchronous reparents (sort, drag-and-drop, `insertBefore`) the moves that destroy React, Vue, and Lit components.
826
- - [`@zakkster/lite-virtual`](https://www.npmjs.com/package/@zakkster/lite-virtual) Thrash-free list/grid windowing. Integer-gated reactive indices + `Object.is` cutoff means scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed and variable heights, 2-D grid.
827
- - [`@zakkster/lite-scene`](https://www.npmjs.com/package/@zakkster/lite-scene) Reactive retained-mode Canvas2D scene graph. Nodes (group/rect/circle/line/text/image/path) take signals as props; the renderer redraws only what changed. Hit testing, clip groups, pointerEvents, nested transforms.
857
+ - [`@zakkster/lite-element`](https://www.npmjs.com/package/@zakkster/lite-element) -- Zero-GC reactive Custom Elements, no virtual DOM or templating. Component state survives synchronous reparents (sort, drag-and-drop, `insertBefore`) -- the moves that destroy React, Vue, and Lit components.
858
+ - [`@zakkster/lite-virtual`](https://www.npmjs.com/package/@zakkster/lite-virtual) -- Thrash-free list/grid windowing. Integer-gated reactive indices + `Object.is` cutoff means scrolling within a row writes zero bytes to the DOM. ~3.6M sub-row scrolls/sec, bounded pool regardless of count, fixed and variable heights, 2-D grid.
859
+ - [`@zakkster/lite-scene`](https://www.npmjs.com/package/@zakkster/lite-scene) -- Reactive retained-mode Canvas2D scene graph. Nodes (group/rect/circle/line/text/image/path) take signals as props; the renderer redraws only what changed. Hit testing, clip groups, pointerEvents, nested transforms.
828
860
 
829
861
  **Time & scheduling**
830
- - [`@zakkster/lite-raf`](https://www.npmjs.com/package/@zakkster/lite-raf) Zero-GC frame-rate scheduling. One `requestAnimationFrame` loop; frameTime/frameDelta/frameCount as signals; `rafEffect()` reactive effects that run at most once per frame. Built for canvas/WebGL render loops and games.
831
- - [`@zakkster/lite-time`](https://www.npmjs.com/package/@zakkster/lite-time) Reactive, drift-corrected wall-clock cadence. One 1s heartbeat; zero-GC relativeTime/countdown/every; deterministic for tests and SSR. Not a date library `Intl` does formatting, you bring the dates.
862
+ - [`@zakkster/lite-raf`](https://www.npmjs.com/package/@zakkster/lite-raf) -- Zero-GC frame-rate scheduling. One `requestAnimationFrame` loop; frameTime/frameDelta/frameCount as signals; `rafEffect()` -- reactive effects that run at most once per frame. Built for canvas/WebGL render loops and games.
863
+ - [`@zakkster/lite-time`](https://www.npmjs.com/package/@zakkster/lite-time) -- Reactive, drift-corrected wall-clock cadence. One 1s heartbeat; zero-GC relativeTime/countdown/every; deterministic for tests and SSR. Not a date library -- `Intl` does formatting, you bring the dates.
832
864
 
833
865
  ---
834
866
 
@@ -850,7 +882,7 @@ Pure ES2020 + `Object.is` + `Int32 | 0`. Runs anywhere that runs modern JavaScri
850
882
  | Cloudflare Workers | ✓ |
851
883
  | Deno | ✓ |
852
884
 
853
- ESM-only. No CommonJS build modern bundlers handle this; legacy consumers can use a wrapper.
885
+ ESM-only. No CommonJS build -- modern bundlers handle this; legacy consumers can use a wrapper.
854
886
 
855
887
  </details>
856
888
 
@@ -947,7 +979,7 @@ The exact post-1.2 pass count is being re-run against the upstream suite;
947
979
  per-test results and the runner adapter live in `/conformance/`.
948
980
 
949
981
  **177 of 178 tests pass **, placing lite-signal **in the second place of sixteen**
950
- evaluated libraries just behind alien-signals (177).
982
+ evaluated libraries -- just behind alien-signals (177).
951
983
 
952
984
  We publish both passing and failing tests, because honesty about behavior is
953
985
  more useful to library users than a green checkmark.
@@ -959,17 +991,17 @@ more useful to library users than a green checkmark.
959
991
  - **Cycle detection** in effects (matches preact, reatom, svelte, solid).
960
992
  Many libraries silently iterate to a 200-step bail; lite-signal throws so
961
993
  the bug surfaces at development time.
962
- - **`Object.is` equality** throughout, including NaN matches Vue,
994
+ - **`Object.is` equality** throughout, including NaN -- matches Vue,
963
995
  Angular, Reatom, the TC39 polyfill, and tansu. The `===` camp returns
964
996
  incorrect results on NaN flows.
965
- - **Single-pass propagation** through computed chains on inner writes
997
+ - **Single-pass propagation** through computed chains on inner writes --
966
998
  matches alien-signals and Vue; faster than preact, solid, reatom, mobx,
967
999
  and most others by one re-evaluation per write.
968
- - **Auto-unsubscribe** on first-run effect throws matches preact, reatom,
1000
+ - **Auto-unsubscribe** on first-run effect throws -- matches preact, reatom,
969
1001
  solid. Half the field leaks the subscription.
970
1002
  - **Observer-lifecycle introspection** (`hasObservers` / `observeObservers`,
971
- 1.1.4): the 01 and 10 observer transitions are first-class, zero-cost-when-
972
- unused hooks the basis for auto-pausing a clock or RAF loop only while a
1003
+ 1.1.4): the 0->1 and 1->0 observer transitions are first-class, zero-cost-when-
1004
+ unused hooks -- the basis for auto-pausing a clock or RAF loop only while a
973
1005
  derived value is watched. Few signal libraries expose this.
974
1006
 
975
1007
  ### What lite-signal does NOT do yet
@@ -1025,10 +1057,10 @@ Effects already on the call stack will finish their current invocation. Future s
1025
1057
  `signal.subscribe(callback)` is the integration surface. For React, wire it into `useSyncExternalStore`. For Vue, expose `signal()` as a getter. For Svelte, return `{ subscribe }` matching the store contract.
1026
1058
 
1027
1059
  **Can I read a computed without subscribing?**
1028
- Yes `computed.peek()` triggers re-evaluation if needed but doesn't add a dependency edge. `untrack(() => c())` is equivalent but slightly more expensive (it toggles a global flag).
1060
+ Yes -- `computed.peek()` triggers re-evaluation if needed but doesn't add a dependency edge. `untrack(() => c())` is equivalent but slightly more expensive (it toggles a global flag).
1029
1061
 
1030
1062
  **What happens if I `set()` from inside an effect's cleanup?**
1031
- The cleanup runs *before* the next computeFn body, so the set's notification arrives normally and propagates after the current flush pass. No special-case behavior the queue handles it.
1063
+ The cleanup runs *before* the next computeFn body, so the set's notification arrives normally and propagates after the current flush pass. No special-case behavior -- the queue handles it.
1032
1064
 
1033
1065
  **Is the dep order stable across re-runs?**
1034
1066
  Yes, if your computeFn reads its deps in the same order each invocation. The `currentDep` cursor walks the existing dep list and tries to match; matches reuse the existing link (zero alloc), mismatches insert/remove. Stable order = stable performance.
@@ -1051,8 +1083,8 @@ npm run verify # test + test:gc + sanity bench; gate for publish
1051
1083
 
1052
1084
  ## License
1053
1085
 
1054
- MIT © Zahary Shinikchiev
1086
+ MIT (c) Zahary Shinikchiev
1055
1087
 
1056
1088
  ---
1057
1089
 
1058
- > Part of the **@zakkster** zero-GC stack: [`lite-ecs`](https://www.npmjs.com/package/@zakkster/lite-ecs) · [`lite-ease`](https://www.npmjs.com/package/@zakkster/lite-ease) · [`lite-pointer-tracker`](https://www.npmjs.com/package/@zakkster/lite-pointer-tracker) · [`lite-bmfont`](https://www.npmjs.com/package/@zakkster/lite-bmfont) · [`lite-color`](https://www.npmjs.com/package/@zakkster/lite-color)
1090
+ > Part of the **@zakkster** zero-GC stack: [`lite-ecs`](https://www.npmjs.com/package/@zakkster/lite-ecs) * [`lite-ease`](https://www.npmjs.com/package/@zakkster/lite-ease) * [`lite-pointer-tracker`](https://www.npmjs.com/package/@zakkster/lite-pointer-tracker) * [`lite-bmfont`](https://www.npmjs.com/package/@zakkster/lite-bmfont) * [`lite-color`](https://www.npmjs.com/package/@zakkster/lite-color)