@zakkster/lite-signal 1.2.0 → 1.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (6) hide show
  1. package/CHANGELOG.md +331 -68
  2. package/README.md +244 -155
  3. package/Signal.d.ts +74 -20
  4. package/Signal.js +191 -85
  5. package/llms.txt +189 -66
  6. package/package.json +7 -3
package/CHANGELOG.md CHANGED
@@ -4,14 +4,277 @@ 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.0] 2026-06-11
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
152
+
153
+ A correctness-and-pauses patch in two halves: the pool allocator stops paying
154
+ for growth in unbounded bursts, and the introspection surface stops lying about
155
+ handles the 1.2.0 owner tree disposed behind your back. Plus the graph-mutation
156
+ hook -- the keystone that lets lite-devtools 1.1 / lite-studio 1.1 go push-based.
157
+ Drop-in over 1.2.0: 404-test suite green, 177/178 on
158
+ johnsoncodehk/reactive-framework-test-suite (same single open cell, Inner
159
+ Write #179), hot-path regression gate flat on two hosts.
160
+
161
+ ### Fixed -- bounded pool growth (no more construction bursts)
162
+ - Under `onCapacityExceeded: "grow"`, exhausting a pool used to double it by
163
+ synchronously constructing `currentCapacity` fresh nodes/links -- at a
164
+ 524,288-node pool that is a quarter-million 25-field allocations in one
165
+ pause, in whatever frame triggered it. Growth is now incremental: **one**
166
+ node/link constructed per free-list miss, pushed into the pool, recycled
167
+ forever after. The capacity **ledger** still doubles, so `stats()`
168
+ (`nodePoolCapacity` / `linkPoolCapacity` / `pooledLinks`), the
169
+ `maxLinks × 16` ceiling, and every `CapacityError` are bit-identical to
170
+ 1.2.0 -- only the construction schedule changed. Locked by the existing
171
+ `test/03-pool` capacity/ceiling/recycle contracts.
172
+ - Benchmark effect (volynetstyle/js-reactivity-benchmark, same host as the
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).
175
+ Honest redistribution note: rows that previously *fit inside the doubling
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
178
+ accidental prefetch, and the same mechanism produced the pathological
179
+ bursts. Bounded pauses are the right trade for real applications; the
180
+ group total still improves.
181
+ - Steady-state hot paths are untouched (update / dynamic-retracking /
182
+ effect-recycle measured flat on both benchmark hosts).
183
+
184
+ ### Fixed -- effect queues / mark stack stay PACKED
185
+ - Pool growth used to pre-size `effectQueueA/B` and the mark stack with
186
+ `arr.length = newCap` -- which permanently converts a PACKED V8 array to
187
+ HOLEY elements, a silent tax on every subsequent flush read. The queues now
188
+ grow by sequential append (packed-preserving, auto-amortised) and
189
+ `destroy()` truncates instead of null-filling to capacity.
190
+
191
+ ### Fixed -- `destroy()` iterates physical pools
192
+ - `destroy()` walked `currentNodesCapacity` slots by index; with incremental
193
+ growth (and any future lazy population) the ledger can exceed the physical
194
+ pool. It now walks `nodePool.length` / `linkPool.length` and is safe on an
195
+ empty pool.
196
+
197
+ ### Fixed -- stale-handle introspection (the owner-tree follow-up)
198
+ - 1.2.0's owner tree made the engine recycle pool slots **autonomously**: an
199
+ owner re-run cascade-disposes its owned observers, so holding a stale handle
200
+ stopped being a user error and became a routine occurrence. The
201
+ introspection surface -- `nodeId` / `describe` / `forEachObserver` /
202
+ `forEachSource` / `hasObservers` / `observeObservers` -- still resolved
203
+ `NODE_PTR` without a generation check and would happily report the
204
+ **recycled slot's new resident** (wrong id, wrong value, wrong edges)
205
+ through an old handle. All six entry points now resolve through a
206
+ gen-guarded `liveNode()` and report stale handles as `undefined` (or throw
207
+ the existing `TypeError`, for `observeObservers`) -- the same ABA discipline
208
+ `read()` / `set()` / `dispose()` already had.
209
+ - `describe()` descriptors are now **gen-stamped** alongside the node
210
+ reference, so the documented "descriptors are re-walkable handles" contract
211
+ survives the guard: a fresh descriptor walks; one held across a recycle
212
+ correctly goes stale. Pinned by the existing
213
+ "forEach* descriptors carry id and are re-walkable" test.
214
+ - **Effect dispose handles are now first-class introspection handles.** On
215
+ every prior version, `effect()` returned a bare closure carrying neither
216
+ `NODE_PTR` nor `NODE_GEN` -- so `describe` / `nodeId` / `forEachSource`
217
+ returned `undefined`/empty for a **live** effect handle, and
218
+ `observeObservers(effectHandle)` threw. The dispose function is now stamped
219
+ with the same symbol pair as signal/computed handles (`NODE_GEN` mirrors the
220
+ disposer's own `birthGen`, so introspection validity agrees exactly with its
221
+ stale-guard). After explicit dispose, slot recycle, or owner-cascade the
222
+ handle correctly reads stale. Measured cost: two property stores per effect
223
+ creation (~50 ns on a create/dispose churn microbench) -- symmetric with
224
+ what signal/computed handles already pay, create-path only. Found by the
225
+ lite-devtools 1.1 cross-probe campaign (`track(effectHandle)` threw).
226
+ - `peek()` had the same hole: `sharedSignalPeek` / `sharedComputedPeek`
227
+ resolved the slot ungated, so a stale handle's `peek()` returned the new
228
+ resident's value. Both now gen-check first and return `undefined` when
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).
231
+
232
+ ### Added -- `onGraphMutation(fn)`: the graph-mutation hook
233
+ - Registry-level (and default-registry module export) debug hook, the
234
+ connection point for push-based tooling. Single nullable listener; every
235
+ fire point is one `if (mutationHook !== null)` branch and the dispatch is
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
242
+ - Cost: **zero when unregistered** (hot-path gate flat); registered, the
243
+ worst case measured is +29% on a dynamic-retracking torture loop (11.4M
244
+ events for 400K writes) -- a debug-mode tax paid only while a consumer is
245
+ attached, proportional to event volume.
246
+ - **Listener contract: observe only -- never throw, never mutate the graph.**
247
+ The hook fires synchronously inside mutation points; lite-devtools 1.1
248
+ multiplexes all of its consumers behind one registration, isolates their
249
+ exceptions, and unregisters when the last consumer stops (returning the
250
+ engine to the zero-cost state). `onGraphMutation` returns an unsubscribe
251
+ that restores the previously registered listener.
252
+
253
+ ### Added -- owner-tree introspection: `forEachOwned` / `ownerOf`
254
+ - The 1.2.0 owner tree finally gets a (read-only, gen-guarded) window:
255
+ `forEachOwned(handle, fn)` iterates a node's owned children as standard
256
+ re-walkable descriptors; `ownerOf(handle)` returns the owner's descriptor
257
+ or `undefined` (top-level or stale). Same descriptor conventions as
258
+ `forEachObserver` / `forEachSource`; garbage input is a no-op /
259
+ `undefined`. This is what lite-devtools 1.1 builds `ownerTree()` and the
260
+ `graph({owners: true})` ownership edges on
261
+ (`capabilities().owners === true` from this release).
262
+
263
+ ### Compatibility
264
+ - No behavioural change for live handles; stale handles now read as stale
265
+ everywhere instead of as the slot's next tenant. Allocation strategy is
266
+ unobservable through the public API. Tooling floor: lite-devtools >= 1.1.0
267
+ detects `onGraphMutation` / `forEachOwned` at load and degrades to its
268
+ 1.0 polling behaviour on older engines.
269
+
270
+ ## [1.2.0] -- 2026-06-11
8
271
 
