@zakkster/lite-signal 1.2.1 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,63 +4,207 @@ All notable changes to `@zakkster/lite-signal` are documented here.
4
4
  Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
  This project follows [Semantic Versioning](https://semver.org/).
6
6
 
7
- ## [1.2.1] 2026-06-12
7
+ ## [1.2.2] -- 2026-06-16
8
+
9
+ A code-deletion ship: a `createNode` audit removes ten redundant field-writes
10
+ that defended against a state the engine cannot produce on a clean free-list.
11
+ No public surface change, no semantic change, no new tests required for new
12
+ behavior (because there is none) -- only an added invariant suite that pins
13
+ the cleanliness claim the audit relies on. Drop-in over 1.2.1.
14
+
15
+ **Version lineage note.** This is the engine previously labeled `1.2.3` in dev.
16
+ Renumbered to `1.2.2` to keep semver tidy: the deletion is small, isolated, and
17
+ intentionally non-behavioral; bumping the patch rather than the minor reflects
18
+ that. The upcoming `1.3.0` (lazy/chunked pool with `prealloc:"eager"` default
19
+ and intrusive mark stack) carries the next minor bump.
20
+
21
+ ### Changed -- clean free-list invariant audit in `createNode`
22
+
23
+ Two clusters of redundant writes removed. Both rely on a single invariant:
24
+ **every node leaving the pool has the listed fields at their fresh-construct
25
+ default values** because `disposeNode` and `runCleanup` already null them and
26
+ the `ReactiveNode` constructor initializes them to the same values on
27
+ fresh-pool-growth allocation.
28
+
29
+ - **Seven graph/batch fields** no longer rewritten on every allocate:
30
+ `headDep`, `tailDep`, `headSub`, `tailSub`, `revertEpoch`, `preBatchValue`,
31
+ `preBatchVersion`. Paired-checked: `disposeNode` clears all seven on the
32
+ recycle path; the `ReactiveNode` constructor inits all seven to the same
33
+ values on the fresh-allocation path used by pool growth.
34
+ - **Three owner-tree fields** in the non-adoption path no longer rewritten:
35
+ the `firstOwned = null`, the adoption-path `prevOwned = null`, and the
36
+ else-branch `owner = null`. The `disposeNode` direct path nulls
37
+ `owner / prevOwned / nextOwned`; the `runCleanup` cascade path nulls them
38
+ on every disposed child and sets the parent's `firstOwned = null` at exit.
39
+ The constructor inits all four owner-tree fields to `null` on
40
+ fresh-allocation.
41
+
42
+ What `createNode` still writes are the *lifetime* writes for the new resident:
43
+ `value`, `flags`, `id`, and the three fields `disposeNode` does NOT touch
44
+ (`version`, `evalVersion`, `markEpoch` -- propagation state that must reset for
45
+ the new lifetime), plus the conditional owner-adoption splice
46
+ (`owner`, `nextOwned`, parent chain link).
47
+
48
+ ### Added -- `test/10-free-list-invariant_test.mjs`
49
+
50
+ A three-test invariant suite that asserts the audit's claim by inspecting
51
+ freshly-allocated nodes' underlying field state (via the documented
52
+ `describe()` -> `NODE_PTR` surface, the same protocol devtools uses). Tests:
53
+
54
+ - Recycled slot reports null `headDep/tailDep/headSub/tailSub` and zero
55
+ `revertEpoch/preBatchVersion`, undefined `preBatchValue`, after disposing
56
+ a real signal->computed->effect graph.
57
+ - Recycled slot reports null `owner/prevOwned/nextOwned/firstOwned` after an
58
+ owner-cascade tears down a nested observer tree.
59
+ - Mixed-pattern churn (simple, batched-write, error-flush) leaves no dirty
60
+ state on the free list across 32 follow-up allocations.
61
+
62
+ If any future change reintroduces a write to a clean-state field on the
63
+ dispose path or removes a write that turns out NOT to be redundant, this
64
+ suite catches it.
65
+
66
+ ### Added -- `test/11-devtools-contract_test.mjs`
67
+
68
+ A 12-test smoke probe of the introspection surface that lite-devtools 1.1 /
69
+ lite-studio 1.1 consume. Verifies handle resolution + walkers, owner-tree
70
+ walkers, the `onGraphMutation` push hook, the `observeObservers` ghost
71
+ contract (zero added nodes under heavy introspection), and pins the
72
+ authoritative 1.2.x `stats()` shape (exactly 8 keys: `signals`, `computeds`,
73
+ `effects`, `activeNodes`, `activeLinks`, `pooledLinks`, `nodePoolCapacity`,
74
+ `linkPoolCapacity`). Also pins the absence of `totalAllocations` /
75
+ `totalDisposals` / `poolGrowths` on 1.2.x -- those are reserved for 1.4.0 and
76
+ the test fails if they appear early.
77
+
78
+ ### Verified
79
+
80
+ - **408 tests total: 398 pass, 10 skip, 0 fail** across the 23 active
81
+ suites (01-09 baseline + 11-23 introspection/ownership/perf-pin + my new
82
+ 25 devtools-real-boot + 26 free-list-invariant). The 10 skips are 9
83
+ signalBox-staged-for-1.5.0 in `24-signalbox` and 1 architecturally-N/A
84
+ SSR case in `17-reactivity`.
85
+ - **Coverage on `Signal.js`: 100% statements, 98.43% branches, 100%
86
+ functions, 100% lines.** `Watch.js`: 100% across all four. (c8@11,
87
+ Node 22.) Better branch coverage than the 1.2.1 baseline documented in
88
+ `llms.txt` (was 98.07%); the engine path that closed the gap was a
89
+ targeted test of the swallow-on-self-dispose-then-throw branch in
90
+ `pullComputed`. The remaining ~5 unreached branches are exactly the
91
+ unreachable-by-construction cases already catalogued as `/* c8 ignore */`
92
+ candidates in `COVERAGE-NOTES.md` (cursor fast path, batch wraparound
93
+ sentinel, etc.).
94
+ - **Devtools 1.1.0 + Studio 1.1.0 contract: green.** Test
95
+ `25-devtools-real-boot` boots the actual `Devtools.js` against the
96
+ 1.2.2 engine and exercises all 19 exports + the 10 symbols Studio
97
+ imports from Devtools. The ghost contract holds (heavy introspection
98
+ adds zero nodes). One real test-rig finding surfaced during this work:
99
+ if the engine is developed in a repo whose own `package.json` declares
100
+ `name: "@zakkster/lite-signal"`, importing the package by name from the
101
+ project root can resolve to a different module instance than imports
102
+ from inside a sibling `node_modules/@zakkster/lite-devtools/`,
103
+ fragmenting the `defaultRegistry`. This is purely a dev-loop / test-rig
104
+ matter (not an engine, devtools, or studio bug) -- in a real consumer
105
+ installation both packages live in `node_modules` and resolve once.
106
+
107
+ ### Not changed
108
+
109
+ - Public API: no additions, no removals, no signature changes.
110
+ - Type surface: `Signal.d.ts` unchanged.
111
+ - Behavior: every existing test case in 01-09 passes unmodified.
112
+
113
+ ### Honest notes
114
+
115
+ - **Perf**: no microbench numbers cited. Ten removed field writes per
116
+ `createNode` is a real saving on creation-heavy workloads, but creation
117
+ cost is dominated by the owner-adoption splice and the optional mutation
118
+ hook, not by these writes. Any "X% faster on creation" claim would need
119
+ to come from a per-run lite-vs-alien measurement on the project's
120
+ standard benchmark harness; this ship does not include one because the
121
+ audit is justified by correctness (clean invariant beats defensive
122
+ writes) rather than measured speedup.
123
+ - **Differential testing**: the retracking difftest harness expects two
124
+ engine builds (REF = prior shipped, CANDIDATE = under review). This ship
125
+ was validated against itself (30,000 writes, 0 disagreements), which
126
+ proves determinism but not 1.2.2-vs-1.2.1 behavioral equivalence --
127
+ equivalence is argued instead from the audit being a code-deletion of
128
+ writes provably-redundant under the existing pre/post-invariants, and
129
+ from the full 01-09 suite passing unmodified.
130
+
131
+ ### Benchmarks
132
+
133
+ The audit ship does not move the curve on any benchmark -- by design;
134
+ the steady-state hot paths are byte-identical to 1.2.1. The bench
135
+ results were re-measured against 1.2.2 on the project's reference host
136
+ (2016 MacBook Pro, Intel, Node 22) and are published as the baseline
137
+ for the next version. See [`bench/results.txt`](./bench/results.txt)
138
+ (in-house anti-DCE harness, median-of-3 cold-process runs from
139
+ `bench/run-all.sh`), [`bench/resultsReactive.txt`](./bench/resultsReactive.txt)
140
+ (community reactive suite, 10 raw runs), and the third-party
141
+ [js-reactivity-benchmark](https://github.com/volynetstyle/js-reactivity-benchmark)
142
+ results (16 frameworks). Position on the third-party suite: **#4 of 16
143
+ by geomean** (2.05× vs alien-signals 1.00×), behind alien-signals,
144
+ reflex, and @reactively; ahead of Preact Signals (2.09×), uSignal,
145
+ $mol_wire, and 9 others. Outright wins on `manyEffectsFromOneSource`
146
+ and `manySourcesIntoOneComputedEffectWithDirect`; top-3 finishes on
147
+ 18 of 47 tests. The version dependencies used for these numbers are
148
+ pinned in [`bench/package.json`](./bench/package.json) at
149
+ `lite-signal-bench@2.2.0`.
150
+
151
+ ## [1.2.1] -- 2026-06-12
8
152
 
9
153
  A correctness-and-pauses patch in two halves: the pool allocator stops paying
10
154
  for growth in unbounded bursts, and the introspection surface stops lying about
11
155
  handles the 1.2.0 owner tree disposed behind your back. Plus the graph-mutation
12
- hook the keystone that lets lite-devtools 1.1 / lite-studio 1.1 go push-based.
156
+ hook -- the keystone that lets lite-devtools 1.1 / lite-studio 1.1 go push-based.
13
157
  Drop-in over 1.2.0: 404-test suite green, 177/178 on
14
158
  johnsoncodehk/reactive-framework-test-suite (same single open cell, Inner
15
159
  Write #179), hot-path regression gate flat on two hosts.
16
160
 
17
- ### Fixed bounded pool growth (no more construction bursts)
161
+ ### Fixed -- bounded pool growth (no more construction bursts)
18
162
  - Under `onCapacityExceeded: "grow"`, exhausting a pool used to double it by
19
- synchronously constructing `currentCapacity` fresh nodes/links at a
163
+ synchronously constructing `currentCapacity` fresh nodes/links -- at a
20
164
  524,288-node pool that is a quarter-million 25-field allocations in one
21
165
  pause, in whatever frame triggered it. Growth is now incremental: **one**
22
166
  node/link constructed per free-list miss, pushed into the pool, recycled
23
167
  forever after. The capacity **ledger** still doubles, so `stats()`
24
168
  (`nodePoolCapacity` / `linkPoolCapacity` / `pooledLinks`), the
25
169
  `maxLinks × 16` ceiling, and every `CapacityError` are bit-identical to
26
- 1.2.0 only the construction schedule changed. Locked by the existing
170
+ 1.2.0 -- only the construction schedule changed. Locked by the existing
27
171
  `test/03-pool` capacity/ceiling/recycle contracts.
28
172
  - Benchmark effect (volynetstyle/js-reactivity-benchmark, same host as the
29
- 1.2.0 baseline run): creation group 489 423 ms (13.5%), with the burst
30
- cases roughly halved (`1to2` 112 58, `1to8` 113 55, `1to4` 81 54).
173
+ 1.2.0 baseline run): creation group 489 -> 423 ms (-13.5%), with the burst
174
+ cases roughly halved (`1to2` 112 -> 58, `1to8` 113 -> 55, `1to4` 81 -> 54).
31
175
  Honest redistribution note: rows that previously *fit inside the doubling
32
- overshoot* (`createDataSignals` 12.8 71.9, `1to1` 17.8 43.2) now pay
33
- their construction inside the measured window 1.2.0's overshoot was an
176
+ overshoot* (`createDataSignals` 12.8 -> 71.9, `1to1` 17.8 -> 43.2) now pay
177
+ their construction inside the measured window -- 1.2.0's overshoot was an
34
178
  accidental prefetch, and the same mechanism produced the pathological
35
179
  bursts. Bounded pauses are the right trade for real applications; the
36
180
  group total still improves.
37
181
  - Steady-state hot paths are untouched (update / dynamic-retracking /
38
182
  effect-recycle measured flat on both benchmark hosts).
39
183
 
40
- ### Fixed effect queues / mark stack stay PACKED
184
+ ### Fixed -- effect queues / mark stack stay PACKED
41
185
  - Pool growth used to pre-size `effectQueueA/B` and the mark stack with
42
- `arr.length = newCap` which permanently converts a PACKED V8 array to
186
+ `arr.length = newCap` -- which permanently converts a PACKED V8 array to
43
187
  HOLEY elements, a silent tax on every subsequent flush read. The queues now
44
188
  grow by sequential append (packed-preserving, auto-amortised) and
45
189
  `destroy()` truncates instead of null-filling to capacity.
46
190
 
47
- ### Fixed `destroy()` iterates physical pools
191
+ ### Fixed -- `destroy()` iterates physical pools
48
192
  - `destroy()` walked `currentNodesCapacity` slots by index; with incremental
49
193
  growth (and any future lazy population) the ledger can exceed the physical
50
194
  pool. It now walks `nodePool.length` / `linkPool.length` and is safe on an
51
195
  empty pool.
52
196
 
53
- ### Fixed stale-handle introspection (the owner-tree follow-up)
197
+ ### Fixed -- stale-handle introspection (the owner-tree follow-up)
54
198
  - 1.2.0's owner tree made the engine recycle pool slots **autonomously**: an
55
199
  owner re-run cascade-disposes its owned observers, so holding a stale handle
56
200
  stopped being a user error and became a routine occurrence. The
57
- introspection surface `nodeId` / `describe` / `forEachObserver` /
58
- `forEachSource` / `hasObservers` / `observeObservers` still resolved
201
+ introspection surface -- `nodeId` / `describe` / `forEachObserver` /
202
+ `forEachSource` / `hasObservers` / `observeObservers` -- still resolved
59
203
  `NODE_PTR` without a generation check and would happily report the
60
204
  **recycled slot's new resident** (wrong id, wrong value, wrong edges)
61
205
  through an old handle. All six entry points now resolve through a
62
206
  gen-guarded `liveNode()` and report stale handles as `undefined` (or throw
63
- the existing `TypeError`, for `observeObservers`) the same ABA discipline
207
+ the existing `TypeError`, for `observeObservers`) -- the same ABA discipline
64
208
  `read()` / `set()` / `dispose()` already had.
