@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 +344 -99
- package/README.md +227 -188
- package/Signal.d.ts +49 -30
- package/Signal.js +309 -158
- package/Watch.js +13 -13
- package/llms.txt +176 -78
- package/package.json +3 -2
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.
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
30
|
-
cases roughly halved (`1to2` 112
|
|
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
|
|
33
|
-
their construction inside the measured window
|
|
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
|
|
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`
|
|
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
|
|
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
|
|
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
|
|
58
|
-
`forEachSource` / `hasObservers` / `observeObservers`
|
|
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`)
|
|
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`
|
|
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)
|
|
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
|
|
86
|
-
Measured cost: 4M peeks 7.1
|
|
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
|
|
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
|
|
93
|
-
- `1` node create
|
|
94
|
-
- `2` node dispose
|
|
95
|
-
- `3` link add
|
|
96
|
-
- `4` link remove
|
|
97
|
-
- `5` recompute
|
|
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)
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
376
|
+
that split. No behavioural changes for existing code -- drop-in over 1.1.5.
|
|
132
377
|
|
|
133
|
-
### Added
|
|
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
|
|
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
|
|
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
|
|
405
|
+
unchanged -- a flush exceeding `maxFlushPasses` (default 100) throws an
|
|
161
406
|
`Error` prefixed `"CycleError:"`.
|
|
162
407
|
|
|
163
|
-
### Added
|
|
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
|
|
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**
|
|
419
|
+
- **L1 Graph topology** -- `allocateLink` / `freeLink` / `severTail`. Pure
|
|
175
420
|
edge mechanics. Never touches `owner` / `firstOwned`.
|
|
176
|
-
- **L2 Ownership/lifecycle**
|
|
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**
|
|
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
|
-
~10
|
|
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
|
|
193
|
-
- `pullComputed` retains the **`markEpoch` clean short-circuit**
|
|
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**
|
|
196
|
-
remains O(N), not O(N
|
|
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
|
|
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
|
|
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
|
|
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
|
|
227
|
-
`gen` branches added by the conformance fixes
|
|
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)
|
|
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
|
|
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]
|
|
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
|
|
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`
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
292
|
-
- `dyn: wide dense` 5115ms
|
|
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
|
|
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)`
|
|
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? })`
|
|
306
|
-
Fires `onConnect` on the 0
|
|
307
|
-
*after* registration (transition-only
|
|
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
|
|
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]
|
|
578
|
+
## [1.1.3] -- 2026-05-28
|
|
334
579
|
|
|
335
|
-
Patch release: one new export, no behavior changes, no engine changes
|
|
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
|
|
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
|
|
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 1
|
|
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]
|
|
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
|
|
622
|
+
the creation path. No behavior changes, no API changes -- drop-in over 1.1.1.
|
|
378
623
|
|
|
379
|
-
### Changed
|
|
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
|
|
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
|
|
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
|
|
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]
|
|
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
|
|
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
|
|
443
|
-
`dispose()` from a containing effect
|
|
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
|
|
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]
|
|
740
|
+
## [1.1.0] -- 2026-05-20
|
|
496
741
|
|
|
497
742
|
### Added
|
|
498
|
-
- `markDownstream` iterative DFS marker backed by preallocated `markStack`
|
|
499
|
-
- Double-buffered effect queue (`effectQueueA` / `effectQueueB`)
|
|
500
|
-
- Generation counter (`gen`) per node
|
|
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" })`
|
|
503
|
-
- `createRegistry({ maxFlushPasses })`
|
|
504
|
-
- `destroy()`
|
|
505
|
-
- `watch(source, callback, { immediate? })`, `when(predicate, callback)`, `whenAsync(predicate)`
|
|
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`
|
|
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
|
|
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]
|
|
778
|
+
## [1.0.0] -- 2026-05-12
|
|
534
779
|
Initial public release.
|