9
272
  A structural refactor that internally splits the engine into three named layers
10
273
  (graph topology / ownership-lifecycle / propagation-execution) with a strict
11
274
  dependency direction, plus a small set of additive features built on top of
12
- 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.
13
276
 
14
- ### Added auto-disposal of nested observers (owner tree)
277
+ ### Added -- auto-disposal of nested observers (owner tree)
15
278
  - An effect or computed that creates **observers** (nested `effect`/`computed`)
16
279
  now owns them via an internal owner tree. When the owner re-runs or is
17
280
  disposed, all owned observers are cascade-disposed before the new run starts.
@@ -25,7 +288,7 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
25
288
  `test/15-owner-lazy-alloc.test.mjs` (the lite-store cross-wire shape) and
26
289
  the new `test/19-v12-additions.test.mjs`.
27
290
 
28
- ### Added pre-batch revert (the "set X, set X back" optimisation)
291
+ ### Added -- pre-batch revert (the "set X, set X back" optimisation)
29
292
  - Inside a `batch(...)`, if a signal is set and then set back to its
30
293
  pre-batch value (under the signal's own `equals`), the version bump is
31
294
  reverted and downstream effects/computeds do **not** fire. Eliminates a
@@ -33,15 +296,15 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
33
296
  in form state and undo/redo. Verified end-to-end (signal, computed, effect)
34
297
  in `test/19-v12-additions.test.mjs`.
35
298
 
36
- ### Added multi-effect throws aggregate to `AggregateError`
299
+ ### Added -- multi-effect throws aggregate to `AggregateError`
37
300
  - When two or more effects throw in the **same flush pass**, the engine
38
301
  collects all errors and rethrows a native `AggregateError` at the
39
302
  triggering `set()` / batch boundary. A single thrown error is rethrown
40
303
  unwrapped (no change). Effects that don't throw still run. Cycle detection
41
- unchanged a flush exceeding `maxFlushPasses` (default 100) throws an
304
+ unchanged -- a flush exceeding `maxFlushPasses` (default 100) throws an
42
305
  `Error` prefixed `"CycleError:"`.
43
306
 
44
- ### Added scheduler thunk caching with gen-bound ABA guard
307
+ ### Added -- scheduler thunk caching with gen-bound ABA guard
45
308
  - `effect(fn, { scheduler })` now caches the scheduler thunk on the node
46
309
  itself (`node.schedulerThunk`) so repeated re-schedules reuse the same
47
310
  closure (no allocation per re-schedule). The thunk holds a generation snapshot
@@ -49,14 +312,14 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
49
312
  generation, so a stale thunk fired by an async scheduler against a recycled
50
313
  pool slot is a guaranteed no-op (ABA safe).
51
314
 
52
- ### Changed internal refactor, no behavioural difference
315
+ ### Changed -- internal refactor, no behavioural difference
53
316
  - The engine is reorganised into three explicit layers with documented
54
317
  invariants (see the file header in `Signal.js`):
55
- - **L1 Graph topology** `allocateLink` / `freeLink` / `severTail`. Pure
318
+ - **L1 Graph topology** -- `allocateLink` / `freeLink` / `severTail`. Pure
56
319
  edge mechanics. Never touches `owner` / `firstOwned`.
57
- - **L2 Ownership/lifecycle** `createNode` / `disposeNode` / `runCleanup`.
320
+ - **L2 Ownership/lifecycle** -- `createNode` / `disposeNode` / `runCleanup`.
58
321
  Owns the owner tree and user cleanup. Never touches the tracking cursor.
59
- - **L3 Propagation/execution** `markDownstream` (cursor-free), and the
322
+ - **L3 Propagation/execution** -- `markDownstream` (cursor-free), and the
60
323
  orchestrators `executeEffect` / `pullComputed` that drive the cursor
61
324
  (L1) and call `runCleanup` (L2) before a re-run.
62
325
  - `currentObserver` and `currentOwner` are now distinct pointers. Today they
@@ -65,31 +328,31 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
65
328
  - **Shared `peek` (perf).** `signal()` and `computed()` now reuse a single
66
329
  `peek` function per registry instead of allocating a fresh closure per
67
330
  primitive. Equivalent across registries (each registry has its own pair).
68
- ~1014% faster signal/computed creation on the `S:create*` micros, no
331
+ ~10-14% faster signal/computed creation on the `S:create*` micros, no
69
332
  hot-path or behavioural change. Verified by 5 dedicated tests + the full
70
333
  309-strong existing suite + 30,000-write differential retracking fuzz vs
71
334
  the published 1.1.5.
72
335
 
73
- ### Changed port-forward of the 1.1.3/1.1.4 perf fixes
74
- - `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
75
338
  a computed after an unrelated source changed is O(1).
76
- - `allocateLink` retains the **O(1) `tailSub` dedup** divergent re-tracking
77
- 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
78
341
  re-read of the same source after an intervening observer can retain one
79
342
  duplicate link per intervening edge, bounded by the loop count and
80
343
  dispose-reclaimed.
81
344
 
82
- ### Fixed conformance regressions surfaced during release prep
345
+ ### Fixed -- conformance regressions surfaced during release prep
83
346
  - **#141 (`dispose during execution then continue: no re-run`)**: an effect that
84
347
  called its own dispose handle mid-run and then continued to read another
85
348
  signal would corrupt the link-list bookkeeping in `severTail` (latent crash
86
- 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
87
350
  aggressively and made it visible). Fixed by nulling the tracking cursor in
88
351
  `disposeNode` when the disposed node is the active observer, plus a
89
352
  gen-snapshot guard in `executeEffect` / `pullComputed` so a post-body
90
353
  `severTail` on a recycled slot is skipped.
91
354
  - **#238 / #241 / #243 (cleanup ordering)**: nested effect cleanups must fire
92
- inside-out on owner-tree disposal grandchild before child before outer.
355
+ inside-out on owner-tree disposal -- grandchild before child before outer.
93
356
  The previous `runCleanup` ran the node's OWN cleanup before cascading, which
94
357
  surfaced on cascade-dispose, on owner re-run, AND on the regression path
95
358
  where an inner-only re-run had fired first. Fixed by swapping the order:
@@ -104,18 +367,18 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
104
367
  - 363 tests / 133 suites total, all passing under `node --expose-gc --test`.
105
368
  - **100% line coverage** and **98.62% branch coverage** on `Signal.js`
106
369
  + `Watch.js` (the few uncovered branches are defensive guards: cycle
107
- detection, batchEpoch wraparound after 2³² batches, and the self-dispose
108
- `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
109
372
  conformance + existing user code).