65
209
  - `describe()` descriptors are now **gen-stamped** alongside the node
66
210
  reference, so the documented "descriptors are re-walkable handles" contract
@@ -69,44 +213,44 @@ Write #179), hot-path regression gate flat on two hosts.
69
213
  "forEach* descriptors carry id and are re-walkable" test.
70
214
  - **Effect dispose handles are now first-class introspection handles.** On
71
215
  every prior version, `effect()` returned a bare closure carrying neither
72
- `NODE_PTR` nor `NODE_GEN` so `describe` / `nodeId` / `forEachSource`
216
+ `NODE_PTR` nor `NODE_GEN` -- so `describe` / `nodeId` / `forEachSource`
73
217
  returned `undefined`/empty for a **live** effect handle, and
74
218
  `observeObservers(effectHandle)` threw. The dispose function is now stamped
75
219
  with the same symbol pair as signal/computed handles (`NODE_GEN` mirrors the
76
220
  disposer's own `birthGen`, so introspection validity agrees exactly with its
77
221
  stale-guard). After explicit dispose, slot recycle, or owner-cascade the
78
222
  handle correctly reads stale. Measured cost: two property stores per effect
79
- creation (~50 ns on a create/dispose churn microbench) symmetric with
223
+ creation (~50 ns on a create/dispose churn microbench) -- symmetric with
80
224
  what signal/computed handles already pay, create-path only. Found by the
