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