110
373
  - New file `test/19-v12-additions.test.mjs` (24 tests) locks in shared peek,
111
374
  owner adoption rule, pre-batch revert, AggregateError aggregation,
112
375
  CycleError detection, the `maxLinks` config branch, the disposed-signal
113
376
  read/set behaviour, and the stop-fn ABA guard.
114
- - 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
115
378
  engine-invariant "axes" plus the permanent conformance pins for #141,
116
379
  #238, #241, #243.
117
380
  - Existing `test/15-owner-lazy-alloc.test.mjs` skips ("scheduler-thunk
118
- 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
119
382
  tests pass.
120
383
  - Differential retracking fuzz against the published 1.1.5: 30,000 writes,
121
384
  **0 disagreements** (`bench/retracking.difftest.mjs`).
@@ -130,76 +393,76 @@ that split. No behavioural changes for existing code — drop-in over 1.1.5.
130
393
  - The "scheduler-thunk caching" hint that referenced an older internal
131
394
  staging name (Signal-1.3.0-rc) is gone; the file is the public 1.2.0.
132
395
 
133
- ## [1.1.5] 2026-06-04
396
+ ## [1.1.5] -- 2026-06-04
134
397
 
135
398
  Additive release in service of `@zakkster/lite-devtools`: stable node identity on the
136
399
  introspection surface, so a tool can dedupe and traverse the full reactive DAG. Drop-in
137
400
  over 1.1.4, no breaking changes.
138
401
 
139
- ### Added node identity (top-level + per-registry)
402
+ ### Added -- node identity (top-level + per-registry)
140
403
  - `nodeId(handle)` -> the node's stable per-allocation id (`number`), or `undefined` for a
141
404
  non-handle. The dedupe key for graph walks.
142
405
  - `describe(handle)` -> the handle's own `{ id, kind, value }` descriptor, or `undefined`
143
406
  for a non-handle. **Re-walkable**: the descriptor may be passed back into
144
- `forEachObserver`/`forEachSource` the recursion primitive for full DAG discovery.
407
+ `forEachObserver`/`forEachSource` -- the recursion primitive for full DAG discovery.
145
408
  - `forEachObserver`/`forEachSource` descriptors now carry `id` (`{ id, kind, value }`).
146
409
  - Every node gains a stable `id` assigned at allocation: one SMI write at creation, node
147
410
  shape kept uniform (monomorphic). **Zero steady-state cost.**
148
411
 
149
412
  ### Test suite
150
- - 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`
151
414
  undefined on non-handles, descriptor shape `{ id, kind, value }`, descriptors re-walkable,
152
415
  identity walks non-perturbing.
153
416
 
154
- ## [1.1.4] 2026-05-31
417
+ ## [1.1.4] -- 2026-05-31
155
418
 
156
419
  Combined release: a retracking rewrite that closes the two documented chaotic
157
420
  read-order limitations, plus an observer-lifecycle introspection surface. No
158
- 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
159
422
  folds in the work that was internally staged as 1.1.4 and 1.1.5; it ships as a
160
423
  single 1.1.4.)
161
424
 
162
- ### Changed performance (retracking, no semantic change)
425
+ ### Changed -- performance (retracking, no semantic change)
163
426
  - **Version-stamped O(1) reconciliation + clean-read short-circuit.** The cursor
164
427
  reconciliation now stamps each source per evaluation and a `markEpoch` guard
165
428
  short-circuits the pull when a subtree is already clean. This replaces the
166
429
  prior strategy's O(N)-per-dep degradation under chaotic, high-fan-in, batched
167
430
  read-after-write (every read re-validating its dependency subtree). Stable
168
- 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.
169
432
  - **Result.** The two rows that were the documented v1.1.x limitation flipped from
170
433
  multiples-behind to ahead of `alien-signals`, and are now the fastest of the five
171
434
  benchmarked frameworks:
172
- - `dyn: large web app` 6194ms **571ms** (~10.9× faster; +9% vs alien)
173
- - `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)
174
437
  No regressions on the other rows (steady-state update, propagation, and creation
175
438
  paths are within noise of 1.1.2). See `resultsReactive.txt`.
