@zakkster/lite-signal 1.2.1 → 1.3.0-rc

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