81
225
  lite-devtools 1.1 cross-probe campaign (`track(effectHandle)` threw).
82
226
  - `peek()` had the same hole: `sharedSignalPeek` / `sharedComputedPeek`
83
227
  resolved the slot ungated, so a stale handle's `peek()` returned the new
84
228
  resident's value. Both now gen-check first and return `undefined` when
85
- stale closing the last unguarded entry point in the probe-c1 ABA family.
86
- Measured cost: 4M peeks 7.1 7.4 ms (0.08 ns/op).
229
+ stale -- closing the last unguarded entry point in the probe-c1 ABA family.
230
+ Measured cost: 4M peeks 7.1 -> 7.4 ms (~0.08 ns/op).
87
231
 
88
- ### Added `onGraphMutation(fn)`: the graph-mutation hook
232
+ ### Added -- `onGraphMutation(fn)`: the graph-mutation hook
89
233
  - Registry-level (and default-registry module export) debug hook, the
90
234
  connection point for push-based tooling. Single nullable listener; every
91
235
  fire point is one `if (mutationHook !== null)` branch and the dispatch is
92
- allocation-free `(opcode, intA, intB)`:
93
- - `1` node create `(id, flags)`, end of `createNode`
94
- - `2` node dispose `(id, flags)`, top of `disposeNode` (cascades included)
95
- - `3` link add `(source.id, target.id)`
96
- - `4` link remove `(source.id, target.id)`
97
- - `5` recompute `(id, 0)`, before an effect re-run / computed re-eval
236
+ allocation-free -- `(opcode, intA, intB)`:
237
+ - `1` node create -- `(id, flags)`, end of `createNode`
238
+ - `2` node dispose -- `(id, flags)`, top of `disposeNode` (cascades included)
239
+ - `3` link add -- `(source.id, target.id)`
240
+ - `4` link remove -- `(source.id, target.id)`
241
+ - `5` recompute -- `(id, 0)`, before an effect re-run / computed re-eval
98
242
  - Cost: **zero when unregistered** (hot-path gate flat); registered, the