176
439
  - **Correctness.** The new retracking is validated by `retracking.difftest.mjs`
177
440
  against a reference reconciler: 20,000 direct writes and 10,000 batched writes,
178
441
  **0 disagreements**.
179
442
 
180
- ### Added observer-lifecycle introspection (top-level + per-registry)
443
+ ### Added -- observer-lifecycle introspection (top-level + per-registry)
181
444
  A small, zero-cost-when-unused surface for auto-pausing wrappers and devtools.
182
445
  All accept a public `Signal`/`Computed` handle.
183
- - **`hasObservers(handle)` `boolean`.** O(1) (`node.headSub !== null`). The
446
+ - **`hasObservers(handle)` -> `boolean`.** O(1) (`node.headSub !== null`). The
184
447
  auto-pause predicate: is anything subscribed to this source right now? A `peek`
185
448
  does not count.
186
- - **`observeObservers(handle, { onConnect?, onDisconnect? })` `unobserve`.**
187
- Fires `onConnect` on the 01 observer transition and `onDisconnect` on 10,
188
- *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
189
452
  already observed). Re-tracking a persistently-read source does **not** churn
190
453
  connect/disconnect. This is the hook `lite-time` / `lite-raf` use to start a
191
454
  ticker only while a derived value is being watched.
192
455
  - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`.** Walk the live
193
456
  graph in either direction; `fn` receives a `{ kind, value }` descriptor where
194
457
  `kind` is `"signal" | "computed" | "effect"`. For graph inspection (lite-devtools).
195
- - **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
196
459
  being observed, the hot path adds a single branch-predicted `count !== 0` check
197
460
  inside link alloc/free and nothing else. Zero steady-state cost when unused.
198
461
  - **Error contract.** `hasObservers` / `forEachObserver` / `forEachSource` no-op
199
462
  on a non-handle argument; `observeObservers` throws `TypeError`.
200
463
 
201
464
  ### Test suite
202
- - 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 --
203
466
  `hasObservers` (live observation reflects, peek doesn't count), `observeObservers`
204
467
  auto-pause lifecycle (start-on-first/stop-on-last, no extra connect for a 2nd
205
468
  observer, re-observe fires again, no churn on re-track, conditional reads toggle
@@ -211,14 +474,14 @@ All accept a public `Signal`/`Computed` handle.
211
474
  None required. Drop-in upgrade. No existing surface or behavior changed; the
212
475
  introspection functions are purely additive and the retracking change is internal.
213
476
 
214
- ## [1.1.3] 2026-05-28
477
+ ## [1.1.3] -- 2026-05-28
215
478
 
216
- 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
217
480
  over 1.1.2.
218
481
 
219
482
  ### Added
220
483
  - **`isTracking()`** (top-level + per-registry). Returns `true` iff a read RIGHT
221
- 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
222
485
  stack AND tracking is enabled. Returns `false` inside `untrack()`, inside the
223
486
  callback of `signal.subscribe` (which inlines the same untracked-notify), inside
224
487
  `onCleanup` bodies, inside the `watch` / `when` callback path, and outside any
@@ -236,14 +499,14 @@ allocation on whether the read will actually subscribe anything.
236
499
 
237
500
  ### API notes
238
501
  - **Per-registry.** A wrapper operating against a non-default registry MUST call
239
- 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
240
503
  own tracking state. The top-level helper delegates to the default registry,
241
504
  matching the existing pattern for `signal`/`computed`/`effect`/`untrack`.
242
505
  - **Cost.** Two closure-variable loads, one AND, one return; V8 inlines it.
243
- Roughly 12 ns per call.
506
+ Roughly 1-2 ns per call.
244
507
 
245
508
  ### Test suite
246
- - 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 --
247
510
  observer-bodies (effect + computed), untracked windows (`untrack`, `subscribe`
248
511
  callback, `onCleanup`, `watch` callback), outside-observer (module scope,
249
512
  call-site of unobserved computed read), robustness (state restored after
@@ -252,19 +515,19 @@ allocation on whether the read will actually subscribe anything.
252
515
  ### Migration from 1.1.2
253
516
  None required. Drop-in upgrade. No existing surface or behavior changed.
254
517
 
255
- ## [1.1.2] 2026-05-26
518
+ ## [1.1.2] -- 2026-05-26
256
519
 
257
520
  Patch release: hot-path micro-optimizations and a zero-allocation cleanup of
258
- 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.
259
522
 
260
- ### Changed performance (no semantic change)
523
+ ### Changed -- performance (no semantic change)
261
524
  - **Inlined cursor fast-path in `signal()`/`computed()` reads.** On stable read
262
525
  order the cursor match is now handled inline; only a cursor *miss* falls
263
526
  through into the (large, non-inlinable) `allocateLink` frame. Removes a
264
527
  function call from the steady-state read hot path.
265
528
  - **Allocation-free creation.** `signal`/`computed`/`effect` now read their
266
529
  `opts` argument defensively instead of defaulting the parameter to `{}`. The
267
- `= {}` 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
268
531
  path when mounting many cells. Creation is now zero-allocation on that path.
269
532
  - **Single-closure `subscribe`.** The tracked read + untracked notify is inlined
270
533
  (one closure instead of two), dropping a per-subscription closure and an
@@ -274,11 +537,11 @@ the creation path. No behavior changes, no API changes — drop-in over 1.1.1.
274
537
  the `markEpoch` dedup guard on purpose (hoisting it would add work on the
275
538
  already-marked revisit path that the guard exists to keep cheap).
276
539
 
277
- ### Changed packaging
540
+ ### Changed -- packaging
278
541
  - Canonical single-engine layout: the implementation is `Signal.js` and the
279
542
  watcher utilities are `Watch.js`, which imports `effect`/`untrack` from
280
543
  `./Signal.js`. Both the public entry and `Watch.js` resolve to one engine
281
- instance eliminating any chance of a duplicate-module-instance split that
544
+ instance -- eliminating any chance of a duplicate-module-instance split that
282
545
  would silently break cross-module dependency tracking.
283
546
 
284
547
  ### Test suite
@@ -297,7 +560,7 @@ the creation path. No behavior changes, no API changes — drop-in over 1.1.1.
297
560
  ### Migration from 1.1.x
298
561
  None required. Drop-in upgrade.
299
562
 
300
- ## [1.1.1] 2026-05-22
563
+ ## [1.1.1] -- 2026-05-22
301
564
 
302
565
  Patch release: cleanup-semantics adapter integration, conformance fixes from
303
566
  the `johnsoncodehk/reactive-framework-test-suite`, and one targeted
@@ -311,7 +574,7 @@ correctness bug in flush error reporting.
311
574
  - `tailSub` field on `ReactiveNode`. Symmetric with the existing `tailDep`;
312
575
  enables O(1) tail insertion into the subscriber list.
313
576
 
314
- ### Changed conformance fixes
577
+ ### Changed -- conformance fixes
315
578
 
316
579
  - **#216** Effects now fire in **creation order** on a shared signal.
317
580
  Subscriber list insertion is tail-first instead of head-first; traversal
@@ -320,8 +583,8 @@ correctness bug in flush error reporting.
320
583
 
321
584
  - **#178** `runCleanup` invokes registered cleanups in an **untracked
322
585
  context** (`currentObserver = null`, `isTrackingDeps = false`). Reads
323
- inside a cleanup body including reads triggered by a synchronous
324
- `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
325
588
  observer's dep set.
