@zakkster/lite-signal 1.1.5 → 1.2.0

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,415 @@
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.0] — 2026-06-11
8
+
9
+ A structural refactor that internally splits the engine into three named layers
10
+ (graph topology / ownership-lifecycle / propagation-execution) with a strict
11
+ 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.
13
+
14
+ ### Added — auto-disposal of nested observers (owner tree)
15
+ - An effect or computed that creates **observers** (nested `effect`/`computed`)
16
+ now owns them via an internal owner tree. When the owner re-runs or is
17
+ disposed, all owned observers are cascade-disposed before the new run starts.
18
+ This is what closes the long-standing "nested effects leak on re-run" hazard
19
+ that other engines fix with `createRoot` wrappers.
20
+ - **Plain signals are deliberately NOT owner-adopted.** Lazy-allocation
21
+ wrappers (`lite-store` allocates a key's signal on first read, `lite-form`
22
+ allocates lazy fields the same way) depend on a lazily-created signal
23
+ surviving its allocating computed's re-runs. The rule is:
24
+ *observers cascade with the owner; signals do not.* Locked in by 5 tests in
25
+ `test/15-owner-lazy-alloc.test.mjs` (the lite-store cross-wire shape) and
26
+ the new `test/19-v12-additions.test.mjs`.
27
+
28
+ ### Added — pre-batch revert (the "set X, set X back" optimisation)
29
+ - Inside a `batch(...)`, if a signal is set and then set back to its
30
+ pre-batch value (under the signal's own `equals`), the version bump is
31
+ reverted and downstream effects/computeds do **not** fire. Eliminates a
32
+ whole class of "spurious re-run from a temporary mutation" patterns common
33
+ in form state and undo/redo. Verified end-to-end (signal, computed, effect)
34
+ in `test/19-v12-additions.test.mjs`.
35
+
36
+ ### Added — multi-effect throws aggregate to `AggregateError`
37
+ - When two or more effects throw in the **same flush pass**, the engine
38
+ collects all errors and rethrows a native `AggregateError` at the
39
+ triggering `set()` / batch boundary. A single thrown error is rethrown
40
+ unwrapped (no change). Effects that don't throw still run. Cycle detection
41
+ unchanged — a flush exceeding `maxFlushPasses` (default 100) throws an
42
+ `Error` prefixed `"CycleError:"`.
43
+
44
+ ### Added — scheduler thunk caching with gen-bound ABA guard
45
+ - `effect(fn, { scheduler })` now caches the scheduler thunk on the node
46
+ itself (`node.schedulerThunk`) so repeated re-schedules reuse the same
47
+ closure (no allocation per re-schedule). The thunk holds a generation snapshot
48
+ taken at effect creation: after `dispose()` the engine bumps the node's
49
+ generation, so a stale thunk fired by an async scheduler against a recycled
50
+ pool slot is a guaranteed no-op (ABA safe).
51
+
52
+ ### Changed — internal refactor, no behavioural difference
53
+ - The engine is reorganised into three explicit layers with documented
54
+ invariants (see the file header in `Signal.js`):
55
+ - **L1 Graph topology** — `allocateLink` / `freeLink` / `severTail`. Pure
56
+ edge mechanics. Never touches `owner` / `firstOwned`.
57
+ - **L2 Ownership/lifecycle** — `createNode` / `disposeNode` / `runCleanup`.
58
+ Owns the owner tree and user cleanup. Never touches the tracking cursor.
59
+ - **L3 Propagation/execution** — `markDownstream` (cursor-free), and the
60
+ orchestrators `executeEffect` / `pullComputed` that drive the cursor
61
+ (L1) and call `runCleanup` (L2) before a re-run.
62
+ - `currentObserver` and `currentOwner` are now distinct pointers. Today they
63
+ move together (no behavioural change), but the split paves the way for
64
+ future `runWithOwner`/`createRoot` without coupling tracking and lifecycle.
65
+ - **Shared `peek` (perf).** `signal()` and `computed()` now reuse a single
66
+ `peek` function per registry instead of allocating a fresh closure per
67
+ primitive. Equivalent across registries (each registry has its own pair).
68
+ ~10–14% faster signal/computed creation on the `S:create*` micros, no
69
+ hot-path or behavioural change. Verified by 5 dedicated tests + the full
70
+ 309-strong existing suite + 30,000-write differential retracking fuzz vs
71
+ the published 1.1.5.
72
+
73
+ ### Changed — port-forward of the 1.1.3/1.1.4 perf fixes
74
+ - `pullComputed` retains the **`markEpoch` clean short-circuit** — re-reading
75
+ 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
78
+ re-read of the same source after an intervening observer can retain one
79
+ duplicate link per intervening edge, bounded by the loop count and
80
+ dispose-reclaimed.
81
+
82
+ ### Fixed — conformance regressions surfaced during release prep
83
+ - **#141 (`dispose during execution then continue: no re-run`)**: an effect that
84
+ called its own dispose handle mid-run and then continued to read another
85
+ 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
87
+ aggressively and made it visible). Fixed by nulling the tracking cursor in
88
+ `disposeNode` when the disposed node is the active observer, plus a
89
+ gen-snapshot guard in `executeEffect` / `pullComputed` so a post-body
90
+ `severTail` on a recycled slot is skipped.
91
+ - **#238 / #241 / #243 (cleanup ordering)**: nested effect cleanups must fire
92
+ inside-out on owner-tree disposal — grandchild before child before outer.
93
+ The previous `runCleanup` ran the node's OWN cleanup before cascading, which
94
+ surfaced on cascade-dispose, on owner re-run, AND on the regression path
95
+ where an inner-only re-run had fired first. Fixed by swapping the order:
96
+ cascade children first, then own. Matches React / Solid (children may rely
97
+ on parent state being live at cleanup time; never the reverse).
98
+ - Permanent regression guards for all four landed in
99
+ `test/20-axis-stress.test.mjs` under "Conformance pins" (7 new tests across
100
+ two suites; includes a BONUS test for the re-run cascade path which has the
101
+ same invariant).
102
+
103
+ ### Test suite (released numbers)
104
+ - 363 tests / 133 suites total, all passing under `node --expose-gc --test`.
105
+ - **100% line coverage** and **98.62% branch coverage** on `Signal.js`
106
+ + `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
109
+ conformance + existing user code).
110
+ - New file `test/19-v12-additions.test.mjs` (24 tests) locks in shared peek,
111
+ owner adoption rule, pre-batch revert, AggregateError aggregation,
112
+ CycleError detection, the `maxLinks` config branch, the disposed-signal
113
+ read/set behaviour, and the stop-fn ABA guard.
114
+ - New file `test/20-axis-stress.test.mjs` (23 tests) — eight orthogonal
115
+ engine-invariant "axes" plus the permanent conformance pins for #141,
116
+ #238, #241, #243.
117
+ - 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
119
+ tests pass.
120
+ - Differential retracking fuzz against the published 1.1.5: 30,000 writes,
121
+ **0 disagreements** (`bench/retracking.difftest.mjs`).
122
+
123
+ ### Notes for users
124
+ - **Drop-in.** No public surface removed. Behaviour identical to 1.1.5 except
125
+ for: (i) the owner-cascade auto-dispose of nested observers (was: leaked),
126
+ (ii) the pre-batch revert (was: always fired even if reverted), and
127
+ (iii) multi-throw aggregation. (i) and (ii) are silent wins; if you
128
+ previously caught the first thrown effect in a flush, you now get an
129
+ `AggregateError` whose `.errors[0]` is what you used to get.
130
+ - The "scheduler-thunk caching" hint that referenced an older internal
131
+ staging name (Signal-1.3.0-rc) is gone; the file is the public 1.2.0.
132
+
133
+ ## [1.1.5] — 2026-06-04
134
+
135
+ Additive release in service of `@zakkster/lite-devtools`: stable node identity on the
136
+ introspection surface, so a tool can dedupe and traverse the full reactive DAG. Drop-in
137
+ over 1.1.4, no breaking changes.
138
+
139
+ ### Added — node identity (top-level + per-registry)
140
+ - `nodeId(handle)` -> the node's stable per-allocation id (`number`), or `undefined` for a
141
+ non-handle. The dedupe key for graph walks.
142
+ - `describe(handle)` -> the handle's own `{ id, kind, value }` descriptor, or `undefined`
143
+ for a non-handle. **Re-walkable**: the descriptor may be passed back into
144
+ `forEachObserver`/`forEachSource` — the recursion primitive for full DAG discovery.
145
+ - `forEachObserver`/`forEachSource` descriptors now carry `id` (`{ id, kind, value }`).
146
+ - Every node gains a stable `id` assigned at allocation: one SMI write at creation, node
147
+ shape kept uniform (monomorphic). **Zero steady-state cost.**
148
+
149
+ ### Test suite
150
+ - Added `test/15-identity_test.mjs`: 5 tests — ids unique + stable, `nodeId`/`describe`
151
+ undefined on non-handles, descriptor shape `{ id, kind, value }`, descriptors re-walkable,
152
+ identity walks non-perturbing.
153
+
154
+ ## [1.1.4] — 2026-05-31
155
+
156
+ Combined release: a retracking rewrite that closes the two documented chaotic
157
+ 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
159
+ folds in the work that was internally staged as 1.1.4 and 1.1.5; it ships as a
160
+ single 1.1.4.)
161
+
162
+ ### Changed — performance (retracking, no semantic change)
163
+ - **Version-stamped O(1) reconciliation + clean-read short-circuit.** The cursor
164
+ reconciliation now stamps each source per evaluation and a `markEpoch` guard
165
+ short-circuits the pull when a subtree is already clean. This replaces the
166
+ prior strategy's O(N)-per-dep degradation under chaotic, high-fan-in, batched
167
+ 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.
169
+ - **Result.** The two rows that were the documented v1.1.x limitation flipped from
170
+ multiples-behind to ahead of `alien-signals`, and are now the fastest of the five
171
+ 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)
174
+ No regressions on the other rows (steady-state update, propagation, and creation
175
+ paths are within noise of 1.1.2). See `resultsReactive.txt`.
176
+ - **Correctness.** The new retracking is validated by `retracking.difftest.mjs`
177
+ against a reference reconciler: 20,000 direct writes and 10,000 batched writes,
178
+ **0 disagreements**.
179
+
180
+ ### Added — observer-lifecycle introspection (top-level + per-registry)
181
+ A small, zero-cost-when-unused surface for auto-pausing wrappers and devtools.
182
+ All accept a public `Signal`/`Computed` handle.
183
+ - **`hasObservers(handle)` → `boolean`.** O(1) (`node.headSub !== null`). The
184
+ auto-pause predicate: is anything subscribed to this source right now? A `peek`
185
+ does not count.
186
+ - **`observeObservers(handle, { onConnect?, onDisconnect? })` → `unobserve`.**
187
+ Fires `onConnect` on the 0→1 observer transition and `onDisconnect` on 1→0,
188
+ *after* registration (transition-only — no immediate fire if the handle is
189
+ already observed). Re-tracking a persistently-read source does **not** churn
190
+ connect/disconnect. This is the hook `lite-time` / `lite-raf` use to start a
191
+ ticker only while a derived value is being watched.
192
+ - **`forEachObserver(handle, fn)` / `forEachSource(handle, fn)`.** Walk the live
193
+ graph in either direction; `fn` receives a `{ kind, value }` descriptor where
194
+ `kind` is `"signal" | "computed" | "effect"`. For graph inspection (lite-devtools).
195
+ - **Cost.** The hooks sit behind an internal lifecycle counter — when no handle is
196
+ being observed, the hot path adds a single branch-predicted `count !== 0` check
197
+ inside link alloc/free and nothing else. Zero steady-state cost when unused.
198
+ - **Error contract.** `hasObservers` / `forEachObserver` / `forEachSource` no-op
199
+ on a non-handle argument; `observeObservers` throws `TypeError`.
200
+
201
+ ### Test suite
202
+ - Added `test/13-introspection_test.mjs`: 10 tests across 3 describe blocks —
203
+ `hasObservers` (live observation reflects, peek doesn't count), `observeObservers`
204
+ auto-pause lifecycle (start-on-first/stop-on-last, no extra connect for a 2nd
205
+ observer, re-observe fires again, no churn on re-track, conditional reads toggle
206
+ honestly, transition-only registration, works for computeds), and
207
+ `forEachObserver`/`forEachSource` enumeration (both directions, descriptor carries
208
+ kind + value).
209
+
210
+ ### Migration from 1.1.3
211
+ None required. Drop-in upgrade. No existing surface or behavior changed; the
212
+ introspection functions are purely additive and the retracking change is internal.
213
+
214
+ ## [1.1.3] — 2026-05-28
215
+
216
+ Patch release: one new export, no behavior changes, no engine changes — drop-in
217
+ over 1.1.2.
218
+
219
+ ### Added
220
+ - **`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
222
+ stack AND tracking is enabled. Returns `false` inside `untrack()`, inside the
223
+ callback of `signal.subscribe` (which inlines the same untracked-notify), inside
224
+ `onCleanup` bodies, inside the `watch` / `when` callback path, and outside any
225
+ observer. The predicate mirrors the engine's own read-trap check
226
+ (`isTrackingDeps && currentObserver !== null`) so callers stay in lockstep with
227
+ what the engine actually does on a read, not just whether an observer is on the
228
+ stack.
229
+
230
+ ### Why
231
+ Wrapper libraries (lite-store, lite-query, lite-form) need to allocate reactive
232
+ primitives lazily on property reads to preserve the zero-GC contract. Without a
233
+ predicate they must either always allocate (defeats the point) or inspect engine
234
+ internals (fragile coupling). `isTracking()` is the first-class way to gate
235
+ allocation on whether the read will actually subscribe anything.
236
+
237
+ ### API notes
238
+ - **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
240
+ own tracking state. The top-level helper delegates to the default registry,
241
+ matching the existing pattern for `signal`/`computed`/`effect`/`untrack`.
242
+ - **Cost.** Two closure-variable loads, one AND, one return; V8 inlines it.
243
+ Roughly 1–2 ns per call.
244
+
245
+ ### Test suite
246
+ - Added `test/10-is-tracking_test.mjs`: 11 tests across 5 describe blocks —
247
+ observer-bodies (effect + computed), untracked windows (`untrack`, `subscribe`
248
+ callback, `onCleanup`, `watch` callback), outside-observer (module scope,
249
+ call-site of unobserved computed read), robustness (state restored after
250
+ observer body throws, per-registry isolation), and the top-level binding.
251
+
252
+ ### Migration from 1.1.2
253
+ None required. Drop-in upgrade. No existing surface or behavior changed.
254
+
255
+ ## [1.1.2] — 2026-05-26
256
+
257
+ 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.
259
+
260
+ ### Changed — performance (no semantic change)
261
+ - **Inlined cursor fast-path in `signal()`/`computed()` reads.** On stable read
262
+ order the cursor match is now handled inline; only a cursor *miss* falls
263
+ through into the (large, non-inlinable) `allocateLink` frame. Removes a
264
+ function call from the steady-state read hot path.
265
+ - **Allocation-free creation.** `signal`/`computed`/`effect` now read their
266
+ `opts` argument defensively instead of defaulting the parameter to `{}`. The
267
+ `= {}` default allocated a throwaway object on every no-opts call — the common
268
+ path when mounting many cells. Creation is now zero-allocation on that path.
269
+ - **Single-closure `subscribe`.** The tracked read + untracked notify is inlined
270
+ (one closure instead of two), dropping a per-subscription closure and an
271
+ `untrack` wrapper call on every fire.
272
+ - **`markDownstream` micro-cleanup.** Combined `(FLAG_QUEUED | FLAG_COMPUTING)`
273
+ test and tightened stack/queue index arithmetic. The `flags` read stays inside
274
+ the `markEpoch` dedup guard on purpose (hoisting it would add work on the
275
+ already-marked revisit path that the guard exists to keep cheap).
276
+
277
+ ### Changed — packaging
278
+ - Canonical single-engine layout: the implementation is `Signal.js` and the
279
+ watcher utilities are `Watch.js`, which imports `effect`/`untrack` from
280
+ `./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
282
+ would silently break cross-module dependency tracking.
283
+
284
+ ### Test suite
285
+ - `tests/09-conformance.test.mjs`: the owner-tree conformance items **#209** and
286
+ **#210** (three-level cascading disposal; inner-effect cleanup on outer re-run)
287
+ are marked skipped with a v1.2 pointer. The baseline engine maintains no owner
288
+ tree; these are validated against the v1.2 ownership hybrid. All other
289
+ conformance items pass.
290
+
291
+ ### Performance
292
+ - Steady-state hot path remains **0 allocations** (`signal.set`, `peek`, computed
293
+ read, effect re-run, dispose). Creation path now also 0-allocation on the
294
+ no-opts common case. Re-run `npm run bench` on your target host for current
295
+ ops/s; the 1.1.1 numbers stand as a floor.
296
+
297
+ ### Migration from 1.1.x
298
+ None required. Drop-in upgrade.
299
+
300
+ ## [1.1.1] — 2026-05-22
301
+
302
+ Patch release: cleanup-semantics adapter integration, conformance fixes from
303
+ the `johnsoncodehk/reactive-framework-test-suite`, and one targeted
304
+ correctness bug in flush error reporting.
305
+
306
+ ### Added
307
+ - Top-level `destroy()` export. Wipes the default registry; intended for
308
+ test-suite isolation only. Previously the function existed but was not
309
+ re-exported from the package entrypoint, breaking any adapter that
310
+ destructure-imports it.
311
+ - `tailSub` field on `ReactiveNode`. Symmetric with the existing `tailDep`;
312
+ enables O(1) tail insertion into the subscriber list.
313
+
314
+ ### Changed — conformance fixes
315
+
316
+ - **#216** Effects now fire in **creation order** on a shared signal.
317
+ Subscriber list insertion is tail-first instead of head-first; traversal
318
+ order in `markDownstream` is unchanged. Brings lite-signal in line with
319
+ every other library in the suite except solid-js and pota.
320
+
321
+ - **#178** `runCleanup` invokes registered cleanups in an **untracked
322
+ 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
325
+ observer's dep set.
326
+
327
+ - **#111** `executeEffect` bails cleanly when a node is disposed by its own
328
+ cleanup. Previously the post-cleanup body invocation hit `undefined()` on
329
+ the cleared `computeFn`.
330
+
331
+ - **#123 / #132 / #147** **Revert detection in batches.** A signal whose
332
+ in-batch write sequence ends at the pre-batch value (per its `equals`
333
+ predicate) restores its `version` and skips propagation. Captures are
334
+ scoped per top-level batch via a `revertEpoch` counter; the `0` sentinel
335
+ is preserved through SMI wraparound by skipping it on increment.
336
+
337
+ - **#121** **Throw isolation in flush.** Effects that throw during
338
+ `flushEffects` no longer halt the flush. Errors are collected in a
339
+ reused per-registry buffer; on flush completion, a single thrown error
340
+ re-raises as-is, multiple throws raise as `AggregateError`. `isFlushing`
341
+ is now cleared in a `try/finally`, eliminating the registry-deadlock
342
+ that the prior throw-out path would leave behind.
343
+
344
+ - **#180 / #213** **No-re-run semantics for self-cycles.** An effect that
345
+ is currently executing on the call stack is no longer re-queued by
346
+ `markDownstream` when its own body's writes propagate back through a
347
+ computed chain. Matches S.js / pre-2.0 Solid. Sibling effects on the
348
+ same chain continue to fire normally.
349
+
350
+ ### Fixed
351
+ - Flush error buffer no longer leaks across calls when a `CycleError`
352
+ escapes the flush loop. Buffered effect errors are cleared in the
353
+ outer `finally` if the flush is exiting abnormally.
354
+
355
+ ### Performance
356
+ - No regressions observed in MUX, BROADCAST, DEEP CHAIN, KAIROS, or
357
+ SELECTIVE DAG benchmarks. MUX moved from 156K to 226K ops/s — V8 appears
358
+ to optimise the flush loop more aggressively now that the per-iteration
359
+ `try/catch` shape is stable. Out-of-batch `signal.set` is unchanged
360
+ (revert-detection guards short-circuit on `batchDepth === 0`).
361
+
362
+ ### Conformance score
363
+ - Before 1.1.1: 145 / 156 (with v1.1.0 adapter pre-fix), 164 / 177
364
+ (corrected adapter, no library fixes).
365
+ - After 1.1.1: TBD pending full conformance re-run. Expected: all
366
+ Tier 1 + Tier 2 items closed (#216, #178, #111, #123, #132, #147, #121,
367
+ #180, #213, #235), leaving `#179`, `#209`, `#210` deferred to v1.2
368
+ (owner-tree / computed-self-write).
369
+
370
+ ### Internal test suite
371
+ - Added `tests/09-conformance.test.mjs` collecting the upstream test IDs
372
+ by number, with companion tests pinning the design decisions
373
+ (sibling-effect propagation under no-re-run, cycle precedence over
374
+ buffered errors, custom-equals revert, etc.).
375
+
376
+ ## [1.1.0] — 2026-05-20
377
+
378
+ ### 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.
382
+ - `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).
387
+
388
+ ### Changed
389
+ - 32-bit modular epoch arithmetic across `globalVersion`, `evalVersion`, `markEpoch`. Engine survives indefinite uptime without integer-overflow risk.
390
+ - `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`.
392
+ - `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
+
394
+ ### Fixed
395
+ - Diamond dependency reads no longer over-fire effects (versioned pull resolves convergence cleanly in one pass).
396
+ - Effect re-runs no longer leak link slots when the dep set shrinks (tail-link severance in `severTail`).
397
+ - Disposed-then-recycled slots no longer mis-dispose under stale handles (generation guard in `dispose`).
398
+ - Cleanup functions registered inside computeds now fire (previously effect-only).
399
+
400
+ ### Performance
401
+ - Steady-state hot path: **0 allocations** across `signal.set`, `signal.peek`, computed read, effect re-run, dispose.
402
+ - **249K ops/s** on MUX fan-in (Node 22, 2016 MacBook Pro). +20% vs alien-signals on identical workload.
403
+ - **15 KB** transient heap across 20,000 iterations.
404
+ - Full methodology and reproducibility recipe in [`bench/README.md`](./bench/README.md).
405
+
406
+ ### 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).
408
+ - Computed resolution is recursive on the JS call stack; bounded by the engine stack limit (~10,000 frames).
409
+ - `whenAsync` allocates one Promise per call. Use `when` (callback form) for per-frame paths.
410
+
411
+ ### Migration from 1.0.x
412
+ None required. Drop-in upgrade.
413
+
414
+ ## [1.0.0] — 2026-05-12
415
+ Initial public release.