99
243
  worst case measured is +29% on a dynamic-retracking torture loop (11.4M
100
- events for 400K writes) a debug-mode tax paid only while a consumer is
244
+ events for 400K writes) -- a debug-mode tax paid only while a consumer is
101
245
  attached, proportional to event volume.
102
- - **Listener contract: observe only never throw, never mutate the graph.**
246
+ - **Listener contract: observe only -- never throw, never mutate the graph.**
103
247
  The hook fires synchronously inside mutation points; lite-devtools 1.1
104
248
  multiplexes all of its consumers behind one registration, isolates their
105
249
  exceptions, and unregisters when the last consumer stops (returning the
106
250
  engine to the zero-cost state). `onGraphMutation` returns an unsubscribe
107
251
  that restores the previously registered listener.
108
252
 
109
- ### Added owner-tree introspection: `forEachOwned` / `ownerOf`
253
+ ### Added -- owner-tree introspection: `forEachOwned` / `ownerOf`
110
254
  - The 1.2.0 owner tree finally gets a (read-only, gen-guarded) window:
111
255
  `forEachOwned(handle, fn)` iterates a node's owned children as standard
112
256
  re-walkable descriptors; `ownerOf(handle)` returns the owner's descriptor
@@ -119,18 +263,18 @@ Write #179), hot-path regression gate flat on two hosts.
119
263
  ### Compatibility
120
264
  - No behavioural change for live handles; stale handles now read as stale
121
265
  everywhere instead of as the slot's next tenant. Allocation strategy is
122
- unobservable through the public API. Tooling floor: lite-devtools 1.1.0
266
+ unobservable through the public API. Tooling floor: lite-devtools >= 1.1.0
123
267
  detects `onGraphMutation` / `forEachOwned` at load and degrades to its
124
268
  1.0 polling behaviour on older engines.
125
269
 
126
- ## [1.2.0] 2026-06-11
270
+ ## [1.2.0] -- 2026-06-11
127
271
 
128
272
  A structural refactor that internally splits the engine into three named layers
129
273
  (graph topology / ownership-lifecycle / propagation-execution) with a strict
130
274
  dependency direction, plus a small set of additive features built on top of
131
- that split. No behavioural changes for existing code drop-in over 1.1.5.
275
+ that split. No behavioural changes for existing code -- drop-in over 1.1.5.
132
276
 
133
- ### Added auto-disposal of nested observers (owner tree)
277
+ ### Added -- auto-disposal of nested observers (owner tree)
134
278
  - An effect or computed that creates **observers** (nested `effect`/`computed`)
135
279
  now owns them via an internal owner tree. When the owner re-runs or is
136
280
  disposed, all owned observers are cascade-disposed before the new run starts.
@@ -144,7 +288,7 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
144
288
  `test/15-owner-lazy-alloc.test.mjs` (the lite-store cross-wire shape) and
145
289
  the new `test/19-v12-additions.test.mjs`.
146
290
 
147
- ### Added pre-batch revert (the "set X, set X back" optimisation)
291
+ ### Added -- pre-batch revert (the "set X, set X back" optimisation)
148
292
  - Inside a `batch(...)`, if a signal is set and then set back to its