326
589
 
327
590
  - **#111** `executeEffect` bails cleanly when a node is disposed by its own
@@ -354,7 +617,7 @@ correctness bug in flush error reporting.
354
617
 
355
618
  ### Performance
356
619
  - No regressions observed in MUX, BROADCAST, DEEP CHAIN, KAIROS, or
357
- 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
358
621
  to optimise the flush loop more aggressively now that the per-iteration
359
622
  `try/catch` shape is stable. Out-of-batch `signal.set` is unchanged
360
623
  (revert-detection guards short-circuit on `batchDepth === 0`).
@@ -373,22 +636,22 @@ correctness bug in flush error reporting.
373
636
  (sibling-effect propagation under no-re-run, cycle precedence over
374
637
  buffered errors, custom-equals revert, etc.).
375
638
 
376
- ## [1.1.0] 2026-05-20
639
+ ## [1.1.0] -- 2026-05-20
377
640
 
378
641
  ### Added
379
- - `markDownstream` iterative DFS marker backed by preallocated `markStack` propagation no longer grows the JS call stack regardless of graph depth.
380
- - Double-buffered effect queue (`effectQueueA` / `effectQueueB`) effects scheduled mid-flush land in the next pass, no recursive flush.
381
- - 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.
382
645
  - `CapacityError` with `kind` (`"nodes"` | `"links"`) and `capacity` fields, thrown when the `"throw"` policy is set and a pool is exhausted.
