@zakkster/lite-signal 1.1.5 → 1.2.1

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 ADDED
@@ -0,0 +1,534 @@
1
+ # Changelog
2
+
3
+ All notable changes to `@zakkster/lite-signal` are documented here.
4
+ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
+ This project follows [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [1.2.1] — 2026-06-12
8
+
9
+ A correctness-and-pauses patch in two halves: the pool allocator stops paying
10
+ for growth in unbounded bursts, and the introspection surface stops lying about
11
+ 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.
13
+ Drop-in over 1.2.0: 404-test suite green, 177/178 on
14
+ johnsoncodehk/reactive-framework-test-suite (same single open cell, Inner
15
+ Write #179), hot-path regression gate flat on two hosts.
16
+
17
+ ### Fixed — bounded pool growth (no more construction bursts)
18
+ - Under `onCapacityExceeded: "grow"`, exhausting a pool used to double it by
19
+ synchronously constructing `currentCapacity` fresh nodes/links — at a
20
+ 524,288-node pool that is a quarter-million 25-field allocations in one
21
+ pause, in whatever frame triggered it. Growth is now incremental: **one**
22
+ node/link constructed per free-list miss, pushed into the pool, recycled
23
+ forever after. The capacity **ledger** still doubles, so `stats()`
24
+ (`nodePoolCapacity` / `linkPoolCapacity` / `pooledLinks`), the
25
+ `maxLinks × 16` ceiling, and every `CapacityError` are bit-identical to
26
+ 1.2.0 — only the construction schedule changed. Locked by the existing
27
+ `test/03-pool` capacity/ceiling/recycle contracts.
28
+ - 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).
31
+ 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
34
+ accidental prefetch, and the same mechanism produced the pathological
35
+ bursts. Bounded pauses are the right trade for real applications; the
36
+ group total still improves.
37
+ - Steady-state hot paths are untouched (update / dynamic-retracking /
38
+ effect-recycle measured flat on both benchmark hosts).
39
+
40
+ ### Fixed — effect queues / mark stack stay PACKED
41
+ - 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
43
+ HOLEY elements, a silent tax on every subsequent flush read. The queues now
44
+ grow by sequential append (packed-preserving, auto-amortised) and
45
+ `destroy()` truncates instead of null-filling to capacity.
46
+
47
+ ### Fixed — `destroy()` iterates physical pools
48
+ - `destroy()` walked `currentNodesCapacity` slots by index; with incremental
49
+ growth (and any future lazy population) the ledger can exceed the physical
50
+ pool. It now walks `nodePool.length` / `linkPool.length` and is safe on an
51
+ empty pool.
52
+
53
+ ### Fixed — stale-handle introspection (the owner-tree follow-up)
54
+ - 1.2.0's owner tree made the engine recycle pool slots **autonomously**: an
55
+ owner re-run cascade-disposes its owned observers, so holding a stale handle
56
+ stopped being a user error and became a routine occurrence. The
57
+ introspection surface — `nodeId` / `describe` / `forEachObserver` /
58
+ `forEachSource` / `hasObservers` / `observeObservers` — still resolved
59
+ `NODE_PTR` without a generation check and would happily report the
60
+ **recycled slot's new resident** (wrong id, wrong value, wrong edges)
61
+ through an old handle. All six entry points now resolve through a
62
+ gen-guarded `liveNode()` and report stale handles as `undefined` (or throw
63
+ the existing `TypeError`, for `observeObservers`) — the same ABA discipline
64
+ `read()` / `set()` / `dispose()` already had.
65
+ - `describe()` descriptors are now **gen-stamped** alongside the node
66
+ reference, so the documented "descriptors are re-walkable handles" contract
67
+ survives the guard: a fresh descriptor walks; one held across a recycle
68
+ correctly goes stale. Pinned by the existing
69
+ "forEach* descriptors carry id and are re-walkable" test.
70
+ - **Effect dispose handles are now first-class introspection handles.** On
71
+ every prior version, `effect()` returned a bare closure carrying neither
72
+ `NODE_PTR` nor `NODE_GEN` — so `describe` / `nodeId` / `forEachSource`
73
+ returned `undefined`/empty for a **live** effect handle, and
74
+ `observeObservers(effectHandle)` threw. The dispose function is now stamped
75
+ with the same symbol pair as signal/computed handles (`NODE_GEN` mirrors the
76
+ disposer's own `birthGen`, so introspection validity agrees exactly with its
77
+ stale-guard). After explicit dispose, slot recycle, or owner-cascade the
78
+ handle correctly reads stale. Measured cost: two property stores per effect
79
+ creation (~50 ns on a create/dispose churn microbench) — symmetric with
80
+ what signal/computed handles already pay, create-path only. Found by the
81
+ lite-devtools 1.1 cross-probe campaign (`track(effectHandle)` threw).
82
+ - `peek()` had the same hole: `sharedSignalPeek` / `sharedComputedPeek`
83
+ resolved the slot ungated, so a stale handle's `peek()` returned the new
84
+ 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).
87
+
88
+ ### Added — `onGraphMutation(fn)`: the graph-mutation hook
89
+ - Registry-level (and default-registry module export) debug hook, the
90
+ connection point for push-based tooling. Single nullable listener; every
91
+ 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
98
+ - Cost: **zero when unregistered** (hot-path gate flat); registered, the
99
+ 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
101
+ attached, proportional to event volume.
102
+ - **Listener contract: observe only — never throw, never mutate the graph.**
103
+ The hook fires synchronously inside mutation points; lite-devtools 1.1
104
+ multiplexes all of its consumers behind one registration, isolates their
105
+ exceptions, and unregisters when the last consumer stops (returning the
106
+ engine to the zero-cost state). `onGraphMutation` returns an unsubscribe
107
+ that restores the previously registered listener.
108
+
109
+ ### Added — owner-tree introspection: `forEachOwned` / `ownerOf`
110
+ - The 1.2.0 owner tree finally gets a (read-only, gen-guarded) window:
111
+ `forEachOwned(handle, fn)` iterates a node's owned children as standard
112
+ re-walkable descriptors; `ownerOf(handle)` returns the owner's descriptor
113
+ or `undefined` (top-level or stale). Same descriptor conventions as
114
+ `forEachObserver` / `forEachSource`; garbage input is a no-op /
115
+ `undefined`. This is what lite-devtools 1.1 builds `ownerTree()` and the
116
+ `graph({owners: true})` ownership edges on
117
+ (`capabilities().owners === true` from this release).
118
+
119
+ ### Compatibility
120
+ - No behavioural change for live handles; stale handles now read as stale
121
+ 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
123
+ detects `onGraphMutation` / `forEachOwned` at load and degrades to its
124
+ 1.0 polling behaviour on older engines.
125
+
126
+ ## [1.2.0] — 2026-06-11
127
+
128
+ A structural refactor that internally splits the engine into three named layers
129
+ (graph topology / ownership-lifecycle / propagation-execution) with a strict
130
+ 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.
132
+
133
+ ### Added — auto-disposal of nested observers (owner tree)
134
+ - An effect or computed that creates **observers** (nested `effect`/`computed`)
135
+ now owns them via an internal owner tree. When the owner re-runs or is
136
+ disposed, all owned observers are cascade-disposed before the new run starts.
137
+ This is what closes the long-standing "nested effects leak on re-run" hazard
138
+ that other engines fix with `createRoot` wrappers.
139
+ - **Plain signals are deliberately NOT owner-adopted.** Lazy-allocation
140
+ wrappers (`lite-store` allocates a key's signal on first read, `lite-form`
141
+ allocates lazy fields the same way) depend on a lazily-created signal
142
+ surviving its allocating computed's re-runs. The rule is:
143
+ *observers cascade with the owner; signals do not.* Locked in by 5 tests in
144
+ `test/15-owner-lazy-alloc.test.mjs` (the lite-store cross-wire shape) and
145
+ the new `test/19-v12-additions.test.mjs`.
146
+
147
+ ### Added — pre-batch revert (the "set X, set X back" optimisation)
148
+ - Inside a `batch(...)`, if a signal is set and then set back to its
149
+ pre-batch value (under the signal's own `equals`), the version bump is
150
+ reverted and downstream effects/computeds do **not** fire. Eliminates a
151
+ whole class of "spurious re-run from a temporary mutation" patterns common
152
+ in form state and undo/redo. Verified end-to-end (signal, computed, effect)
153
+ in `test/19-v12-additions.test.mjs`.
154
+
155
+ ### Added — multi-effect throws aggregate to `AggregateError`
156
+ - When two or more effects throw in the **same flush pass**, the engine
157
+ collects all errors and rethrows a native `AggregateError` at the
158
+ triggering `set()` / batch boundary. A single thrown error is rethrown
159
+ unwrapped (no change). Effects that don't throw still run. Cycle detection
160
+ unchanged — a flush exceeding `maxFlushPasses` (default 100) throws an
161
+ `Error` prefixed `"CycleError:"`.
162
+
163
+ ### Added — scheduler thunk caching with gen-bound ABA guard
164
+ - `effect(fn, { scheduler })` now caches the scheduler thunk on the node
165
+ itself (`node.schedulerThunk`) so repeated re-schedules reuse the same
166
+ closure (no allocation per re-schedule). The thunk holds a generation snapshot
167
+ taken at effect creation: after `dispose()` the engine bumps the node's
168
+ generation, so a stale thunk fired by an async scheduler against a recycled
169
+ pool slot is a guaranteed no-op (ABA safe).
170
+
171
+ ### Changed — internal refactor, no behavioural difference
172
+ - The engine is reorganised into three explicit layers with documented
173
+ invariants (see the file header in `Signal.js`):
174
+ - **L1 Graph topology** — `allocateLink` / `freeLink` / `severTail`. Pure
175
+ edge mechanics. Never touches `owner` / `firstOwned`.
176
+ - **L2 Ownership/lifecycle** — `createNode` / `disposeNode` / `runCleanup`.
177
+ Owns the owner tree and user cleanup. Never touches the tracking cursor.
178
+ - **L3 Propagation/execution** — `markDownstream` (cursor-free), and the
179
+ orchestrators `executeEffect` / `pullComputed` that drive the cursor
180
+ (L1) and call `runCleanup` (L2) before a re-run.
181
+ - `currentObserver` and `currentOwner` are now distinct pointers. Today they
182
+ move together (no behavioural change), but the split paves the way for
183
+ future `runWithOwner`/`createRoot` without coupling tracking and lifecycle.
184
+ - **Shared `peek` (perf).** `signal()` and `computed()` now reuse a single
185
+ `peek` function per registry instead of allocating a fresh closure per
186
+ primitive. Equivalent across registries (each registry has its own pair).
187
+ ~10–14% faster signal/computed creation on the `S:create*` micros, no
188
+ hot-path or behavioural change. Verified by 5 dedicated tests + the full
189
+ 309-strong existing suite + 30,000-write differential retracking fuzz vs
190
+ the published 1.1.5.
191
+
192
+ ### Changed — port-forward of the 1.1.3/1.1.4 perf fixes
193
+ - `pullComputed` retains the **`markEpoch` clean short-circuit** — re-reading
194
+ 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
197
+ re-read of the same source after an intervening observer can retain one
198
+ duplicate link per intervening edge, bounded by the loop count and
199
+ dispose-reclaimed.
200
+
201
+ ### Fixed — conformance regressions surfaced during release prep
202
+ - **#141 (`dispose during execution then continue: no re-run`)**: an effect that
203
+ called its own dispose handle mid-run and then continued to read another
204
+ 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
206
+ aggressively and made it visible). Fixed by nulling the tracking cursor in
207
+ `disposeNode` when the disposed node is the active observer, plus a
208
+ gen-snapshot guard in `executeEffect` / `pullComputed` so a post-body
209
+ `severTail` on a recycled slot is skipped.
210
+ - **#238 / #241 / #243 (cleanup ordering)**: nested effect cleanups must fire
211
+ inside-out on owner-tree disposal — grandchild before child before outer.
212
+ The previous `runCleanup` ran the node's OWN cleanup before cascading, which
213
+ surfaced on cascade-dispose, on owner re-run, AND on the regression path
214
+ where an inner-only re-run had fired first. Fixed by swapping the order:
215
+ cascade children first, then own. Matches React / Solid (children may rely
216
+ on parent state being live at cleanup time; never the reverse).
217
+ - Permanent regression guards for all four landed in
218
+ `test/20-axis-stress.test.mjs` under "Conformance pins" (7 new tests across
219
+ two suites; includes a BONUS test for the re-run cascade path which has the
220
+ same invariant).
221
+
222
+ ### Test suite (released numbers)
223
+ - 363 tests / 133 suites total, all passing under `node --expose-gc --test`.
224
+ - **100% line coverage** and **98.62% branch coverage** on `Signal.js`
225
+ + `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
228
+ conformance + existing user code).
229
+ - New file `test/19-v12-additions.test.mjs` (24 tests) locks in shared peek,
230
+ owner adoption rule, pre-batch revert, AggregateError aggregation,
231
+ CycleError detection, the `maxLinks` config branch, the disposed-signal
232
+ read/set behaviour, and the stop-fn ABA guard.
233
+ - New file `test/20-axis-stress.test.mjs` (23 tests) — eight orthogonal
234
+ engine-invariant "axes" plus the permanent conformance pins for #141,
235
+ #238, #241, #243.
236
+ - 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
238
+ tests pass.
239
+ - Differential retracking fuzz against the published 1.1.5: 30,000 writes,
240
+ **0 disagreements** (`bench/retracking.difftest.mjs`).
241
+
242
+ ### Notes for users
243
+ - **Drop-in.** No public surface removed. Behaviour identical to 1.1.5 except
244
+ for: (i) the owner-cascade auto-dispose of nested observers (was: leaked),
245
+ (ii) the pre-batch revert (was: always fired even if reverted), and
246
+ (iii) multi-throw aggregation. (i) and (ii) are silent wins; if you
247
+ previously caught the first thrown effect in a flush, you now get an
248
+ `AggregateError` whose `.errors[0]` is what you used to get.
249
+ - The "scheduler-thunk caching" hint that referenced an older internal
250
+ staging name (Signal-1.3.0-rc) is gone; the file is the public 1.2.0.
251
+
252
+ ## [1.1.5] — 2026-06-04
253
+
254
+ Additive release in service of `@zakkster/lite-devtools`: stable node identity on the
255
+ introspection surface, so a tool can dedupe and traverse the full reactive DAG. Drop-in
256
+ over 1.1.4, no breaking changes.
257
+
258
+ ### Added — node identity (top-level + per-registry)
259
+ - `nodeId(handle)` -> the node's stable per-allocation id (`number`), or `undefined` for a
260
+ non-handle. The dedupe key for graph walks.
261
+ - `describe(handle)` -> the handle's own `{ id, kind, value }` descriptor, or `undefined`
262
+ for a non-handle. **Re-walkable**: the descriptor may be passed back into
263
+ `forEachObserver`/`forEachSource` — the recursion primitive for full DAG discovery.
264
+ - `forEachObserver`/`forEachSource` descriptors now carry `id` (`{ id, kind, value }`).
265
+ - Every node gains a stable `id` assigned at allocation: one SMI write at creation, node
266
+ shape kept uniform (monomorphic). **Zero steady-state cost.**
267
+
268
+ ### Test suite
269
+ - Added `test/15-identity_test.mjs`: 5 tests — ids unique + stable, `nodeId`/`describe`
270
+ undefined on non-handles, descriptor shape `{ id, kind, value }`, descriptors re-walkable,
271
+ identity walks non-perturbing.
272
+
273
+ ## [1.1.4] — 2026-05-31
274
+
275
+ Combined release: a retracking rewrite that closes the two documented chaotic
276
+ 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
278
+ folds in the work that was internally staged as 1.1.4 and 1.1.5; it ships as a
279
+ single 1.1.4.)
280
+
281
+ ### Changed — performance (retracking, no semantic change)
282
+ - **Version-stamped O(1) reconciliation + clean-read short-circuit.** The cursor
283
+ reconciliation now stamps each source per evaluation and a `markEpoch` guard
284
+ short-circuits the pull when a subtree is already clean. This replaces the
285
+ prior strategy's O(N)-per-dep degradation under chaotic, high-fan-in, batched
286
+ 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.
288
+ - **Result.** The two rows that were the documented v1.1.x limitation flipped from
289
+ multiples-behind to ahead of `alien-signals`, and are now the fastest of the five
290
+ 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)
293
+ No regressions on the other rows (steady-state update, propagation, and creation
294
+ paths are within noise of 1.1.2). See `resultsReactive.txt`.
295
+ - **Correctness.** The new retracking is validated by `retracking.difftest.mjs`
296
+ against a reference reconciler: 20,000 direct writes and 10,000 batched writes,
297
+ **0 disagreements**.
298
+
299
+ ### Added — observer-lifecycle introspection (top-level + per-registry)
300
+ A small, zero-cost-when-unused surface for auto-pausing wrappers and devtools.
301
+ All accept a public `Signal`/`Computed` handle.
302
+ - **`hasObservers(handle)` → `boolean`.** O(1) (`node.headSub !== null`). The
303
+ auto-pause predicate: is anything subscribed to this source right now? A `peek`
304
+ does not count.
305
+ - **`observeObservers(handle, { onConnect?, onDisconnect? })` → `unobserve`.**
306
+ Fires `onConnect` on the 0→1 observer transition and `onDisconnect` on 1→0,
307
+ *after* registration (transition-only — no immediate fire if the handle is
308
+ already observed). Re-tracking a persistently-read source does **not** churn
309
+ connect/disconnect. This is the hook `lite-time` / `lite-raf` use to start a
310
+ ticker only while a derived value is being watched.
311
+ - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`.** Walk the live
312
+ graph in either direction; `fn` receives a `{ kind, value }` descriptor where
313
+ `kind` is `"signal" | "computed" | "effect"`. For graph inspection (lite-devtools).
314
+ - **Cost.** The hooks sit behind an internal lifecycle counter — when no handle is
315
+ being observed, the hot path adds a single branch-predicted `count !== 0` check
316
+ inside link alloc/free and nothing else. Zero steady-state cost when unused.
317
+ - **Error contract.** `hasObservers` / `forEachObserver` / `forEachSource` no-op
318
+ on a non-handle argument; `observeObservers` throws `TypeError`.
319
+
320
+ ### Test suite
321
+ - Added `test/13-introspection_test.mjs`: 10 tests across 3 describe blocks —
322
+ `hasObservers` (live observation reflects, peek doesn't count), `observeObservers`
323
+ auto-pause lifecycle (start-on-first/stop-on-last, no extra connect for a 2nd
324
+ observer, re-observe fires again, no churn on re-track, conditional reads toggle
325
+ honestly, transition-only registration, works for computeds), and
326
+ `forEachObserver`/`forEachSource` enumeration (both directions, descriptor carries
327
+ kind + value).
328
+
329
+ ### Migration from 1.1.3
330
+ None required. Drop-in upgrade. No existing surface or behavior changed; the
331
+ introspection functions are purely additive and the retracking change is internal.
332
+
333
+ ## [1.1.3] — 2026-05-28
334
+
335
+ Patch release: one new export, no behavior changes, no engine changes — drop-in
336
+ over 1.1.2.
337
+
338
+ ### Added
339
+ - **`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
341
+ stack AND tracking is enabled. Returns `false` inside `untrack()`, inside the
342
+ callback of `signal.subscribe` (which inlines the same untracked-notify), inside
343
+ `onCleanup` bodies, inside the `watch` / `when` callback path, and outside any
344
+ observer. The predicate mirrors the engine's own read-trap check
345
+ (`isTrackingDeps && currentObserver !== null`) so callers stay in lockstep with
346
+ what the engine actually does on a read, not just whether an observer is on the
347
+ stack.
348
+
349
+ ### Why
350
+ Wrapper libraries (lite-store, lite-query, lite-form) need to allocate reactive
351
+ primitives lazily on property reads to preserve the zero-GC contract. Without a
352
+ predicate they must either always allocate (defeats the point) or inspect engine
353
+ internals (fragile coupling). `isTracking()` is the first-class way to gate
354
+ allocation on whether the read will actually subscribe anything.
355
+
356
+ ### API notes
357
+ - **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
359
+ own tracking state. The top-level helper delegates to the default registry,
360
+ matching the existing pattern for `signal`/`computed`/`effect`/`untrack`.
361
+ - **Cost.** Two closure-variable loads, one AND, one return; V8 inlines it.
362
+ Roughly 1–2 ns per call.
363
+
364
+ ### Test suite
365
+ - Added `test/10-is-tracking_test.mjs`: 11 tests across 5 describe blocks —
366
+ observer-bodies (effect + computed), untracked windows (`untrack`, `subscribe`
367
+ callback, `onCleanup`, `watch` callback), outside-observer (module scope,
368
+ call-site of unobserved computed read), robustness (state restored after
369
+ observer body throws, per-registry isolation), and the top-level binding.
370
+
371
+ ### Migration from 1.1.2
372
+ None required. Drop-in upgrade. No existing surface or behavior changed.
373
+
374
+ ## [1.1.2] — 2026-05-26
375
+
376
+ 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.
378
+
379
+ ### Changed — performance (no semantic change)
380
+ - **Inlined cursor fast-path in `signal()`/`computed()` reads.** On stable read
381
+ order the cursor match is now handled inline; only a cursor *miss* falls
382
+ through into the (large, non-inlinable) `allocateLink` frame. Removes a
383
+ function call from the steady-state read hot path.
384
+ - **Allocation-free creation.** `signal`/`computed`/`effect` now read their
385
+ `opts` argument defensively instead of defaulting the parameter to `{}`. The
386
+ `= {}` default allocated a throwaway object on every no-opts call — the common
387
+ path when mounting many cells. Creation is now zero-allocation on that path.
388
+ - **Single-closure `subscribe`.** The tracked read + untracked notify is inlined
389
+ (one closure instead of two), dropping a per-subscription closure and an
390
+ `untrack` wrapper call on every fire.
391
+ - **`markDownstream` micro-cleanup.** Combined `(FLAG_QUEUED | FLAG_COMPUTING)`
392
+ test and tightened stack/queue index arithmetic. The `flags` read stays inside
393
+ the `markEpoch` dedup guard on purpose (hoisting it would add work on the
394
+ already-marked revisit path that the guard exists to keep cheap).
395
+
396
+ ### Changed — packaging
397
+ - Canonical single-engine layout: the implementation is `Signal.js` and the
398
+ watcher utilities are `Watch.js`, which imports `effect`/`untrack` from
399
+ `./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
401
+ would silently break cross-module dependency tracking.
402
+
403
+ ### Test suite
404
+ - `tests/09-conformance.test.mjs`: the owner-tree conformance items **#209** and
405
+ **#210** (three-level cascading disposal; inner-effect cleanup on outer re-run)
406
+ are marked skipped with a v1.2 pointer. The baseline engine maintains no owner
407
+ tree; these are validated against the v1.2 ownership hybrid. All other
408
+ conformance items pass.
409
+
410
+ ### Performance
411
+ - Steady-state hot path remains **0 allocations** (`signal.set`, `peek`, computed
412
+ read, effect re-run, dispose). Creation path now also 0-allocation on the
413
+ no-opts common case. Re-run `npm run bench` on your target host for current
414
+ ops/s; the 1.1.1 numbers stand as a floor.
415
+
416
+ ### Migration from 1.1.x
417
+ None required. Drop-in upgrade.
418
+
419
+ ## [1.1.1] — 2026-05-22
420
+
421
+ Patch release: cleanup-semantics adapter integration, conformance fixes from
422
+ the `johnsoncodehk/reactive-framework-test-suite`, and one targeted
423
+ correctness bug in flush error reporting.
424
+
425
+ ### Added
426
+ - Top-level `destroy()` export. Wipes the default registry; intended for
427
+ test-suite isolation only. Previously the function existed but was not
428
+ re-exported from the package entrypoint, breaking any adapter that
429
+ destructure-imports it.
430
+ - `tailSub` field on `ReactiveNode`. Symmetric with the existing `tailDep`;
431
+ enables O(1) tail insertion into the subscriber list.
432
+
433
+ ### Changed — conformance fixes
434
+
435
+ - **#216** Effects now fire in **creation order** on a shared signal.
436
+ Subscriber list insertion is tail-first instead of head-first; traversal
437
+ order in `markDownstream` is unchanged. Brings lite-signal in line with
438
+ every other library in the suite except solid-js and pota.
439
+
440
+ - **#178** `runCleanup` invokes registered cleanups in an **untracked
441
+ 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
444
+ observer's dep set.
445
+
446
+ - **#111** `executeEffect` bails cleanly when a node is disposed by its own
447
+ cleanup. Previously the post-cleanup body invocation hit `undefined()` on
448
+ the cleared `computeFn`.
449
+
450
+ - **#123 / #132 / #147** **Revert detection in batches.** A signal whose
451
+ in-batch write sequence ends at the pre-batch value (per its `equals`
452
+ predicate) restores its `version` and skips propagation. Captures are
453
+ scoped per top-level batch via a `revertEpoch` counter; the `0` sentinel
454
+ is preserved through SMI wraparound by skipping it on increment.
455
+
456
+ - **#121** **Throw isolation in flush.** Effects that throw during
457
+ `flushEffects` no longer halt the flush. Errors are collected in a
458
+ reused per-registry buffer; on flush completion, a single thrown error
459
+ re-raises as-is, multiple throws raise as `AggregateError`. `isFlushing`
460
+ is now cleared in a `try/finally`, eliminating the registry-deadlock
461
+ that the prior throw-out path would leave behind.
462
+
463
+ - **#180 / #213** **No-re-run semantics for self-cycles.** An effect that
464
+ is currently executing on the call stack is no longer re-queued by
465
+ `markDownstream` when its own body's writes propagate back through a
466
+ computed chain. Matches S.js / pre-2.0 Solid. Sibling effects on the
467
+ same chain continue to fire normally.
468
+
469
+ ### Fixed
470
+ - Flush error buffer no longer leaks across calls when a `CycleError`
471
+ escapes the flush loop. Buffered effect errors are cleared in the
472
+ outer `finally` if the flush is exiting abnormally.
473
+
474
+ ### Performance
475
+ - No regressions observed in MUX, BROADCAST, DEEP CHAIN, KAIROS, or
476
+ SELECTIVE DAG benchmarks. MUX moved from 156K to 226K ops/s — V8 appears
477
+ to optimise the flush loop more aggressively now that the per-iteration
478
+ `try/catch` shape is stable. Out-of-batch `signal.set` is unchanged
479
+ (revert-detection guards short-circuit on `batchDepth === 0`).
480
+
481
+ ### Conformance score
482
+ - Before 1.1.1: 145 / 156 (with v1.1.0 adapter pre-fix), 164 / 177
483
+ (corrected adapter, no library fixes).
484
+ - After 1.1.1: TBD pending full conformance re-run. Expected: all
485
+ Tier 1 + Tier 2 items closed (#216, #178, #111, #123, #132, #147, #121,
486
+ #180, #213, #235), leaving `#179`, `#209`, `#210` deferred to v1.2
487
+ (owner-tree / computed-self-write).
488
+
489
+ ### Internal test suite
490
+ - Added `tests/09-conformance.test.mjs` collecting the upstream test IDs
491
+ by number, with companion tests pinning the design decisions
492
+ (sibling-effect propagation under no-re-run, cycle precedence over
493
+ buffered errors, custom-equals revert, etc.).
494
+
495
+ ## [1.1.0] — 2026-05-20
496
+
497
+ ### 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.
501
+ - `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).
506
+
507
+ ### Changed
508
+ - 32-bit modular epoch arithmetic across `globalVersion`, `evalVersion`, `markEpoch`. Engine survives indefinite uptime without integer-overflow risk.
509
+ - `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`.
511
+ - `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
+
513
+ ### Fixed
514
+ - Diamond dependency reads no longer over-fire effects (versioned pull resolves convergence cleanly in one pass).
515
+ - Effect re-runs no longer leak link slots when the dep set shrinks (tail-link severance in `severTail`).
516
+ - Disposed-then-recycled slots no longer mis-dispose under stale handles (generation guard in `dispose`).
517
+ - Cleanup functions registered inside computeds now fire (previously effect-only).
518
+
519
+ ### Performance
520
+ - Steady-state hot path: **0 allocations** across `signal.set`, `signal.peek`, computed read, effect re-run, dispose.
521
+ - **249K ops/s** on MUX fan-in (Node 22, 2016 MacBook Pro). +20% vs alien-signals on identical workload.
522
+ - **15 KB** transient heap across 20,000 iterations.
523
+ - Full methodology and reproducibility recipe in [`bench/README.md`](./bench/README.md).
524
+
525
+ ### 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).
527
+ - Computed resolution is recursive on the JS call stack; bounded by the engine stack limit (~10,000 frames).
528
+ - `whenAsync` allocates one Promise per call. Use `when` (callback form) for per-frame paths.
529
+
530
+ ### Migration from 1.0.x
531
+ None required. Drop-in upgrade.
532
+
533
+ ## [1.0.0] — 2026-05-12
534
+ Initial public release.