149
293
  pre-batch value (under the signal's own `equals`), the version bump is
150
294
  reverted and downstream effects/computeds do **not** fire. Eliminates a
@@ -152,15 +296,15 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
152
296
  in form state and undo/redo. Verified end-to-end (signal, computed, effect)
153
297
  in `test/19-v12-additions.test.mjs`.
154
298
 
155
- ### Added multi-effect throws aggregate to `AggregateError`
299
+ ### Added -- multi-effect throws aggregate to `AggregateError`
156
300
  - When two or more effects throw in the **same flush pass**, the engine
157
301
  collects all errors and rethrows a native `AggregateError` at the
158
302
  triggering `set()` / batch boundary. A single thrown error is rethrown
159
303
  unwrapped (no change). Effects that don't throw still run. Cycle detection
160
- unchanged a flush exceeding `maxFlushPasses` (default 100) throws an
304
+ unchanged -- a flush exceeding `maxFlushPasses` (default 100) throws an
161
305
  `Error` prefixed `"CycleError:"`.
162
306
 
163
- ### Added scheduler thunk caching with gen-bound ABA guard
307
+ ### Added -- scheduler thunk caching with gen-bound ABA guard
164
308
  - `effect(fn, { scheduler })` now caches the scheduler thunk on the node
165
309
  itself (`node.schedulerThunk`) so repeated re-schedules reuse the same
166
310
  closure (no allocation per re-schedule). The thunk holds a generation snapshot
@@ -168,14 +312,14 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
168
312
  generation, so a stale thunk fired by an async scheduler against a recycled
169
313
  pool slot is a guaranteed no-op (ABA safe).
170
314
 
171
- ### Changed internal refactor, no behavioural difference
315
+ ### Changed -- internal refactor, no behavioural difference
172
316
  - The engine is reorganised into three explicit layers with documented
173
317
  invariants (see the file header in `Signal.js`):
174
- - **L1 Graph topology** `allocateLink` / `freeLink` / `severTail`. Pure
318
+ - **L1 Graph topology** -- `allocateLink` / `freeLink` / `severTail`. Pure
175
319
  edge mechanics. Never touches `owner` / `firstOwned`.
176
- - **L2 Ownership/lifecycle** `createNode` / `disposeNode` / `runCleanup`.
320
+ - **L2 Ownership/lifecycle** -- `createNode` / `disposeNode` / `runCleanup`.
177
321
  Owns the owner tree and user cleanup. Never touches the tracking cursor.
178
- - **L3 Propagation/execution** `markDownstream` (cursor-free), and the
322
+ - **L3 Propagation/execution** -- `markDownstream` (cursor-free), and the
179
323
  orchestrators `executeEffect` / `pullComputed` that drive the cursor
180
324
  (L1) and call `runCleanup` (L2) before a re-run.
181
325
  - `currentObserver` and `currentOwner` are now distinct pointers. Today they
@@ -184,31 +328,31 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
184
328
  - **Shared `peek` (perf).** `signal()` and `computed()` now reuse a single
185
329
  `peek` function per registry instead of allocating a fresh closure per
186
330
  primitive. Equivalent across registries (each registry has its own pair).
187
- ~1014% faster signal/computed creation on the `S:create*` micros, no
331
+ ~10-14% faster signal/computed creation on the `S:create*` micros, no
188
332
  hot-path or behavioural change. Verified by 5 dedicated tests + the full
189
333
  309-strong existing suite + 30,000-write differential retracking fuzz vs
190
334
  the published 1.1.5.
191
335
 
192
- ### Changed port-forward of the 1.1.3/1.1.4 perf fixes
193
- - `pullComputed` retains the **`markEpoch` clean short-circuit** re-reading
336
+ ### Changed -- port-forward of the 1.1.3/1.1.4 perf fixes
337
+ - `pullComputed` retains the **`markEpoch` clean short-circuit** -- re-reading
194
338
  a computed after an unrelated source changed is O(1).
195
- - `allocateLink` retains the **O(1) `tailSub` dedup** divergent re-tracking
196
- remains O(N), not O(N²). The same documented edge note applies: a nested
339
+ - `allocateLink` retains the **O(1) `tailSub` dedup** -- divergent re-tracking
340
+ remains O(N), not O(N^2). The same documented edge note applies: a nested
197
341
  re-read of the same source after an intervening observer can retain one
198
342
  duplicate link per intervening edge, bounded by the loop count and
199
343
  dispose-reclaimed.
200
344
 
201
- ### Fixed conformance regressions surfaced during release prep
345
+ ### Fixed -- conformance regressions surfaced during release prep
202
346
  - **#141 (`dispose during execution then continue: no re-run`)**: an effect that
203
347
  called its own dispose handle mid-run and then continued to read another
204
348
  signal would corrupt the link-list bookkeeping in `severTail` (latent crash
205
- present in 1.1.5 too the v1.2 owner tree exercised the path more
349
+ present in 1.1.5 too -- the v1.2 owner tree exercised the path more
206
350
  aggressively and made it visible). Fixed by nulling the tracking cursor in
207
351
  `disposeNode` when the disposed node is the active observer, plus a
208
352
  gen-snapshot guard in `executeEffect` / `pullComputed` so a post-body
209
353
  `severTail` on a recycled slot is skipped.
210
354
  - **#238 / #241 / #243 (cleanup ordering)**: nested effect cleanups must fire
211
- inside-out on owner-tree disposal grandchild before child before outer.
355
+ inside-out on owner-tree disposal -- grandchild before child before outer.
212
356
  The previous `runCleanup` ran the node's OWN cleanup before cascading, which
213
357
  surfaced on cascade-dispose, on owner re-run, AND on the regression path
214
358
  where an inner-only re-run had fired first. Fixed by swapping the order:
@@ -223,18 +367,18 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
223
367
  - 363 tests / 133 suites total, all passing under `node --expose-gc --test`.
224
368
  - **100% line coverage** and **98.62% branch coverage** on `Signal.js`
225
369
  + `Watch.js` (the few uncovered branches are defensive guards: cycle
226
- detection, batchEpoch wraparound after 2³² batches, and the self-dispose
227
- `gen` branches added by the conformance fixes unreachable from
370
+ detection, batchEpoch wraparound after 2^3^2 batches, and the self-dispose
371
+ `gen` branches added by the conformance fixes -- unreachable from
228
372
  conformance + existing user code).
229
373
  - New file `test/19-v12-additions.test.mjs` (24 tests) locks in shared peek,
230
374
  owner adoption rule, pre-batch revert, AggregateError aggregation,
231
375
  CycleError detection, the `maxLinks` config branch, the disposed-signal
232
376
  read/set behaviour, and the stop-fn ABA guard.
233
- - New file `test/20-axis-stress.test.mjs` (23 tests) eight orthogonal
377
+ - New file `test/20-axis-stress.test.mjs` (23 tests) -- eight orthogonal
234
378
  engine-invariant "axes" plus the permanent conformance pins for #141,
235
379
  #238, #241, #243.
236
380
  - Existing `test/15-owner-lazy-alloc.test.mjs` skips ("scheduler-thunk
237
- caching lands in v1.2.0") are removed the owner tree exists, the
381
+ caching lands in v1.2.0") are removed -- the owner tree exists, the
238
382
  tests pass.
239
383
  - Differential retracking fuzz against the published 1.1.5: 30,000 writes,
240
384
  **0 disagreements** (`bench/retracking.difftest.mjs`).
@@ -249,76 +393,76 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
249
393
  - The "scheduler-thunk caching" hint that referenced an older internal
250
394
  staging name (Signal-1.3.0-rc) is gone; the file is the public 1.2.0.
251
395
 
252
- ## [1.1.5] 2026-06-04
396
+ ## [1.1.5] -- 2026-06-04
253
397
 
254
398
  Additive release in service of `@zakkster/lite-devtools`: stable node identity on the
255
399
  introspection surface, so a tool can dedupe and traverse the full reactive DAG. Drop-in
256
400
  over 1.1.4, no breaking changes.
257
401
 
258
- ### Added node identity (top-level + per-registry)
402
+ ### Added -- node identity (top-level + per-registry)
259
403
  - `nodeId(handle)` -> the node's stable per-allocation id (`number`), or `undefined` for a
260
404
  non-handle. The dedupe key for graph walks.
261
405
  - `describe(handle)` -> the handle's own `{ id, kind, value }` descriptor, or `undefined`
262
406
  for a non-handle. **Re-walkable**: the descriptor may be passed back into
263
- `forEachObserver`/`forEachSource` the recursion primitive for full DAG discovery.
407
+ `forEachObserver`/`forEachSource` -- the recursion primitive for full DAG discovery.
264
408
  - `forEachObserver`/`forEachSource` descriptors now carry `id` (`{ id, kind, value }`).
265
409
  - Every node gains a stable `id` assigned at allocation: one SMI write at creation, node
266
410
  shape kept uniform (monomorphic). **Zero steady-state cost.**
267
411
 
268
412
  ### Test suite
269
- - Added `test/15-identity_test.mjs`: 5 tests ids unique + stable, `nodeId`/`describe`
413
+ - Added `test/15-identity_test.mjs`: 5 tests -- ids unique + stable, `nodeId`/`describe`
270
414
  undefined on non-handles, descriptor shape `{ id, kind, value }`, descriptors re-walkable,
271
415
  identity walks non-perturbing.
272
416
 
273
- ## [1.1.4] 2026-05-31
417
+ ## [1.1.4] -- 2026-05-31
274
418
 
275
419
  Combined release: a retracking rewrite that closes the two documented chaotic
276
420
  read-order limitations, plus an observer-lifecycle introspection surface. No
277
- breaking changes, no public-API removals drop-in over 1.1.3. (This release
421
+ breaking changes, no public-API removals -- drop-in over 1.1.3. (This release
278
422
  folds in the work that was internally staged as 1.1.4 and 1.1.5; it ships as a
279
423
  single 1.1.4.)
280
424
 
281
- ### Changed performance (retracking, no semantic change)
425
+ ### Changed -- performance (retracking, no semantic change)
282
426
  - **Version-stamped O(1) reconciliation + clean-read short-circuit.** The cursor
283
427
  reconciliation now stamps each source per evaluation and a `markEpoch` guard
284
428
  short-circuits the pull when a subtree is already clean. This replaces the
285
429
  prior strategy's O(N)-per-dep degradation under chaotic, high-fan-in, batched
286
430
  read-after-write (every read re-validating its dependency subtree). Stable
287
- read order is unchanged still O(1) per dep via cursor reuse, still zero-alloc.
431
+ read order is unchanged -- still O(1) per dep via cursor reuse, still zero-alloc.
288
432
  - **Result.** The two rows that were the documented v1.1.x limitation flipped from
289
433
  multiples-behind to ahead of `alien-signals`, and are now the fastest of the five
290
434
  benchmarked frameworks:
291
- - `dyn: large web app` 6194ms **571ms** (~10.9× faster; +9% vs alien)
292
- - `dyn: wide dense` 5115ms **912ms** (~5.6× faster; +10% vs alien)
435
+ - `dyn: large web app` 6194ms -> **571ms** (~10.9× faster; +9% vs alien)
436
+ - `dyn: wide dense` 5115ms -> **912ms** (~5.6× faster; +10% vs alien)
293
437
  No regressions on the other rows (steady-state update, propagation, and creation
294
438
  paths are within noise of 1.1.2). See `resultsReactive.txt`.
295
439
  - **Correctness.** The new retracking is validated by `retracking.difftest.mjs`
296
440
  against a reference reconciler: 20,000 direct writes and 10,000 batched writes,
297
441
  **0 disagreements**.
298
442
 
299
- ### Added observer-lifecycle introspection (top-level + per-registry)
443
+ ### Added -- observer-lifecycle introspection (top-level + per-registry)
300
444
  A small, zero-cost-when-unused surface for auto-pausing wrappers and devtools.
301
445
  All accept a public `Signal`/`Computed` handle.
302
- - **`hasObservers(handle)` `boolean`.** O(1) (`node.headSub !== null`). The
446
+ - **`hasObservers(handle)` -> `boolean`.** O(1) (`node.headSub !== null`). The
303
447
  auto-pause predicate: is anything subscribed to this source right now? A `peek`
304
448
  does not count.
305
- - **`observeObservers(handle, { onConnect?, onDisconnect? })` `unobserve`.**
306
- Fires `onConnect` on the 01 observer transition and `onDisconnect` on 10,
307
- *after* registration (transition-only no immediate fire if the handle is
449
+ - **`observeObservers(handle, { onConnect?, onDisconnect? })` -> `unobserve`.**
450
+ Fires `onConnect` on the 0->1 observer transition and `onDisconnect` on 1->0,
451
+ *after* registration (transition-only -- no immediate fire if the handle is
308
452
  already observed). Re-tracking a persistently-read source does **not** churn
309
453
  connect/disconnect. This is the hook `lite-time` / `lite-raf` use to start a
310
454
  ticker only while a derived value is being watched.
311
455
  - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`.** Walk the live
312
456
  graph in either direction; `fn` receives a `{ kind, value }` descriptor where
313
457
  `kind` is `"signal" | "computed" | "effect"`. For graph inspection (lite-devtools).
314
- - **Cost.** The hooks sit behind an internal lifecycle counter when no handle is
458
+ - **Cost.** The hooks sit behind an internal lifecycle counter -- when no handle is
315
459
  being observed, the hot path adds a single branch-predicted `count !== 0` check
316
460
  inside link alloc/free and nothing else. Zero steady-state cost when unused.
317
461
  - **Error contract.** `hasObservers` / `forEachObserver` / `forEachSource` no-op
318
462
  on a non-handle argument; `observeObservers` throws `TypeError`.
319
463
 
320
464
  ### Test suite
321
- - Added `test/13-introspection_test.mjs`: 10 tests across 3 describe blocks
465
+ - Added `test/13-introspection_test.mjs`: 10 tests across 3 describe blocks --
322
466
  `hasObservers` (live observation reflects, peek doesn't count), `observeObservers`
323
467
  auto-pause lifecycle (start-on-first/stop-on-last, no extra connect for a 2nd
324
468
  observer, re-observe fires again, no churn on re-track, conditional reads toggle
@@ -330,14 +474,14 @@ All accept a public `Signal`/`Computed` handle.
330
474
  None required. Drop-in upgrade. No existing surface or behavior changed; the
331
475
  introspection functions are purely additive and the retracking change is internal.
332
476
 
333
- ## [1.1.3] 2026-05-28
477
+ ## [1.1.3] -- 2026-05-28
334
478
 
335
- Patch release: one new export, no behavior changes, no engine changes drop-in
479
+ Patch release: one new export, no behavior changes, no engine changes -- drop-in
336
480
  over 1.1.2.
337
481
 
338
482
  ### Added
339
483
  - **`isTracking()`** (top-level + per-registry). Returns `true` iff a read RIGHT
340
- NOW would record a dependency on this registry an observer body is on the
484
+ NOW would record a dependency on this registry -- an observer body is on the
341
485
  stack AND tracking is enabled. Returns `false` inside `untrack()`, inside the
342
486
  callback of `signal.subscribe` (which inlines the same untracked-notify), inside
343
487
  `onCleanup` bodies, inside the `watch` / `when` callback path, and outside any
@@ -355,14 +499,14 @@ allocation on whether the read will actually subscribe anything.
355
499
 
356
500
  ### API notes
357
501
  - **Per-registry.** A wrapper operating against a non-default registry MUST call
358
- THAT registry's `isTracking()`, not the top-level one each registry has its
502
+ THAT registry's `isTracking()`, not the top-level one -- each registry has its
359
503
  own tracking state. The top-level helper delegates to the default registry,
360
504
  matching the existing pattern for `signal`/`computed`/`effect`/`untrack`.
361
505
  - **Cost.** Two closure-variable loads, one AND, one return; V8 inlines it.
362
- Roughly 12 ns per call.
506
+ Roughly 1-2 ns per call.
363
507
 
364
508
  ### Test suite
365
- - Added `test/10-is-tracking_test.mjs`: 11 tests across 5 describe blocks
509
+ - Added `test/10-is-tracking_test.mjs`: 11 tests across 5 describe blocks --
366
510
  observer-bodies (effect + computed), untracked windows (`untrack`, `subscribe`
367
511
  callback, `onCleanup`, `watch` callback), outside-observer (module scope,
368
512
  call-site of unobserved computed read), robustness (state restored after
@@ -371,19 +515,19 @@ allocation on whether the read will actually subscribe anything.
371
515
  ### Migration from 1.1.2
372
516
  None required. Drop-in upgrade. No existing surface or behavior changed.
373
517
 
374
- ## [1.1.2] 2026-05-26
518
+ ## [1.1.2] -- 2026-05-26
375
519
 
376
520
  Patch release: hot-path micro-optimizations and a zero-allocation cleanup of
377
- the creation path. No behavior changes, no API changes drop-in over 1.1.1.
521
+ the creation path. No behavior changes, no API changes -- drop-in over 1.1.1.
378
522
 
379
- ### Changed performance (no semantic change)
523
+ ### Changed -- performance (no semantic change)
380
524
  - **Inlined cursor fast-path in `signal()`/`computed()` reads.** On stable read
381
525
  order the cursor match is now handled inline; only a cursor *miss* falls
382
526
  through into the (large, non-inlinable) `allocateLink` frame. Removes a
383
527
  function call from the steady-state read hot path.
384
528
  - **Allocation-free creation.** `signal`/`computed`/`effect` now read their
385
529
  `opts` argument defensively instead of defaulting the parameter to `{}`. The
386
- `= {}` default allocated a throwaway object on every no-opts call the common
530
+ `= {}` default allocated a throwaway object on every no-opts call -- the common
387
531
  path when mounting many cells. Creation is now zero-allocation on that path.
388
532
  - **Single-closure `subscribe`.** The tracked read + untracked notify is inlined
389
533
  (one closure instead of two), dropping a per-subscription closure and an
@@ -393,11 +537,11 @@ the creation path. No behavior changes, no API changes — drop-in over 1.1.1.
393
537
  the `markEpoch` dedup guard on purpose (hoisting it would add work on the
394
538
  already-marked revisit path that the guard exists to keep cheap).
395
539
 
396
- ### Changed packaging
540
+ ### Changed -- packaging
397
541
  - Canonical single-engine layout: the implementation is `Signal.js` and the
398
542
  watcher utilities are `Watch.js`, which imports `effect`/`untrack` from
399
543
  `./Signal.js`. Both the public entry and `Watch.js` resolve to one engine
400
- instance eliminating any chance of a duplicate-module-instance split that
544
+ instance -- eliminating any chance of a duplicate-module-instance split that
401
545
  would silently break cross-module dependency tracking.
402
546
 
403
547
  ### Test suite
@@ -416,7 +560,7 @@ the creation path. No behavior changes, no API changes — drop-in over 1.1.1.
416
560
  ### Migration from 1.1.x
417
561
  None required. Drop-in upgrade.
418
562
 
419
- ## [1.1.1] 2026-05-22
563
+ ## [1.1.1] -- 2026-05-22
420
564
 
421
565
  Patch release: cleanup-semantics adapter integration, conformance fixes from
422
566
  the `johnsoncodehk/reactive-framework-test-suite`, and one targeted
@@ -430,7 +574,7 @@ correctness bug in flush error reporting.
430
574
  - `tailSub` field on `ReactiveNode`. Symmetric with the existing `tailDep`;
431
575
  enables O(1) tail insertion into the subscriber list.
432
576
 
433
- ### Changed conformance fixes
577
+ ### Changed -- conformance fixes
434
578
 
435
579
  - **#216** Effects now fire in **creation order** on a shared signal.
436
580
  Subscriber list insertion is tail-first instead of head-first; traversal
@@ -439,8 +583,8 @@ correctness bug in flush error reporting.
439
583
 
440
584
  - **#178** `runCleanup` invokes registered cleanups in an **untracked
441
585
  context** (`currentObserver = null`, `isTrackingDeps = false`). Reads
442
- inside a cleanup body including reads triggered by a synchronous
443
- `dispose()` from a containing effect no longer leak into the parent
586
+ inside a cleanup body -- including reads triggered by a synchronous
587
+ `dispose()` from a containing effect -- no longer leak into the parent
444
588
  observer's dep set.
445
589
 
446
590
  - **#111** `executeEffect` bails cleanly when a node is disposed by its own
@@ -473,7 +617,7 @@ correctness bug in flush error reporting.
473
617
 
474
618
  ### Performance
475
619
  - No regressions observed in MUX, BROADCAST, DEEP CHAIN, KAIROS, or
476
- SELECTIVE DAG benchmarks. MUX moved from 156K to 226K ops/s V8 appears
620
+ SELECTIVE DAG benchmarks. MUX moved from 156K to 226K ops/s -- V8 appears
477
621
  to optimise the flush loop more aggressively now that the per-iteration
478
622
  `try/catch` shape is stable. Out-of-batch `signal.set` is unchanged
479
623
  (revert-detection guards short-circuit on `batchDepth === 0`).
@@ -492,22 +636,22 @@ correctness bug in flush error reporting.
492
636
  (sibling-effect propagation under no-re-run, cycle precedence over
493
637
  buffered errors, custom-equals revert, etc.).
494
638
 
495
- ## [1.1.0] 2026-05-20
639
+ ## [1.1.0] -- 2026-05-20
496
640
 
497
641
  ### Added
498
- - `markDownstream` iterative DFS marker backed by preallocated `markStack` propagation no longer grows the JS call stack regardless of graph depth.
499
- - Double-buffered effect queue (`effectQueueA` / `effectQueueB`) effects scheduled mid-flush land in the next pass, no recursive flush.
500
- - Generation counter (`gen`) per node stale handles after dispose+recycle silently no-op instead of corrupting the pool.
642
+ - `markDownstream` iterative DFS marker backed by preallocated `markStack` -- propagation no longer grows the JS call stack regardless of graph depth.
643
+ - Double-buffered effect queue (`effectQueueA` / `effectQueueB`) -- effects scheduled mid-flush land in the next pass, no recursive flush.
644
+ - Generation counter (`gen`) per node -- stale handles after dispose+recycle silently no-op instead of corrupting the pool.
501
645
  - `CapacityError` with `kind` (`"nodes"` | `"links"`) and `capacity` fields, thrown when the `"throw"` policy is set and a pool is exhausted.
502
- - `createRegistry({ onCapacityExceeded: "grow" })` opt-in unbounded pool growth, bounded by `maxLinks * 16` ceiling.
503
- - `createRegistry({ maxFlushPasses })` configurable cycle-protection limit (default `100`).
504
- - `destroy()` full registry reset; all prior handles silently no-op afterward.
505
- - `watch(source, callback, { immediate? })`, `when(predicate, callback)`, `whenAsync(predicate)` re-exported from `Watch.js`. Zero-allocation hot paths in `watch` and `when`; `whenAsync` allocates one Promise per call (documented; not for per-frame use).
646
+ - `createRegistry({ onCapacityExceeded: "grow" })` -- opt-in unbounded pool growth, bounded by `maxLinks * 16` ceiling.
647
+ - `createRegistry({ maxFlushPasses })` -- configurable cycle-protection limit (default `100`).
648
+ - `destroy()` -- full registry reset; all prior handles silently no-op afterward.
649
+ - `watch(source, callback, { immediate? })`, `when(predicate, callback)`, `whenAsync(predicate)` -- re-exported from `Watch.js`. Zero-allocation hot paths in `watch` and `when`; `whenAsync` allocates one Promise per call (documented; not for per-frame use).
506
650
 
507
651
  ### Changed
508
652
  - 32-bit modular epoch arithmetic across `globalVersion`, `evalVersion`, `markEpoch`. Engine survives indefinite uptime without integer-overflow risk.
509
653
  - `dispose(api)` is now universal across signals, computeds, effect handles, and `.subscribe()` return values. Cross-registry calls are silent no-ops. Foreign reactive primitives are duck-typed (on `.peek`) and not invoked.
510
- - `untrack(fn)` restores prior tracking state via `try / finally` safe under thrown errors inside `fn`.
654
+ - `untrack(fn)` restores prior tracking state via `try / finally` -- safe under thrown errors inside `fn`.
511
655
  - `onCleanup(fn)` now accepts multiple registrations per scope and works in computeds, not just effects. Stored as a single function or upgraded to an array.
512
656
 
513
657
  ### Fixed
@@ -523,12 +667,12 @@ correctness bug in flush error reporting.
523
667
  - Full methodology and reproducibility recipe in [`bench/README.md`](./bench/README.md).
524
668
 
525
669
  ### Known limitations
526
- - Dependency reconciliation is O(1) per read on stable read order; degrades to O(N) under chaotic read order. v1.2 (in benchmark validation) replaces the cursor-based retracking with per-source version-stamped reconciliation see [RFC #N1](https://github.com/PeshoVurtoleta/lite-signal/issues/1).
670
+ - Dependency reconciliation is O(1) per read on stable read order; degrades to O(N) under chaotic read order. v1.2 (in benchmark validation) replaces the cursor-based retracking with per-source version-stamped reconciliation -- see [RFC #N1](https://github.com/PeshoVurtoleta/lite-signal/issues/1).
527
671
  - Computed resolution is recursive on the JS call stack; bounded by the engine stack limit (~10,000 frames).
528
672
  - `whenAsync` allocates one Promise per call. Use `when` (callback form) for per-frame paths.
529
673
 
530
674
  ### Migration from 1.0.x
531
675
  None required. Drop-in upgrade.
532
676
 
533
- ## [1.0.0] 2026-05-12
677
+ ## [1.0.0] -- 2026-05-12
534
678
  Initial public release.