383
- - `createRegistry({ onCapacityExceeded: "grow" })` opt-in unbounded pool growth, bounded by `maxLinks * 16` ceiling.
384
- - `createRegistry({ maxFlushPasses })` configurable cycle-protection limit (default `100`).
385
- - `destroy()` full registry reset; all prior handles silently no-op afterward.
386
- - `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).
387
650
 
388
651
  ### Changed
389
652
  - 32-bit modular epoch arithmetic across `globalVersion`, `evalVersion`, `markEpoch`. Engine survives indefinite uptime without integer-overflow risk.
390
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.
391
- - `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`.
392
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.
393
656
 
394
657
  ### Fixed
@@ -404,12 +667,12 @@ correctness bug in flush error reporting.
404
667
  - Full methodology and reproducibility recipe in [`bench/README.md`](./bench/README.md).
405
668
 
406
669
  ### Known limitations
407
- - 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).
408
671
  - Computed resolution is recursive on the JS call stack; bounded by the engine stack limit (~10,000 frames).
409
672
  - `whenAsync` allocates one Promise per call. Use `when` (callback form) for per-frame paths.
410
673
 
411
674
  ### Migration from 1.0.x
412
675
  None required. Drop-in upgrade.
413
676
 
414
- ## [1.0.0] 2026-05-12
677
+ ## [1.0.0] -- 2026-05-12
415
678
  Initial public release.