@zakkster/lite-signal-decorators 0.4.0 → 1.1.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 +218 -0
- package/README.md +129 -16
- package/SignalDecorators.d.ts +28 -1
- package/SignalDecorators.js +294 -11
- package/llms.txt +60 -10
- package/package.json +13 -2
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,222 @@ All notable changes to `@zakkster/lite-signal-decorators` are documented here.
|
|
|
4
4
|
The format follows Keep a Changelog; this project adheres to Semantic
|
|
5
5
|
Versioning.
|
|
6
6
|
|
|
7
|
+
## [1.1.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
The pooled-lifetime release: an instance is no longer single-lifetime. The
|
|
10
|
+
surface grows 16 -> 18 with the additive `releaseReactive` / `reinitReactive`
|
|
11
|
+
pair (a MINOR under the 1.0.0 semver promise), and this entry also folds in the
|
|
12
|
+
repo-only COOKBOOK work that rode `[Unreleased]` (PD-34). The hot accessor canon
|
|
13
|
+
(`makeGet` / `makeSet` / `makeDerivedGet`) and `wireInstance` are **byte-identical
|
|
14
|
+
to 1.0.0** -- reinit is a cold-path inverse of dispose, the prebuilt closure set
|
|
15
|
+
is built LAZILY at first `releaseReactive`, so the construct-once/dispose-once
|
|
16
|
+
path takes zero new bytes and no new branches (git-diff proven; S6-A4). Dist-tag
|
|
17
|
+
`latest`. Stage gate archived verbatim below: 257/257 tests on both lanes, torture
|
|
18
|
+
16 scenarios (14 run + 2 forward-compat floor-skips) with 16/16 sabotage controls
|
|
19
|
+
breaking as required, the peer-preview lane SUITE-GREEN per tag, cookbook corpus
|
|
20
|
+
and control sweep green, pack the same 7-file set.
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
|
|
24
|
+
- `releaseReactive(vm) -> boolean` -- park a LIVE instance to the engine pool:
|
|
25
|
+
cascade the anchor, dispose each signal box, swap every slot to a per-class
|
|
26
|
+
PARKED handle (touch throws `ReactiveDisposedError` with a *parked* message),
|
|
27
|
+
keep the prebuilt wiring closures. A parked instance holds ZERO engine nodes.
|
|
28
|
+
`true` on first release, `false` on park->park (idempotent, mirrors
|
|
29
|
+
double-dispose). Fails closed on a disposed, unwired, frozen, or non-reactive
|
|
30
|
+
value; carries the same self-in-`@derived` re-entrancy guard `disposeReactive`
|
|
31
|
+
does. The closure set is built lazily on first release (0011).
|
|
32
|
+
- `reinitReactive(vm, initials?) -> vm` -- revive a PARKED instance: rebuild each
|
|
33
|
+
signal box (`initials[key]` wins, else the plan initial), rebuild anchor +
|
|
34
|
+
deriveds + effects through the SAME prebuilt closures, restore live slots
|
|
35
|
+
(values reset). Atomic -- any throw mid-reinit routes through `disposeCore` and
|
|
36
|
+
lands the instance terminally DISPOSED (a failed revival is final). Fails closed
|
|
37
|
+
on a live, disposed, frozen, unwired, or non-reactive value; `null` is not zero.
|
|
38
|
+
- The pooled-lifetime lattice: LIVE / PARKED / DISPOSED on one axis, with all nine
|
|
39
|
+
transitions pinned in `test/16-reinit.test.mjs` (29 cases, both emit lanes) --
|
|
40
|
+
park->reinit->live (values reset), park->dispose (lands DISPOSED, idempotent
|
|
41
|
+
false thereafter), park->park (false), the five `reinitReactive` fail-closed
|
|
42
|
+
states, parked-touch throwing "parked" not "disposed", and `boxOf`/`rootOf`
|
|
43
|
+
agreement. No new error class (PD-44): the PARKED touch reuses
|
|
44
|
+
`ReactiveDisposedError` with a pooled-lifetime message.
|
|
45
|
+
- `test/torture/reinit-torture.mjs` (group `semantic`, floor 1.5.0) -- the
|
|
46
|
+
acquire/release gate: 4096 pooled cycles at the churn shape (P=4, D=2, E=1) hold
|
|
47
|
+
`maxMajor 0`, `maxPauseMs <= 4.0`, minors CONTROL-RELATIVE (`MINOR_FLOOR + 128`),
|
|
48
|
+
retained delta-heap at/below the in-process zero-alloc control, and exact pool
|
|
49
|
+
conservation (F-0: `activeNodes` to baseline, `poolGrowths` delta 0, ledger
|
|
50
|
+
balanced). Its `TORTURE_BREAK=reinit-torture` control leaks one un-parked
|
|
51
|
+
instance per cycle and exits non-zero. Torture 15 -> 16 scenarios.
|
|
52
|
+
- `bench/scenarios/churn-reuse.mjs` -- the CHURN-shape acquire/release bench lane
|
|
53
|
+
(P=4, D=2, E=1): the `lsd`, `lsd-define`, and hand-wired `lite-raw-boxes` tiers
|
|
54
|
+
pool with retained 0.0KB; `mobx`, `signal-utils`, and `alien-class` return
|
|
55
|
+
`{ unsupported }` with an honest reason (MobX instances are never disposable --
|
|
56
|
+
atoms return only via GC, so there is no release/reinit cycle to pool). Bench
|
|
57
|
+
scenarios 7 -> 8; `bench/results.txt` re-stamped.
|
|
58
|
+
- `spikes/reinit-contract.mjs` -- the S6-T1 spike (four questions answered with
|
|
59
|
+
numbers on the installed 1.5.0 peer before a line of `reinitReactive` was
|
|
60
|
+
written): the engine NODE is fully pooled, the JS HANDLE is fresh-per-call but
|
|
61
|
+
never retains, one prebuilt closure drives N registrations with zero stale
|
|
62
|
+
fires, and stale external handles fail CLOSED (no aliasing). Verdict EXIT A.
|
|
63
|
+
- `decisions/0010-reinit-contract.md` (the spike contract + the four measured
|
|
64
|
+
tables + the peer-registry watch) and `decisions/0011-reinit-api.md` (the
|
|
65
|
+
two-call API shape, the state lattice, the construction-cost finding, and the
|
|
66
|
+
rejected alternatives).
|
|
67
|
+
- `COOKBOOK.md` -- composition recipes over the surface, delivered GitHub-only
|
|
68
|
+
(repo-only, not in `files[]`; decisions/0009): the tarball stays the 7-file
|
|
69
|
+
runtime surface and the shipped README.md and llms.txt point to the cookbook by
|
|
70
|
+
absolute GitHub URL.
|
|
71
|
+
- `cookbook/` (dev-only, never shipped): a runnable companion corpus of 12
|
|
72
|
+
recipes (0-11), six GC-gated with `COOKBOOK_BREAK=<id>` sabotage controls and
|
|
73
|
+
six ungated each carrying a published reason; plus `cookbook/manifest.json`,
|
|
74
|
+
the `cookbook/citations.json` cross-package symbol allowlist, and
|
|
75
|
+
`cookbook/run.mjs` -- the `node --expose-gc` per-recipe runner behind
|
|
76
|
+
`npm run cookbook` (with `--controls` for the sabotage sweep and `--list`).
|
|
77
|
+
- `test/15-cookbook.test.mjs` -- the drift/parity checker: each fenced block in
|
|
78
|
+
`COOKBOOK.md` is byte-compared against its tagged companion `#region`,
|
|
79
|
+
bidirectionally, with a surface-freeze check (now exactly 18 exports; PD-48,
|
|
80
|
+
second-landing session owns the one number), a citation check, a static-cost
|
|
81
|
+
check, and the shipped-doc-link check.
|
|
82
|
+
- `test/gate.mjs` gains a BLOCKING `cookbook` step (the corpus lane plus the
|
|
83
|
+
`COOKBOOK_BREAK` control sweep, both must exit 0), between `bench:selftest` and
|
|
84
|
+
`pack`: the chain is now 8 blocking steps + 1 non-blocking (peer-preview).
|
|
85
|
+
- `test/gate.mjs` pack check upgraded from a bare count to a named-set
|
|
86
|
+
assertion: the tarball names must equal exactly {SignalDecorators.js,
|
|
87
|
+
SignalDecorators.d.ts, llms.txt, CHANGELOG.md, README.md, LICENSE,
|
|
88
|
+
package.json}; `EXPECT_FILES` stays 7 and stays asserted, and a same-count
|
|
89
|
+
swap now fails.
|
|
90
|
+
- `package.json` `scripts.cookbook` (`node cookbook/run.mjs`) and three pinned
|
|
91
|
+
devDependencies: `@zakkster/lite-store` 1.2.0, `@zakkster/lite-arena` 1.9.0,
|
|
92
|
+
`@zakkster/lite-await` 1.1.1.
|
|
93
|
+
|
|
94
|
+
### Changed
|
|
95
|
+
|
|
96
|
+
- The export surface grows 16 -> 18 (additive MINOR): `releaseReactive`,
|
|
97
|
+
`reinitReactive`. The 1.0.0 semver promise holds -- no existing export's
|
|
98
|
+
signature or behavior changed; the hot accessor canon does not move.
|
|
99
|
+
- Suite 214 -> 257 tests, green on both the plain and `--expose-gc` lanes:
|
|
100
|
+
`test/15-cookbook.test.mjs` added 14 (214 -> 228), `test/16-reinit.test.mjs`
|
|
101
|
+
added 29 (228 -> 257). Torture 15 -> 16 scenarios; sabotage controls 15 -> 16.
|
|
102
|
+
- Docs re-stamped to 1.1.0 across the three version sites
|
|
103
|
+
(`package.json`, `SignalDecorators.js` `VERSION`, `llms.txt`), the README
|
|
104
|
+
Testing/torture/design sections, and the llms.txt Exports header and scope
|
|
105
|
+
note. The llms.txt single-lifetime sentence ("disposed once, never revived") is
|
|
106
|
+
replaced by the pooled-lifetime contract and the three-state lattice, citing
|
|
107
|
+
decisions/0010 and 0011.
|
|
108
|
+
- Construction cost is unchanged: the prebuilt closure set is built LAZILY at
|
|
109
|
+
first `releaseReactive` (not at construction), so a construct-once/dispose-once
|
|
110
|
+
instance carries only one WeakMap `has` beyond 1.0.0; `costOf(Factory).nodes`
|
|
111
|
+
still equals P+D+E+1 and `capacity-torture` is unperturbed. Measured evidence
|
|
112
|
+
for why lazy (a stored or transient per-construction bundle tips the
|
|
113
|
+
`maxMajor 0` churn-soak floor) is in decisions/0011.
|
|
114
|
+
|
|
115
|
+
### Peer-watch note (PD-46)
|
|
116
|
+
|
|
117
|
+
Outcome (i): nothing promoted. The `npm view @zakkster/lite-signal dist-tags`
|
|
118
|
+
probe on 2026-08-30 shows stable `latest` still at **1.5.0**; 1.6.x
|
|
119
|
+
(`createScope`) and 1.9.x (engine `Symbol.dispose`) exist only as prerelease tags,
|
|
120
|
+
exactly the ones the peer-preview lane already tracks. No per-feature floor is
|
|
121
|
+
promoted, `torture:peer-preview` keeps reporting per tag, the `scope-adoption`
|
|
122
|
+
and `using-dispose` scenarios keep skipping legitimately below their floors, and
|
|
123
|
+
the peer RANGE floor stays `>=1.5.0 <2.0.0`. Recorded in decisions/0010.
|
|
124
|
+
|
|
125
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
126
|
+
|
|
127
|
+
```
|
|
128
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
129
|
+
test OK exit 0 -- 257 pass / 0 fail
|
|
130
|
+
test:gc OK exit 0 -- 257 pass / 0 fail
|
|
131
|
+
torture OK exit 0 -- 14 passed, 2 skipped, 0 warned, 0 failed in 33.2s
|
|
132
|
+
torture:controls OK exit 0 -- 16 passed, 0 skipped, 0 warned, 0 failed in 2.0s
|
|
133
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 16 passed, 0 skipped, 0 warned, 0 failed; canary 1.9.0-canary.1 SUITE-GREEN 16 passed, 0 skipped, 0 warned, 0 failed]
|
|
134
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
135
|
+
cookbook OK exit 0/0 -- corpus 12/12 companions ok in 1.8s; controls 6/6 controls fail correctly in 4.6s
|
|
136
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
137
|
+
----------------------------------------------------------------------
|
|
138
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## [1.0.0] - 2026-08-29
|
|
142
|
+
|
|
143
|
+
The 1.0 release: docs freeze, the fleet playground, and the standing pre-publish
|
|
144
|
+
gate. Zero runtime changes -- the 16-export surface ships byte-identical to
|
|
145
|
+
0.4.0 (the hot accessor canon is review-diffed to a zero-line diff), and this
|
|
146
|
+
release freezes that surface under semver: any later signature change is a major
|
|
147
|
+
recorded in a decision file. Dist-tag `latest`. Stage gate, measured at
|
|
148
|
+
closeout by the full section-10 chain (archived verbatim below): 214/214 tests
|
|
149
|
+
on both lanes; torture 15 scenarios (13 pass + the two forward-compat scenarios
|
|
150
|
+
legitimately skipping under the installed 1.5.0 peer) with 15/15 sabotage
|
|
151
|
+
controls breaking as required; the peer-preview lane SUITE-GREEN per tag
|
|
152
|
+
(15/15, forward scenarios RUNNING) against lite-signal 1.9.0-preview.6 AND
|
|
153
|
+
1.9.0-canary.1; the bench sink self-test catching its sabotaged adapter; pack 7
|
|
154
|
+
files, no `demo/` or `Publications/`.
|
|
155
|
+
|
|
156
|
+
### Added
|
|
157
|
+
|
|
158
|
+
- The fleet-playground demo (`demo/`, dev-only, never in `files[]`): a
|
|
159
|
+
single-file instrument console over a two-plane architecture -- Plane A a
|
|
160
|
+
decorated entity-VM fleet in a `capacityFor`-sized custom registry with
|
|
161
|
+
enforced ceilings, Plane B the telemetry signals in the default registry
|
|
162
|
+
driving five `@zakkster/lite-watch-ex` watchers (`watchUntil`,
|
|
163
|
+
`pausableWatch`, `watchChanged`, `watchMany`, `watchPrevious`). The DOM-free
|
|
164
|
+
core runs headless under the same GC-budget and dispose-storm gates the
|
|
165
|
+
library uses; the PD-29 registry wall is proven by a stats-delta (no watcher
|
|
166
|
+
ever forms an edge into the custom-registry fleet), not by assertion.
|
|
167
|
+
- README migration section: MobX 7 and signal-utils translation tables,
|
|
168
|
+
including the closing row MobX cannot write -- `disposeReactive(vm)` is one
|
|
169
|
+
idempotent, node-exact call after which every later touch throws by name.
|
|
170
|
+
- README emit-support matrix, generated from `test/fixtures/hashes.json` (9
|
|
171
|
+
entries) under drift-proof `EMIT-MATRIX` markers; `04-fixture-freshness`
|
|
172
|
+
asserts the README block equals the generator, so a re-emit that changes a
|
|
173
|
+
byte is loud, not silent.
|
|
174
|
+
- `llms.txt` per-feature forward floors documented alongside the peer range:
|
|
175
|
+
1.6.0 for `createScope`, 1.9.0 for `Symbol.dispose`; the peer range floor
|
|
176
|
+
stays `>=1.5.0 <2.0.0`.
|
|
177
|
+
- `gate` script (`test/gate.mjs`): the section-10 pre-publish chain as captured
|
|
178
|
+
child processes -- fixtures, test, test:gc, torture (semantic + soak),
|
|
179
|
+
the TORTURE_BREAK control sweep, the non-blocking peer-preview lane,
|
|
180
|
+
the bench sink self-test, and `npm pack --dry-run` asserting exactly 7 files.
|
|
181
|
+
Every blocking step exits 0 or the gate exits non-zero; peer-preview is
|
|
182
|
+
reported, never gated.
|
|
183
|
+
- `Publications/` (dev-only, never shipped): per-channel release drafts and the
|
|
184
|
+
GitHub release notes, centered on the class-reactivity benchmark methodology
|
|
185
|
+
with an explicit invitation for competitor adapter PRs.
|
|
186
|
+
|
|
187
|
+
### Changed
|
|
188
|
+
|
|
189
|
+
- Docs re-stamped to reality: Testing sections now read 214 tests across
|
|
190
|
+
fourteen files and 15 torture scenarios (13 run + 2 floor-gated skips), and
|
|
191
|
+
every numeric claim traces to a live stamp (`bench/results.txt`,
|
|
192
|
+
`decisions/0006`, or a gate tail). Version references across README and
|
|
193
|
+
llms.txt move to 1.0.0.
|
|
194
|
+
- The 16-export surface is frozen under semver at 1.0.0. The 0.x "frozen for
|
|
195
|
+
0.x" note is retired in favor of the semver promise.
|
|
196
|
+
|
|
197
|
+
### Fixed
|
|
198
|
+
|
|
199
|
+
- (Found in review, pass 1 charge C4) An inline-style violation in the demo
|
|
200
|
+
HTML -- a non-custom-property `style=` attribute against the demo CSS law --
|
|
201
|
+
was hoisted into the stylesheet. Custom-property `style="--var: value"` hooks
|
|
202
|
+
remain, per the law.
|
|
203
|
+
- (Found in the planner audit) The Testing and gates sections carried
|
|
204
|
+
0.2.0-era numbers (171 tests, "12/12 scenarios", an 11-row file table) two
|
|
205
|
+
stages stale, in violation of the no-claim-without-a-stamp rule. Re-stamped
|
|
206
|
+
against the 1.0.0 tree.
|
|
207
|
+
|
|
208
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
209
|
+
|
|
210
|
+
```
|
|
211
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
212
|
+
test OK exit 0 -- 214 pass / 0 fail
|
|
213
|
+
test:gc OK exit 0 -- 214 pass / 0 fail
|
|
214
|
+
torture OK exit 0 -- 13 passed, 2 skipped, 0 warned, 0 failed in 32.7s
|
|
215
|
+
torture:controls OK exit 0 -- 15 passed, 0 skipped, 0 warned, 0 failed in 1.9s
|
|
216
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed; canary 1.9.0-canary.1 SUITE-GREEN 15 passed, 0 skipped, 0 warned, 0 failed]
|
|
217
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
218
|
+
pack OK exit 0 -- 7/7 files, no demo/ no Publications/
|
|
219
|
+
----------------------------------------------------------------------
|
|
220
|
+
GATE PASS -- 7 blocking steps + 1 non-blocking (peer-preview)
|
|
221
|
+
```
|
|
222
|
+
|
|
7
223
|
## [0.4.0] - 2026-08-26
|
|
8
224
|
|
|
9
225
|
The introspection release: the surface grows 11 -> 16, every addition cold-path
|
|
@@ -276,6 +492,8 @@ Initial release -- the decorator core.
|
|
|
276
492
|
- Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
|
|
277
493
|
retention, conservation, lifecycle, and zero-GC lanes.
|
|
278
494
|
|
|
495
|
+
[1.1.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.1.0
|
|
496
|
+
[1.0.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.0.0
|
|
279
497
|
[0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
|
|
280
498
|
[0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
|
|
281
499
|
[0.2.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.2.0
|
package/README.md
CHANGED
|
@@ -49,6 +49,7 @@ No base class to extend. No `makeObservable(this, {...})` mirror object to keep
|
|
|
49
49
|
- [Design decisions worth knowing](#design-decisions-worth-knowing)
|
|
50
50
|
- [Testing (for clients & QA)](#testing-for-clients--qa)
|
|
51
51
|
- [Compatibility](#compatibility)
|
|
52
|
+
- [Migrating from MobX 7 & signal-utils](#migrating-from-mobx-7--signal-utils)
|
|
52
53
|
- [What this is not](#what-this-is-not)
|
|
53
54
|
- [Ecosystem](#ecosystem)
|
|
54
55
|
- [FAQ](#faq) - [License](#license)
|
|
@@ -237,10 +238,12 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
237
238
|
| Export | Signature | Behavior |
|
|
238
239
|
|---|---|---|
|
|
239
240
|
| `disposeReactive` | `(vm) => boolean` | Cascade + poison teardown. `true` on the first call, `false` after (idempotent). Also wired to `Symbol.dispose`, so `using vm = new Player()` disposes at block exit. Refuses a frozen instance up front (named throw, nothing half-done). |
|
|
241
|
+
| `releaseReactive` | `(vm) => boolean` | Park a LIVE instance to the engine pool: cascade the anchor, dispose each box, swap every slot to a PARKED handle (touch throws `ReactiveDisposedError` with a *parked* message), keep the prebuilt wiring closures. A parked instance holds ZERO engine nodes. `true` on first release, `false` on park->park (idempotent). Fails closed on a disposed, unwired, frozen, or non-reactive value. Pooled-lifetime contract: [decisions/0010](decisions/0010-reinit-contract.md), [0011](decisions/0011-reinit-api.md). |
|
|
242
|
+
| `reinitReactive` | `(vm, initials?) => vm` | Revive a PARKED instance: rebuild each box (`initials[key]` wins, else the plan initial), rebuild anchor + deriveds + effects through the SAME prebuilt closures, restore live slots (values reset). Atomic -- a throw mid-reinit lands the instance DISPOSED (a failed revival is final). Fails closed on a live, disposed, frozen, unwired, or non-reactive value. |
|
|
240
243
|
| `boxOf` | `(vm, key) => SignalBox \| ComputedBox` | The live engine box behind a `@reactive`/`@derived` member -- `.peek()`, `.subscribe()`, raw interop. Unknown key: named throw with a did-you-mean. After dispose: `ReactiveDisposedError`. |
|
|
241
244
|
| `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
|
|
242
245
|
|
|
243
|
-
### Introspection & audit (0.
|
|
246
|
+
### Introspection & audit (1.0.0)
|
|
244
247
|
|
|
245
248
|
| Export | Signature | Behavior |
|
|
246
249
|
|---|---|---|
|
|
@@ -256,7 +259,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
|
|
|
256
259
|
| Export | Value |
|
|
257
260
|
|---|---|
|
|
258
261
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
259
|
-
| `VERSION` | `"
|
|
262
|
+
| `VERSION` | `"1.1.0"` |
|
|
260
263
|
|
|
261
264
|
### The rejection matrix
|
|
262
265
|
|
|
@@ -305,6 +308,27 @@ console.log(world.stats().activeNodes); // 0 -- every node returned to the pool
|
|
|
305
308
|
|
|
306
309
|
The default registry never notices any of it: bound-registry churn leaves outside `stats()` frozen (torture-pinned). And because `boxOf` returns the *engine's* box, everything lite-signal composes with -- subscriptions, `peek`, batch, untrack, lite-raf frame effects -- composes with decorated members too.
|
|
307
310
|
|
|
311
|
+
### Predicate-gated watchers (`@zakkster/lite-watch-ex`)
|
|
312
|
+
|
|
313
|
+
`lite-watch-ex` adds one-shot, pausable, and change-gated watchers over the same engine. Its sources are plain **thunks** (`() => vm.hp`, never a box handle), and every watcher creates its effect node in the **default registry** -- so wire one only to a **default-registry** instance (one with no `host.registry`), never across a custom-registry fleet, where the edge would cross a boundary the engine's default `dispose` cannot see:
|
|
314
|
+
|
|
315
|
+
```js
|
|
316
|
+
import { watchUntil } from "@zakkster/lite-watch-ex";
|
|
317
|
+
import { defineReactive } from "@zakkster/lite-signal-decorators";
|
|
318
|
+
|
|
319
|
+
// Default-registry instance -- no host.registry, so it lives in the default graph.
|
|
320
|
+
const ReactivePlayer = defineReactive(class Player {}, { signals: { hp: 100 } });
|
|
321
|
+
const vm = new ReactivePlayer();
|
|
322
|
+
|
|
323
|
+
// Fires ONCE when hp crosses the threshold, then self-disposes:
|
|
324
|
+
watchUntil(() => vm.hp, (h) => h <= 25, (h) => console.log("low hp:", h));
|
|
325
|
+
|
|
326
|
+
vm.hp = 40; // predicate false -> no fire
|
|
327
|
+
vm.hp = 20; // predicate true -> "low hp: 20", watcher disposes itself
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
The [`fleet-playground` demo](demo/fleet-playground.html) shows the safe split at scale: decorated entity VMs in an enforced custom registry, all watchers on a separate default-registry telemetry plane.
|
|
331
|
+
|
|
308
332
|
---
|
|
309
333
|
|
|
310
334
|
## The numbers
|
|
@@ -352,14 +376,14 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
352
376
|
| `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
|
|
353
377
|
| `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
|
|
354
378
|
|
|
355
|
-
The gates that hold it (run on every change, all green at
|
|
379
|
+
The gates that hold it (run on every change, all green at 1.1.0):
|
|
356
380
|
|
|
357
|
-
- `npm test` / `npm run test:gc` -- **
|
|
381
|
+
- `npm test` / `npm run test:gc` -- **257/257** on both lanes.
|
|
358
382
|
- Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
|
|
359
|
-
- Torture: **
|
|
360
|
-
- `churn-soak`: sustained construct/use/dispose
|
|
383
|
+
- Torture: **16 scenarios** (zero-GC read/write lanes at `maxMajor 0, maxPauseMs 4`; 4096-cycle leak gate at 0 live / 0 findings / 0 warnings; capacity atomicity at every overflow point; a 300-seed x 20k-op oracle with zero divergences; the `reinit-torture` acquire/release gate) -- **14 run + 2 that skip correctly below their peer floors** (`scope-adoption` needs 1.6.0, `using-dispose` needs 1.9.0; the installed peer is 1.5.0). A skip *below* a floor is the forward-compat design working; a skip *at or above* it is a FAIL (run.mjs enforces floor-escalation). Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- **16/16 controls** prove each gate can actually fail.
|
|
384
|
+
- `churn-soak` + `fleet-soak`: sustained construct/use/dispose and a 10s 2k-VM fleet tick; pools at floor and retained heap flat at every sample.
|
|
361
385
|
|
|
362
|
-
The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across
|
|
386
|
+
The cross-framework matrix lives in `bench/` (private, never shipped): six engines -- both our tiers, the hand-written `lite-raw-boxes` baseline, MobX 7, signal-utils/signal-polyfill, and a hand-rolled alien-signals class -- across eight class-shaped scenarios (including the `churn-reuse` acquire/release lane, where the lite tiers pool with zero retained growth and MobX/signal-utils/alien-class are structurally `unsupported` -- no disposable instance lifecycle to pool), checksum-verified for identical work, stamped into `bench/results.txt`. The formal verdicts are in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md): the decorated path measured **0.94x** the hand-written baseline on vm-write and **1.10x** on a 10k-instance fleet read (the 2.0x kill line cleared with margin), and **0 major + 0 minor GC over 4096 construct/use/dispose cycles** with pools at floor -- while emitting ~12.6x less transient garbage per churn run than the hand-rolled class it replaces.
|
|
363
387
|
|
|
364
388
|
</details>
|
|
365
389
|
|
|
@@ -376,24 +400,26 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
|
|
|
376
400
|
- **Frozen instances refuse disposal up front.** `Object.freeze(vm)` makes the poison swap impossible, so `disposeReactive` throws by name *before* touching anything -- no half-dispose. `seal`/`preventExtensions` are fine.
|
|
377
401
|
- **Symbol-slot storage.** Chosen over the emitter's private backing (emitter-dependent codegen) and a dict (fleet megamorphism, measured) -- and it is the same mechanism poison uses, so storage, dispose, and poison are one design.
|
|
378
402
|
- **Statics and `#` privates are rejected, not half-supported.** A module-level signal belongs to raw lite-signal; a private member can't be reached by the wiring protocol -- both are named decoration-time throws.
|
|
403
|
+
- **Pooled reinit is an identity-stable arena tool, not a speed shortcut (1.1.0).** `releaseReactive(vm)` parks a live instance to the engine pool and `reinitReactive(vm, initials?)` revives it -- a three-state lattice (live / parked / disposed) over the same instance, so consumers keep the object reference across turnover. It holds its gate: over 4096 acquire/release cycles at the churn shape (P=4, D=2, E=1) it measures **0 major GC**, retained delta-heap **at or below the in-process zero-alloc control**, and exact pool conservation (`activeNodes` back to baseline, zero pool growths, a parked instance holding 0 engine nodes). The honest throughput number, same shape, 2026-08-30 stamp (module 1.1.0): plain construct/dispose CHURN is *faster* -- **1323K ops/s** vs reuse's **1159K** -- because construction is already allocation-light and pool-conserving, so reinit is not a per-op win. Reach for it when you need identity-stable pooled instances under sustained turnover with zero retained growth (an arena/fleet primitive), not when you want raw op speed. MobX has no equivalent lifecycle at all: its instances are never disposable, so there is no release/reinit cycle to pool ([decisions/0010](decisions/0010-reinit-contract.md), [0011](decisions/0011-reinit-api.md)).
|
|
379
404
|
|
|
380
405
|
---
|
|
381
406
|
|
|
382
407
|
## Testing (for clients & QA)
|
|
383
408
|
|
|
384
409
|
```bash
|
|
385
|
-
npm test # node --test,
|
|
386
|
-
npm run test:gc # the same
|
|
410
|
+
npm test # node --test, 257 tests
|
|
411
|
+
npm run test:gc # the same 257 with --expose-gc (enables the allocation assertions)
|
|
412
|
+
npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
|
|
387
413
|
```
|
|
388
414
|
|
|
389
|
-
**
|
|
415
|
+
**257 tests** across sixteen files, all green at 1.1.0. The decorator protocol is tested three times over: against a mock Stage-3 emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
|
|
390
416
|
|
|
391
417
|
| File | Tests | Covers |
|
|
392
418
|
|---|---:|---|
|
|
393
419
|
| `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
|
|
394
420
|
| `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
|
|
395
421
|
| `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
|
|
396
|
-
| `04-fixture-freshness` |
|
|
422
|
+
| `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
|
|
397
423
|
| `05-wiring` | 5 | Anchor creation, wiring order, leaf-wires-once |
|
|
398
424
|
| `06-dispose` | 6 | Cascade, idempotency, poison, `using` |
|
|
399
425
|
| `07-qa-boundary` | 13 | S1 adversarial boundary pins |
|
|
@@ -401,19 +427,68 @@ npm run test:gc # the same 171 with --expose-gc (enables the allocation asse
|
|
|
401
427
|
| `09-buildless` | 16 | `defineReactive` parity + the spec rejection matrix |
|
|
402
428
|
| `10-qa-s2a-boundary` | 34 | Adversarial pins: identity guard, frozen dispose, registry heterogeneity, stacking |
|
|
403
429
|
| `11-qa-s2b-boundary` | 8 | Construction-throw boundaries: init-phase drain, chain-base throws, overflow storms |
|
|
430
|
+
| `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
|
|
431
|
+
| `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
|
|
432
|
+
| `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
|
|
433
|
+
| `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly 18 exports), citation allowlist, link law, static-cost probe |
|
|
434
|
+
| `16-reinit` | 29 | Pooled-reinit lattice on both emit lanes: park/reinit/dispose transitions, the five `reinitReactive` fail-closed states, parked-touch throws by name, `initials` boundary matrix (0..N+1 keys, null/undefined, NaN/-0 verbatim), `Symbol.dispose` on a parked instance, accessor descriptors byte-identical across reinit, self-release re-entrancy, ledger conservation |
|
|
435
|
+
|
|
436
|
+
### Emit-support matrix
|
|
437
|
+
|
|
438
|
+
Three fixture sources, two Stage-3 emitters, both emit lanes -- every cell below is a committed, hash-pinned fixture (the `04-fixture-freshness` guard above). The table is generated from the fixture manifest, so a re-emit that changes a byte is loud, not silent:
|
|
439
|
+
|
|
440
|
+
<!-- EMIT-MATRIX:START -->
|
|
441
|
+
Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.json` -- do not hand-edit. Toolchain pinned by the committed fixtures: **TypeScript 5.9.3**, **@babel/core 7.29.7** + **@babel/plugin-proposal-decorators 7.29.7** (`version: 2023-11`). Each `sha256` is the first 12 hex of the committed emit; `npm run fixtures` regenerates and `test/04-fixture-freshness` fails loudly on any drift.
|
|
442
|
+
|
|
443
|
+
| Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
|
|
444
|
+
|---|---|---|---|---|---|
|
|
445
|
+
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `1a3fc0f943bf` | accepted -- full decorator surface wired + pinned green |
|
|
446
|
+
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `eb9dfb5939b1` | accepted -- full decorator surface wired + pinned green |
|
|
447
|
+
| `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
|
|
448
|
+
| `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
|
|
449
|
+
| `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
|
|
450
|
+
| `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
|
|
451
|
+
|
|
452
|
+
Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
|
|
453
|
+
<!-- EMIT-MATRIX:END -->
|
|
404
454
|
|
|
405
455
|
### The torture suite (dev-side, never shipped)
|
|
406
456
|
|
|
407
457
|
Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
|
|
408
458
|
|
|
409
459
|
```bash
|
|
410
|
-
npm run torture # all
|
|
460
|
+
npm run torture # all 16 scenarios (14 run + 2 floor-gated skips)
|
|
411
461
|
npm run torture:semantic # the correctness lane (CI)
|
|
412
|
-
npm run torture:soak # the wall-clock churn
|
|
462
|
+
npm run torture:soak # the wall-clock churn + fleet soaks
|
|
413
463
|
npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
|
|
414
464
|
```
|
|
415
465
|
|
|
416
|
-
|
|
466
|
+
Sixteen scenarios: emit-matrix, ordering, lifecycle, pool-conservation, zero-GC lanes, capacity atomicity (every overflow point x both construction paths), the full disposed-poison surface + resurrection storms, a 4096-cycle lite-leak gate, a **300-seed x 20k-op oracle fuzzer** (decorated vs hand-wired raw twin in lockstep: every derived value, every effect fire count, every graph opcode tally), raw/decorated interop + cross-registry + `registry.destroy()` contracts, batch/untrack semantics, the `reinit-torture` acquire/release gate (4096 pooled cycles: `maxMajor 0`, retained delta-heap at/below the in-process zero-alloc control, exact pool conservation), the wall-clock churn soak, and a 10s 2k-VM fleet soak -- plus two forward-compat scenarios (`scope-adoption`, `using-dispose`) that **skip correctly** while the installed peer sits below their per-feature floors (1.6.0 `createScope`, 1.9.0 `Symbol.dispose`). A skip below a floor is the design working; a skip at or above it is a FAIL. On the installed 1.5.0 peer: 14 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
|
|
467
|
+
|
|
468
|
+
### The cookbook lane (dev-side, never shipped)
|
|
469
|
+
|
|
470
|
+
Every code block in [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) is byte-identical to a runnable companion in `cookbook/` (dev-only, never in `files[]`):
|
|
471
|
+
|
|
472
|
+
```bash
|
|
473
|
+
npm run cookbook # run all 12 companions under node --expose-gc
|
|
474
|
+
npm run cookbook -- --controls # sabotage sweep: every gated recipe must FAIL when broken
|
|
475
|
+
npm run cookbook -- --list # the manifest: id, title, tier, gc verdict
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Twelve recipe companions, six of them GC-gated (r1, r2, r4, r5, r9, r10) at the S1 budget (`gc.major === 0`, `maxPauseMs <= 4.0`, `<= 0.589` B/op with control-relative minors) and each carrying a `COOKBOOK_BREAK=<id>` sabotage control; the other six publish a non-empty reason in the manifest. Latest lane tails: `cookbook lane: 12/12 companions ok in 1.8s` and, under `--controls`, `cookbook lane: 6/6 controls fail correctly in 4.6s`. `test/15-cookbook.test.mjs` drift-checks the document against the companions in both directions (a one-byte edit either side fails, naming the recipe), and the gate runs the lane as a blocking step -- the chain is now 8 blocking steps + 1 non-blocking.
|
|
479
|
+
|
|
480
|
+
### The fleet demo (dev-side, never shipped)
|
|
481
|
+
|
|
482
|
+
A single-file instrument console -- [`demo/fleet-playground.html`](demo/fleet-playground.html) -- drives a two-plane capacity fleet: decorated entity VMs in an enforced custom registry, telemetry watchers in the default registry. Its DOM-free core runs headless under the same gates the library uses:
|
|
483
|
+
|
|
484
|
+
```bash
|
|
485
|
+
npm run demo:build # esbuild bundle -> demo/bundle.js + rewrite demo/bundle.sha256
|
|
486
|
+
npm run demo:check # verify the committed bundle matches its recorded hash
|
|
487
|
+
npm run demo:gc # headless GC-budget lane over the fleet core (maxMajor 0)
|
|
488
|
+
npm run demo:storm # headless dispose-storm retention lane (lite-leak, size 0)
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
The `demo/` directory is dev-only -- it never enters `package.json` `files[]` and never ships to consumers.
|
|
417
492
|
|
|
418
493
|
---
|
|
419
494
|
|
|
@@ -430,12 +505,45 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
|
|
|
430
505
|
|
|
431
506
|
---
|
|
432
507
|
|
|
508
|
+
## Migrating from MobX 7 & signal-utils
|
|
509
|
+
|
|
510
|
+
The decorator vocabulary maps almost one-to-one; what changes is the lifetime story. Both libraries below leave teardown to the garbage collector -- this package makes it a single deterministic call.
|
|
511
|
+
|
|
512
|
+
### From MobX 7
|
|
513
|
+
|
|
514
|
+
| MobX 7 | lite-signal-decorators |
|
|
515
|
+
|---|---|
|
|
516
|
+
| `@observable accessor x` | `@reactive accessor x` |
|
|
517
|
+
| `@computed get y()` | `@derived get y()` |
|
|
518
|
+
| `@action m()` | `@batched m()` |
|
|
519
|
+
| `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
|
|
520
|
+
| `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
|
|
521
|
+
| reaction disposers only; the instance itself is never disposable | **`disposeReactive(vm)` -- one call, idempotent, node-exact, and every later touch throws by name. MobX has no equivalent; its per-instance graph ends when the collector decides.** |
|
|
522
|
+
|
|
523
|
+
### From signal-utils
|
|
524
|
+
|
|
525
|
+
Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or getters) and `@cached` (on getters) are the two decorators in its surface.
|
|
526
|
+
|
|
527
|
+
| signal-utils 0.21 | lite-signal-decorators |
|
|
528
|
+
|---|---|
|
|
529
|
+
| `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
|
|
530
|
+
| `@cached get y()` | `@derived get y()` |
|
|
531
|
+
| no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
|
|
532
|
+
| Stage-3 build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
|
|
533
|
+
|
|
534
|
+
The cross-framework numbers behind this table are stamped in [`decisions/0006-kill-criteria.md`](decisions/0006-kill-criteria.md) (both engines measured through their documented class APIs at checksum-identical work).
|
|
535
|
+
|
|
536
|
+
---
|
|
537
|
+
|
|
433
538
|
## What this is not
|
|
434
539
|
|
|
435
540
|
- **Not a home for module-level or global signals.** That is raw `lite-signal` territory; `static` members are rejected by design.
|
|
541
|
+
- **Not a deep/proxy observation layer.** No `observable.deep`, no wrapped Arrays/Maps/Sets, no proxy magic -- the reactive unit is a declared member, not a traversed object graph. Collections are `@zakkster/lite-project` territory.
|
|
436
542
|
- **Not a per-frame action system.** `@batched` costs a measured thunk per call -- fine for "one call per user intent", wrong inside a render loop. Per-frame hot lanes stay on plain accessor writes (and frame *scheduling* belongs to `lite-raf`).
|
|
437
543
|
- **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
|
|
438
|
-
- **Not a
|
|
544
|
+
- **Not a general meta-programming kit.** Five decorators, one wiring law -- not an open decorator toolbox. It does one thing: turn a class into a reactive view-model with a provable lifetime.
|
|
545
|
+
- **Not a MobX API shim.** No `makeObservable`, no administration objects -- and no GC-based cleanup: disposal is explicit, deterministic, and verified, because "the collector will get it eventually" is not a lifecycle.
|
|
546
|
+
- **Not a legacy-decorators consumer.** TypeScript `experimentalDecorators` emit is detected by call shape at decoration time and rejected with a named error -- never "works differently under legacy".
|
|
439
547
|
- **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
|
|
440
548
|
|
|
441
549
|
---
|
|
@@ -448,8 +556,13 @@ Native (untranspiled) `@` decorators in engines: not shipped anywhere yet -- unt
|
|
|
448
556
|
| `@zakkster/lite-signal-dom` | DOM bindings for the same engine -- where a view-model meets actual elements. |
|
|
449
557
|
| `@zakkster/lite-raf` | Frame-rate scheduling for the same graph; the frame-coalescing pattern the `scheduler` option on `@reactiveEffect` exists to plug into. |
|
|
450
558
|
| `@zakkster/lite-devtools` | Graph inspection; `rootOf(vm)` + `forEachOwned` is the hook it walks. |
|
|
559
|
+
| [`@zakkster/lite-watch-ex`](https://www.npmjs.com/package/@zakkster/lite-watch-ex) | One-shot / predicate-gated / pausable watchers over the same engine; thunk sources, default-registry effects -- see the [registry note](#composability) before pointing one at a decorated member. |
|
|
451
560
|
| `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler` | The dev-side harness that proves every retention and allocation claim in this README. Never shipped to consumers. |
|
|
452
561
|
|
|
562
|
+
### The cookbook
|
|
563
|
+
|
|
564
|
+
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects twelve composition recipes over the frozen 16-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
|
|
565
|
+
|
|
453
566
|
---
|
|
454
567
|
|
|
455
568
|
## FAQ
|
|
@@ -484,7 +597,7 @@ Because an instance whose base-class boxes live in one pool and whose subclass b
|
|
|
484
597
|
No, and the README says so with numbers: 22.12 ns/op vs 15.25 raw vs 11.67 unbatched on the reference rig -- a thunk + rest-array per call. Use it for actions; keep per-frame writes on plain accessors.
|
|
485
598
|
|
|
486
599
|
**Where are `costOf`, labels, the audit hook, private members?**
|
|
487
|
-
|
|
600
|
+
All three are part of the frozen 1.0.0 surface -- `costOf`/`capacityFor` (measured capacity accounting), `enableLabels`/`labelOf` (devtools identity), and `auditReactive` (leak audit), all cold-path or opt-in with the hot canon untouched. Private-member support remains out. The `llms.txt` scope note tracks exactly what is and isn't included.
|
|
488
601
|
|
|
489
602
|
---
|
|
490
603
|
|
package/SignalDecorators.d.ts
CHANGED
|
@@ -239,6 +239,33 @@ export function defineReactive<C extends new (...args: any[]) => any>(
|
|
|
239
239
|
*/
|
|
240
240
|
export function disposeReactive(vm: object): boolean;
|
|
241
241
|
|
|
242
|
+
/**
|
|
243
|
+
* Release a live reactive instance to the engine pool (PARKED state): cascade its
|
|
244
|
+
* anchor, dispose each signal box, and swap every slot to a parked handle that
|
|
245
|
+
* throws a parked-specific {@link ReactiveDisposedError} on touch. The instance
|
|
246
|
+
* keeps its prebuilt wiring closures so {@link reinitReactive} revives it with
|
|
247
|
+
* zero new closure allocation. Idempotent on an already-parked instance (returns
|
|
248
|
+
* `false`, mirroring {@link disposeReactive}); returns `true` on the first
|
|
249
|
+
* successful release.
|
|
250
|
+
*
|
|
251
|
+
* @throws if `vm` is not a reactive instance, is unwired, is frozen, or was
|
|
252
|
+
* terminally disposed (a disposed instance cannot be pooled).
|
|
253
|
+
*/
|
|
254
|
+
export function releaseReactive(vm: object): boolean;
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Revive a PARKED reactive instance (see {@link releaseReactive}): rebuild its
|
|
258
|
+
* signal boxes -- using `initials`' values where given, else each member's
|
|
259
|
+
* declared initial -- then rebuild the anchor, deriveds, and effects through the
|
|
260
|
+
* instance's prebuilt closures. Atomicity matches construction: any throw
|
|
261
|
+
* mid-reinit leaves the instance terminally disposed. Returns the same `vm`.
|
|
262
|
+
*
|
|
263
|
+
* @param initials optional map of `@reactive` keys to reset values (a non-signal
|
|
264
|
+
* or unknown key throws with a did-you-mean hint).
|
|
265
|
+
* @throws if `vm` is live, disposed, frozen, unwired, or not a reactive instance.
|
|
266
|
+
*/
|
|
267
|
+
export function reinitReactive<T extends object>(vm: T, initials?: Record<PropertyKey, unknown>): T;
|
|
268
|
+
|
|
242
269
|
/**
|
|
243
270
|
* Return the live {@link SignalBox} / {@link ComputedBox} backing a reactive
|
|
244
271
|
* member -- for interop with raw lite-signal code and devtools.
|
|
@@ -366,4 +393,4 @@ export class ReactiveDisposedError extends Error {
|
|
|
366
393
|
// --- Version ------------------------------------------------------------------
|
|
367
394
|
|
|
368
395
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
369
|
-
export const VERSION: "0.
|
|
396
|
+
export const VERSION: "1.0.0";
|
package/SignalDecorators.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators
|
|
2
|
+
* @zakkster/lite-signal-decorators v1.1.0
|
|
3
3
|
* --------------------
|
|
4
4
|
* Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into
|
|
5
5
|
* a reactive view-model with measured per-instance cost and deterministic
|
|
@@ -67,13 +67,35 @@ const HOST_MARK = Symbol("lite-signal-decorators.host");
|
|
|
67
67
|
// The instance's anchor NodeDescriptor lives here; DISPOSED after teardown.
|
|
68
68
|
const ANCHOR = Symbol("lite-signal-decorators.anchor");
|
|
69
69
|
|
|
70
|
-
// Marks the poison/prewired handles so boxOf/rootOf recognize them
|
|
71
|
-
// calling get() (PD-4): value is "disposed" or "
|
|
70
|
+
// Marks the poison/prewired/parked handles so boxOf/rootOf recognize them
|
|
71
|
+
// without calling get() (PD-4): value is "disposed", "prewired", or "parked".
|
|
72
72
|
const NONLIVE = Symbol("lite-signal-decorators.nonlive");
|
|
73
73
|
|
|
74
74
|
// Frozen sentinel written to ANCHOR on dispose (idempotency signal, PD-7).
|
|
75
75
|
const DISPOSED = Object.freeze({ [NONLIVE]: "disposed" });
|
|
76
76
|
|
|
77
|
+
// PD-44: frozen sentinel written to ANCHOR on releaseReactive(), the PARKED
|
|
78
|
+
// state. Distinct from DISPOSED so the lattice tells a pooled instance (revivable
|
|
79
|
+
// by reinitReactive) from a terminally-disposed one; both carry NONLIVE so
|
|
80
|
+
// disposeCore/boxOf/rootOf classify them without touching a live node.
|
|
81
|
+
const PARKED = Object.freeze({ [NONLIVE]: "parked" });
|
|
82
|
+
|
|
83
|
+
// PD-42: the per-instance prebuilt closure set (S6-T2). Built at first wiring
|
|
84
|
+
// (transient) and rebuilt+RETAINED at first releaseReactive, then reused by every
|
|
85
|
+
// acquire (buildGraph) so a reinit allocates ZERO new closures (0010 Q3). Holds
|
|
86
|
+
// the createRoot thunk, the anchor effect body, the runWithOwner thunk, and the
|
|
87
|
+
// per-derived and per-effect bodies. Kept in one module-private slot on the
|
|
88
|
+
// instance; a construct-once/dispose-once instance never stores it (S6-A6: the
|
|
89
|
+
// prebuild adds no retained construction cost, so churn-soak's maxMajor 0 holds).
|
|
90
|
+
const CLOSURES = Symbol("lite-signal-decorators.closures");
|
|
91
|
+
|
|
92
|
+
// PD-44: decorator-signal initials for reinit value reset. A decorator signal's
|
|
93
|
+
// initial is its field-initializer value, captured (per member, first-seen) in
|
|
94
|
+
// makeInit -- NOT retained per instance, so construct-once churn pays nothing.
|
|
95
|
+
// Buildless signals reset via their plan initFn instead; a caller override always
|
|
96
|
+
// wins. Keyed by the frozen signal rec (bounded by the class member count).
|
|
97
|
+
const SIG_INITIAL = new WeakMap();
|
|
98
|
+
|
|
77
99
|
// Scratch-frame stack (D-2h): decorator signal boxes are created in accessor
|
|
78
100
|
// `init` during super()'s field initialization -- BEFORE wireInstance's
|
|
79
101
|
// try/catch exists. Each init pushes its box here; the wrapper constructor
|
|
@@ -162,13 +184,18 @@ const REG_METHODS = [
|
|
|
162
184
|
// --- ReactiveDisposedError ----------------------------------------------------
|
|
163
185
|
|
|
164
186
|
/**
|
|
165
|
-
* Thrown when a disposed reactive member (or root) is read or written.
|
|
166
|
-
* the originating class name and the member key for actionable
|
|
187
|
+
* Thrown when a disposed OR parked reactive member (or root) is read or written.
|
|
188
|
+
* Carries the originating class name and the member key for actionable
|
|
189
|
+
* diagnostics. The optional `parked` flag selects a pooled-lifetime message so a
|
|
190
|
+
* touch on a released-to-pool instance reads differently from a zombie (PD-44) --
|
|
191
|
+
* one error class, two states, no surface growth.
|
|
167
192
|
*/
|
|
168
193
|
export class ReactiveDisposedError extends Error {
|
|
169
|
-
constructor(className, key) {
|
|
194
|
+
constructor(className, key, parked) {
|
|
170
195
|
super(
|
|
171
|
-
|
|
196
|
+
parked
|
|
197
|
+
? `${ERR}${className}.${String(key)} was released to the pool (parked) -- call reinitReactive() to revive it before use`
|
|
198
|
+
: `${ERR}${className}.${String(key)} was used after disposeReactive() -- the reactive graph is gone`,
|
|
172
199
|
);
|
|
173
200
|
this.name = "ReactiveDisposedError";
|
|
174
201
|
this.className = className;
|
|
@@ -325,9 +352,13 @@ function throwNotWired(what) {
|
|
|
325
352
|
);
|
|
326
353
|
}
|
|
327
354
|
|
|
328
|
-
function throwSelfDisposeInDerived(ctorName, key) {
|
|
355
|
+
function throwSelfDisposeInDerived(ctorName, key, op) {
|
|
356
|
+
// `op` defaults to disposeReactive so the 1.0.0 call site's message stays
|
|
357
|
+
// byte-identical; releaseReactive passes its own name (same fail-open hazard).
|
|
358
|
+
const fn = op === undefined ? "disposeReactive" : op;
|
|
359
|
+
const verb = op === undefined ? "Dispose" : "Release";
|
|
329
360
|
throw new Error(
|
|
330
|
-
`${ERR}
|
|
361
|
+
`${ERR}${fn}(${ctorName}) was called from inside its own @derived ${keyLabel(key)} computation -- derived getters must be pure. ${verb} from an effect, a subscription, or plain code instead.`,
|
|
331
362
|
);
|
|
332
363
|
}
|
|
333
364
|
|
|
@@ -353,6 +384,51 @@ function throwPrewiredMember(ctorName, key) {
|
|
|
353
384
|
);
|
|
354
385
|
}
|
|
355
386
|
|
|
387
|
+
function throwReleaseDisposed(ctorName) {
|
|
388
|
+
throw new Error(
|
|
389
|
+
`${ERR}releaseReactive(${ctorName}) -- the instance was disposed (terminal) and cannot be released to the pool. disposeReactive is final; releaseReactive parks a LIVE instance for reinitReactive to revive.`,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function throwReleaseFrozen(ctorName) {
|
|
394
|
+
throw new TypeError(
|
|
395
|
+
`${ERR}releaseReactive(${ctorName}) -- the instance is frozen, so the parked-handle swap cannot be installed. Do not freeze a live reactive instance.`,
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function throwReinitLive(ctorName) {
|
|
400
|
+
throw new Error(
|
|
401
|
+
`${ERR}reinitReactive(${ctorName}) -- the instance is live; call releaseReactive() to park it before reinitReactive() revives it.`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function throwReinitDisposed(ctorName) {
|
|
406
|
+
throw new Error(
|
|
407
|
+
`${ERR}reinitReactive(${ctorName}) -- the instance was disposed (terminal); a disposed instance cannot be revived. Construct a fresh one.`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function throwReinitFrozen(ctorName) {
|
|
412
|
+
throw new TypeError(
|
|
413
|
+
`${ERR}reinitReactive(${ctorName}) -- the instance is frozen, so live handles cannot be restored into its slots. Do not freeze a parked instance.`,
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function throwReinitInitials(ctorName) {
|
|
418
|
+
throw new TypeError(
|
|
419
|
+
`${ERR}reinitReactive(${ctorName}, initials) -- initials must be an object mapping @reactive keys to their reset values.`,
|
|
420
|
+
);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function throwReinitInitialsKey(ctorName, key, plan) {
|
|
424
|
+
const avail = [];
|
|
425
|
+
for (let i = 0; i < plan.signals.length; i++) avail.push(keyLabel(plan.signals[i].key));
|
|
426
|
+
const near = nearestKey(keyLabel(key), avail);
|
|
427
|
+
throw new Error(
|
|
428
|
+
`${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal${near ? ` -- did you mean \`${near}\`?` : ""} Signals: ${avail.join(", ")}.`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
356
432
|
function throwNoBox(ctorName, key, kind) {
|
|
357
433
|
const what = kind === "effect" ? "@reactiveEffect" : "@batched";
|
|
358
434
|
throw new Error(
|
|
@@ -480,6 +556,9 @@ function makeInit(rec) {
|
|
|
480
556
|
const box = rec.plan.reg.signalBox(v, rec.opts);
|
|
481
557
|
this[rec.slot] = box;
|
|
482
558
|
SCRATCH.push(box); // D-2h: track for init-phase rollback
|
|
559
|
+
// PD-44: record the first-seen field-initializer value as this decorator
|
|
560
|
+
// signal's reinit reset value (per member, once; no per-instance retention).
|
|
561
|
+
if (!SIG_INITIAL.has(rec)) SIG_INITIAL.set(rec, v);
|
|
483
562
|
return v; // emitter backing store, unused
|
|
484
563
|
};
|
|
485
564
|
}
|
|
@@ -569,6 +648,7 @@ function applyReactive(target, ctx, opts) {
|
|
|
569
648
|
plan: null,
|
|
570
649
|
poison: null,
|
|
571
650
|
prewired: null,
|
|
651
|
+
parked: null,
|
|
572
652
|
initFn: null, // decorator boxes are born at init
|
|
573
653
|
};
|
|
574
654
|
PENDING.push(rec);
|
|
@@ -609,6 +689,7 @@ function applyDerived(value, ctx, opts) {
|
|
|
609
689
|
plan: null,
|
|
610
690
|
poison: null,
|
|
611
691
|
prewired: null,
|
|
692
|
+
parked: null,
|
|
612
693
|
initFn: null,
|
|
613
694
|
};
|
|
614
695
|
PENDING.push(rec);
|
|
@@ -761,6 +842,15 @@ function buildHandles(rec, ctorName) {
|
|
|
761
842
|
get() { throw new TypeError(msg); },
|
|
762
843
|
set(v) { throw new TypeError(msg); },
|
|
763
844
|
});
|
|
845
|
+
// PD-44: parked handle -- swapped into every slot at releaseReactive(). A
|
|
846
|
+
// touch on a pooled instance throws a parked-specific ReactiveDisposedError
|
|
847
|
+
// (naming Class.prop) so a use-after-release reads differently from a
|
|
848
|
+
// use-after-dispose. Frozen, NONLIVE-tagged so disposeCore skips it.
|
|
849
|
+
rec.parked = Object.freeze({
|
|
850
|
+
[NONLIVE]: "parked",
|
|
851
|
+
get() { throw new ReactiveDisposedError(ctorName, key, true); },
|
|
852
|
+
set(v) { throw new ReactiveDisposedError(ctorName, key, true); },
|
|
853
|
+
});
|
|
764
854
|
}
|
|
765
855
|
|
|
766
856
|
function nearestAncestorPlan(C) {
|
|
@@ -871,6 +961,69 @@ function claimPlan(C, ctorName, registry) {
|
|
|
871
961
|
function makeDerivedBody(inst, fn) { return function () { return fn.call(inst, inst); }; }
|
|
872
962
|
function makeEffectBody(inst, fn) { return function () { return fn.call(inst, inst); }; }
|
|
873
963
|
|
|
964
|
+
// PD-42: build the per-instance closure set -- the createRoot thunk, the anchor
|
|
965
|
+
// effect body (writes the owner straight into inst[ANCHOR]), the runWithOwner
|
|
966
|
+
// thunk (rebuilds deriveds + effects), one body per derived, one per effect. The
|
|
967
|
+
// engine retains nothing of a disposed registration (0010 Q3), so these exact
|
|
968
|
+
// closure objects re-register on every acquire (buildGraph) with zero new
|
|
969
|
+
// allocation. Built LAZILY at first releaseReactive and retained on the instance,
|
|
970
|
+
// so a reused instance amortizes the closure cost to zero across acquire/release
|
|
971
|
+
// cycles -- while a construct-once/dispose-once instance never allocates it (0011:
|
|
972
|
+
// building it at first wiring measured 140 / 1 major GC in churn-soak, both over
|
|
973
|
+
// the maxMajor-0 floor; the construct path stays byte-identical to 1.0.0). Cold.
|
|
974
|
+
function prebuildClosures(inst, plan) {
|
|
975
|
+
const reg = plan.reg;
|
|
976
|
+
const ders = plan.deriveds;
|
|
977
|
+
const effs = plan.effects;
|
|
978
|
+
const derivedBodies = new Array(ders.length);
|
|
979
|
+
for (let i = 0; i < ders.length; i++) derivedBodies[i] = makeDerivedBody(inst, ders[i].fn);
|
|
980
|
+
const effectBodies = new Array(effs.length);
|
|
981
|
+
for (let i = 0; i < effs.length; i++) effectBodies[i] = makeEffectBody(inst, effs[i].fn);
|
|
982
|
+
const anchorBody = function () { inst[ANCHOR] = reg.getOwner(); };
|
|
983
|
+
const bundle = {
|
|
984
|
+
createRootThunk: function () { reg.effect(anchorBody); },
|
|
985
|
+
runOwnerThunk: null,
|
|
986
|
+
derivedBodies,
|
|
987
|
+
effectBodies,
|
|
988
|
+
};
|
|
989
|
+
// The runWithOwner thunk carries the SAME OFF/ON introspection branch the
|
|
990
|
+
// 1.0.0 wireInstance carried; the flags are read at CALL time, so a prebuilt
|
|
991
|
+
// closure honors a later enableLabels()/auditReactive() exactly as before.
|
|
992
|
+
// Effects wire AFTER every derived (D-4a): the first synchronous run sees
|
|
993
|
+
// every field and every derived. Dispose handles are DISCARDED -- teardown is
|
|
994
|
+
// the anchor cascade.
|
|
995
|
+
bundle.runOwnerThunk = function () {
|
|
996
|
+
for (let i = 0; i < ders.length; i++) {
|
|
997
|
+
inst[ders[i].slot] = reg.computedBox(derivedBodies[i], ders[i].opts);
|
|
998
|
+
}
|
|
999
|
+
if (INTROSPECT_ON) {
|
|
1000
|
+
const effHandles = LABELS_ON ? [] : null;
|
|
1001
|
+
for (let i = 0; i < effs.length; i++) {
|
|
1002
|
+
const h = reg.effect(effectBodies[i], effs[i].opts);
|
|
1003
|
+
if (effHandles !== null) effHandles.push(h);
|
|
1004
|
+
}
|
|
1005
|
+
introspectWire(inst, plan, reg, effHandles);
|
|
1006
|
+
} else {
|
|
1007
|
+
for (let i = 0; i < effs.length; i++) {
|
|
1008
|
+
reg.effect(effectBodies[i], effs[i].opts);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
return bundle;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// The node-building body invoked by reinit (S6-T2): build the R-A anchor, then the
|
|
1016
|
+
// deriveds + effects under it, all through a PREBUILT closure set. Signal boxes
|
|
1017
|
+
// are NOT built here -- reinit creates them first (all boxes, with reset values) --
|
|
1018
|
+
// because the value source differs between construction and reinit while the
|
|
1019
|
+
// anchor/derived/effect build is identical. wireInstance keeps its own inline node
|
|
1020
|
+
// build (below) so the construct-once path allocates exactly as 1.0.0 did (0011).
|
|
1021
|
+
function buildGraph(inst, plan, closures) {
|
|
1022
|
+
const reg = plan.reg;
|
|
1023
|
+
reg.createRoot(closures.createRootThunk); // R-A anchor -> inst[ANCHOR]
|
|
1024
|
+
reg.runWithOwner(inst[ANCHOR], closures.runOwnerThunk);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
874
1027
|
function wireInstance(inst, plan) {
|
|
875
1028
|
const reg = plan.reg;
|
|
876
1029
|
// The WHOLE wiring phase is atomic (D-2h): the buildless box loop and the
|
|
@@ -926,7 +1079,10 @@ function wireInstance(inst, plan) {
|
|
|
926
1079
|
function disposeCore(inst, plan) { // assumes not already disposed
|
|
927
1080
|
const reg = plan.reg;
|
|
928
1081
|
const a = inst[ANCHOR];
|
|
929
|
-
|
|
1082
|
+
// PARKED holds no live anchor node (releaseReactive already cascaded it), so a
|
|
1083
|
+
// dispose-on-parked must NOT re-dispose the sentinel -- it only swaps the
|
|
1084
|
+
// parked handles to poison below and lands the instance DISPOSED.
|
|
1085
|
+
if (a !== undefined && a !== DISPOSED && a !== PARKED) reg.dispose(a); // cascades deriveds + effects
|
|
930
1086
|
const sigs = plan.signals;
|
|
931
1087
|
for (let i = 0; i < sigs.length; i++) {
|
|
932
1088
|
const r = sigs[i];
|
|
@@ -1045,6 +1201,7 @@ function makeSignalRecFromValue(key, initFn, opts) {
|
|
|
1045
1201
|
plan: null,
|
|
1046
1202
|
poison: null,
|
|
1047
1203
|
prewired: null,
|
|
1204
|
+
parked: null,
|
|
1048
1205
|
initFn,
|
|
1049
1206
|
};
|
|
1050
1207
|
}
|
|
@@ -1117,6 +1274,7 @@ function makeDerivedRec(key, fn, opts) {
|
|
|
1117
1274
|
plan: null,
|
|
1118
1275
|
poison: null,
|
|
1119
1276
|
prewired: null,
|
|
1277
|
+
parked: null,
|
|
1120
1278
|
initFn: null,
|
|
1121
1279
|
};
|
|
1122
1280
|
}
|
|
@@ -1312,6 +1470,129 @@ export function disposeReactive(vm) {
|
|
|
1312
1470
|
return true;
|
|
1313
1471
|
}
|
|
1314
1472
|
|
|
1473
|
+
// --- Pooled lifecycle: release + reinit (S6-T3, PD-42(b)/PD-44) ---------------
|
|
1474
|
+
|
|
1475
|
+
// The cold inverse of buildGraph: tear the graph down to the engine pool exactly
|
|
1476
|
+
// as disposeCore does (anchor cascade + per-box dispose) but swap every slot to
|
|
1477
|
+
// its per-class PARKED handle (not poison), keep the prebuilt CLOSURES slot, and
|
|
1478
|
+
// set ANCHOR to the PARKED sentinel. A parked instance holds ZERO engine nodes.
|
|
1479
|
+
function releaseCore(inst, plan) { // assumes a LIVE instance
|
|
1480
|
+
const reg = plan.reg;
|
|
1481
|
+
const a = inst[ANCHOR];
|
|
1482
|
+
if (a !== undefined && a !== DISPOSED && a !== PARKED) reg.dispose(a); // cascades deriveds + effects
|
|
1483
|
+
const sigs = plan.signals;
|
|
1484
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
1485
|
+
const r = sigs[i];
|
|
1486
|
+
const box = inst[r.slot];
|
|
1487
|
+
if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
|
|
1488
|
+
inst[r.slot] = r.parked;
|
|
1489
|
+
}
|
|
1490
|
+
const ders = plan.deriveds;
|
|
1491
|
+
for (let i = 0; i < ders.length; i++) {
|
|
1492
|
+
inst[ders[i].slot] = ders[i].parked; // cboxes already cascaded
|
|
1493
|
+
}
|
|
1494
|
+
inst[ANCHOR] = PARKED;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
/**
|
|
1498
|
+
* Release a live reactive instance to the engine pool: cascade its anchor,
|
|
1499
|
+
* dispose each signal box, and swap every slot to a PARKED handle that throws a
|
|
1500
|
+
* parked-specific `ReactiveDisposedError` on touch. The instance keeps its
|
|
1501
|
+
* prebuilt wiring closures so `reinitReactive(vm)` can revive it with zero new
|
|
1502
|
+
* closure allocation. Idempotent on an already-parked instance (returns `false`,
|
|
1503
|
+
* mirroring `disposeReactive`'s double-dispose contract); returns `true` on the
|
|
1504
|
+
* first successful release. Fails closed (named throw) on a non-reactive value, an
|
|
1505
|
+
* unwired instance, a frozen instance, or a terminally-disposed one -- a disposed
|
|
1506
|
+
* instance is gone and cannot be pooled (0011).
|
|
1507
|
+
*/
|
|
1508
|
+
export function releaseReactive(vm) {
|
|
1509
|
+
const plan = planOf(vm);
|
|
1510
|
+
if (plan === undefined) throwNoPlan("releaseReactive");
|
|
1511
|
+
const a = vm[ANCHOR];
|
|
1512
|
+
if (a === PARKED) return false; // idempotent park->park (0011)
|
|
1513
|
+
if (a === DISPOSED) throwReleaseDisposed(plan.ctorName);
|
|
1514
|
+
if (a === undefined) throwNotWired("releaseReactive");
|
|
1515
|
+
if (Object.isFrozen(vm)) throwReleaseFrozen(plan.ctorName);
|
|
1516
|
+
const reg = plan.reg;
|
|
1517
|
+
// Same re-entrancy guard disposeReactive carries (D-2f): releasing from inside
|
|
1518
|
+
// one of this instance's OWN @derived computations would cascade the very node
|
|
1519
|
+
// being computed (fail-open). The isTracking() gate keeps the plain-code path
|
|
1520
|
+
// zero-alloc; only under tracking do we pay one getOwner() descriptor.
|
|
1521
|
+
if (reg.isTracking()) {
|
|
1522
|
+
const cur = reg.getOwner();
|
|
1523
|
+
if (cur !== undefined) {
|
|
1524
|
+
const ders = plan.deriveds;
|
|
1525
|
+
for (let i = 0; i < ders.length; i++) {
|
|
1526
|
+
const h = vm[ders[i].slot];
|
|
1527
|
+
if (h !== undefined && h[NONLIVE] === undefined && reg.nodeId(h) === cur.id) {
|
|
1528
|
+
throwSelfDisposeInDerived(plan.ctorName, ders[i].key, "releaseReactive");
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
if (INTROSPECT_ON) introspectDispose(vm, reg);
|
|
1534
|
+
// Retain the prebuilt closure set on first release (reuse intent now known):
|
|
1535
|
+
// every later reinit re-registers these exact closures with zero new
|
|
1536
|
+
// allocation (0010 Q3), amortizing the cost to zero across acquire/release.
|
|
1537
|
+
if (vm[CLOSURES] === undefined) vm[CLOSURES] = prebuildClosures(vm, plan);
|
|
1538
|
+
releaseCore(vm, plan);
|
|
1539
|
+
return true;
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
/**
|
|
1543
|
+
* Revive a PARKED reactive instance: rebuild its signal boxes (with `initials`'
|
|
1544
|
+
* values where given, else the plan's initials), rebuild the anchor, deriveds,
|
|
1545
|
+
* and effects through the PREBUILT closures, and restore every slot to a live
|
|
1546
|
+
* handle. Atomicity is identical to construction -- any throw mid-reinit routes
|
|
1547
|
+
* through disposeCore, leaving conservation exact and the instance terminally
|
|
1548
|
+
* DISPOSED (a failed revival is final; fail closed). Returns the same `vm`.
|
|
1549
|
+
* Requires PARKED: fails closed (named throw) on a live, disposed, frozen,
|
|
1550
|
+
* unwired, or non-reactive value -- null is not zero.
|
|
1551
|
+
*/
|
|
1552
|
+
export function reinitReactive(vm, initials) {
|
|
1553
|
+
const plan = planOf(vm);
|
|
1554
|
+
if (plan === undefined) throwNoPlan("reinitReactive");
|
|
1555
|
+
const a = vm[ANCHOR];
|
|
1556
|
+
if (a === DISPOSED) throwReinitDisposed(plan.ctorName);
|
|
1557
|
+
if (a === undefined) throwNotWired("reinitReactive");
|
|
1558
|
+
if (a !== PARKED) throwReinitLive(plan.ctorName); // a live anchor node
|
|
1559
|
+
if (Object.isFrozen(vm)) throwReinitFrozen(plan.ctorName);
|
|
1560
|
+
if (initials !== undefined) {
|
|
1561
|
+
if (initials === null || typeof initials !== "object") throwReinitInitials(plan.ctorName);
|
|
1562
|
+
const ikeys = Reflect.ownKeys(initials);
|
|
1563
|
+
for (let i = 0; i < ikeys.length; i++) {
|
|
1564
|
+
const rec = plan.byKey.get(ikeys[i]);
|
|
1565
|
+
if (rec === undefined || rec.kind !== "signal") throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
const reg = plan.reg;
|
|
1569
|
+
const closures = vm[CLOSURES];
|
|
1570
|
+
try {
|
|
1571
|
+
// Rebuild every signal box with its reset value: caller override first,
|
|
1572
|
+
// then the buildless plan initFn, then the decorator field-initial captured
|
|
1573
|
+
// per member in makeInit. Boxes rebuild BEFORE buildGraph so deriveds and
|
|
1574
|
+
// effects see them on the first synchronous run (D-4a), same as construction.
|
|
1575
|
+
const sigs = plan.signals;
|
|
1576
|
+
for (let i = 0; i < sigs.length; i++) {
|
|
1577
|
+
const r = sigs[i];
|
|
1578
|
+
let v;
|
|
1579
|
+
if (initials !== undefined && Object.prototype.hasOwnProperty.call(initials, r.key)) {
|
|
1580
|
+
v = initials[r.key];
|
|
1581
|
+
} else if (r.initFn !== null) {
|
|
1582
|
+
v = r.initFn(vm);
|
|
1583
|
+
} else {
|
|
1584
|
+
v = SIG_INITIAL.get(r);
|
|
1585
|
+
}
|
|
1586
|
+
vm[r.slot] = reg.signalBox(v, r.opts);
|
|
1587
|
+
}
|
|
1588
|
+
buildGraph(vm, plan, closures);
|
|
1589
|
+
} catch (e) {
|
|
1590
|
+
disposeCore(vm, plan); // failed revival is terminal -> DISPOSED
|
|
1591
|
+
throw e;
|
|
1592
|
+
}
|
|
1593
|
+
return vm;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1315
1596
|
/**
|
|
1316
1597
|
* Return the live SignalBox/ComputedBox backing a reactive member. Throws
|
|
1317
1598
|
* `ReactiveDisposedError` if the instance was disposed, and a named error for an
|
|
@@ -1327,6 +1608,7 @@ export function boxOf(vm, key) {
|
|
|
1327
1608
|
if (h === undefined || h === null) throwNotWired("boxOf");
|
|
1328
1609
|
const nl = h[NONLIVE];
|
|
1329
1610
|
if (nl === "disposed") throw new ReactiveDisposedError(plan.ctorName, key);
|
|
1611
|
+
if (nl === "parked") throw new ReactiveDisposedError(plan.ctorName, key, true);
|
|
1330
1612
|
if (nl === "prewired") throwPrewiredMember(plan.ctorName, key);
|
|
1331
1613
|
return h;
|
|
1332
1614
|
}
|
|
@@ -1342,6 +1624,7 @@ export function rootOf(vm) {
|
|
|
1342
1624
|
const a = vm[ANCHOR];
|
|
1343
1625
|
if (a === undefined) throwNotWired("rootOf");
|
|
1344
1626
|
if (a === DISPOSED) throw new ReactiveDisposedError(plan.ctorName, "<root>");
|
|
1627
|
+
if (a === PARKED) throw new ReactiveDisposedError(plan.ctorName, "<root>", true);
|
|
1345
1628
|
return a;
|
|
1346
1629
|
}
|
|
1347
1630
|
|
|
@@ -1661,4 +1944,4 @@ export function auditReactive(on) {
|
|
|
1661
1944
|
// --- Version ------------------------------------------------------------------
|
|
1662
1945
|
|
|
1663
1946
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
1664
|
-
export const VERSION = "
|
|
1947
|
+
export const VERSION = "1.1.0";
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @zakkster/lite-signal-decorators
|
|
2
2
|
|
|
3
|
-
VERSION
|
|
3
|
+
VERSION 1.1.0
|
|
4
4
|
|
|
5
5
|
> Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into a
|
|
6
6
|
> reactive view-model where each instance has a measured per-property cost, a
|
|
@@ -20,7 +20,7 @@ zero decorator syntax, sharing the SAME core by function identity.
|
|
|
20
20
|
slot for a poison handle, so any later read/write throws a named
|
|
21
21
|
`ReactiveDisposedError`.
|
|
22
22
|
|
|
23
|
-
## Exports (
|
|
23
|
+
## Exports (18)
|
|
24
24
|
|
|
25
25
|
- `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
|
|
26
26
|
(factory). Declares a per-instance signal.
|
|
@@ -48,6 +48,18 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
48
48
|
buildless contract.
|
|
49
49
|
- `disposeReactive(vm) -> boolean` -- cascade + poison teardown. Idempotent: a
|
|
50
50
|
second call returns `false` and changes nothing.
|
|
51
|
+
- `releaseReactive(vm) -> boolean` -- park a LIVE instance to the engine pool:
|
|
52
|
+
cascade the anchor, dispose each signal box, swap every slot to a per-class
|
|
53
|
+
PARKED handle, keep the prebuilt wiring closures. A parked instance holds ZERO
|
|
54
|
+
engine nodes. `true` on the first release, `false` on park->park (idempotent,
|
|
55
|
+
mirrors double-dispose). Fails closed on a disposed, unwired, frozen, or
|
|
56
|
+
non-reactive value. See the pooled-lifetime contract (decisions/0010, 0011).
|
|
57
|
+
- `reinitReactive(vm, initials?) -> vm` -- revive a PARKED instance: rebuild each
|
|
58
|
+
signal box (`initials[key]` wins, else the plan initial), rebuild the anchor +
|
|
59
|
+
deriveds + effects through the SAME prebuilt closures, restore live slots.
|
|
60
|
+
Atomic: any throw mid-reinit routes through disposeCore and lands the instance
|
|
61
|
+
DISPOSED (a failed revival is final). Fails closed on a live, disposed, frozen,
|
|
62
|
+
unwired, or non-reactive value. Two-call lifecycle by design (decisions/0011).
|
|
51
63
|
- `boxOf(vm, key) -> SignalBox | ComputedBox` -- the live box behind a member;
|
|
52
64
|
throws with a did-you-mean on an unknown key, `ReactiveDisposedError` after
|
|
53
65
|
dispose.
|
|
@@ -68,7 +80,7 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
68
80
|
`FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
|
|
69
81
|
- `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
|
|
70
82
|
fields `className` and `key`.
|
|
71
|
-
- `VERSION` -- `"
|
|
83
|
+
- `VERSION` -- `"1.1.0"`.
|
|
72
84
|
|
|
73
85
|
## Registry law (one registry per host chain)
|
|
74
86
|
|
|
@@ -185,14 +197,52 @@ throw a named error.
|
|
|
185
197
|
Requires `@zakkster/lite-signal` `>=1.5.0 <2.0.0` (peer dependency). Uses
|
|
186
198
|
`signalBox`, `computedBox`, `effect`, `createRoot`, `getOwner`, `runWithOwner`,
|
|
187
199
|
`dispose`, `nodeId`, `isTracking`, `batch`, and `untrack` from it (all 11 also
|
|
188
|
-
routed per-registry via `createRegistry()`).
|
|
200
|
+
routed per-registry via `createRegistry()`). The RANGE floor is 1.5.0 and stays
|
|
201
|
+
1.5.0 -- everything the runtime surface needs shipped in 1.5.0 stable. Two
|
|
202
|
+
forward-compat torture scenarios are feature-detected above that floor and SKIP
|
|
203
|
+
below it (a skip below the floor is correct; at or above it is a failure): the
|
|
204
|
+
`createScope` adoption scenario needs peer 1.6.0, and the engine
|
|
205
|
+
`[Symbol.dispose]`/`using` interop scenario needs peer 1.9.0. Neither is a
|
|
206
|
+
runtime requirement -- the shipped surface runs on 1.5.0.
|
|
189
207
|
|
|
190
208
|
## Scope note
|
|
191
209
|
|
|
192
|
-
0.
|
|
193
|
-
|
|
210
|
+
1.0.0 FROZE the export surface at 16; 1.1.0 adds `releaseReactive` +
|
|
211
|
+
`reinitReactive` (an additive MINOR under that promise) -> 18 exports: the 13
|
|
212
|
+
runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
|
|
194
213
|
`auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
|
|
195
|
-
0.3.0).
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
214
|
+
0.3.0). The semver promise from here: any change to an existing export's
|
|
215
|
+
signature or behavior is a MAJOR, recorded in a decision file; new exports are
|
|
216
|
+
minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
|
|
217
|
+
major. Also present since
|
|
218
|
+
0.3.0: the dev-side class-reactivity benchmark (`bench/`, private, never
|
|
219
|
+
shipped) and the fleet-soak torture scenario; the formal kill-criteria verdicts
|
|
220
|
+
live in decisions/0006-kill-criteria.md. Composition recipes over this frozen
|
|
221
|
+
surface (the MobX-parity-by-composition matrix, the two-plane fleet, the
|
|
222
|
+
lite-store boundary) live in COOKBOOK.md, delivered GitHub-only and NOT in the
|
|
223
|
+
tarball -- read it at
|
|
224
|
+
https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md
|
|
225
|
+
(every code block byte-verified against a GC-gated companion; decisions/0009). Still OUT of the 1.1 surface (each a
|
|
226
|
+
deliberate, documented exclusion, not an oversight): private (`#`) members,
|
|
227
|
+
static members, and module/global signals (raw lite-signal territory).
|
|
228
|
+
|
|
229
|
+
## Pooled lifetime (1.1.0)
|
|
230
|
+
|
|
231
|
+
An instance is no longer single-lifetime. It moves on a three-state lattice --
|
|
232
|
+
LIVE / PARKED / DISPOSED -- driven by a two-call API. `releaseReactive(vm)` parks
|
|
233
|
+
a LIVE instance (cascades the anchor, disposes each box, holds ZERO engine nodes,
|
|
234
|
+
keeps its prebuilt wiring closures); `reinitReactive(vm, initials?)` revives a
|
|
235
|
+
PARKED one (rebuilds boxes + anchor + deriveds + effects through the SAME
|
|
236
|
+
closures, values reset). Two calls, not one: park and revive are separated in
|
|
237
|
+
time by the pool, and `disposeReactive`'s frozen `(vm) -> boolean` signature
|
|
238
|
+
cannot double as park (decisions/0011). Fail closed: every member touch on a
|
|
239
|
+
PARKED instance throws `ReactiveDisposedError` with a *parked* message (not
|
|
240
|
+
"disposed"), and `boxOf`/`rootOf` agree; a throw mid-reinit routes through
|
|
241
|
+
disposeCore and lands the instance DISPOSED (a failed revival is final); DISPOSED
|
|
242
|
+
is TERMINAL -- only a PARKED instance revives, a disposed one is gone and cannot
|
|
243
|
+
be pooled. park->park is idempotent (`false`); dispose-on-parked lands DISPOSED
|
|
244
|
+
and stays idempotent. The prebuilt closure set is built LAZILY at first
|
|
245
|
+
`releaseReactive`, so the construct-once/dispose-once path is byte-untouched
|
|
246
|
+
(0011); the amortization holds -- one closure build per reused instance,
|
|
247
|
+
re-registered across all N acquire/release cycles with zero new allocation. See
|
|
248
|
+
decisions/0010 (the spike contract, measured) and 0011 (the API shape).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal-decorators",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Stage-3 decorator layer over @zakkster/lite-signal. The reactive class layer where an instance has a measured cost, deterministic teardown, and a churn benchmark.",
|
|
5
5
|
"author": "Zahary Shinikchiev <shinikchiev@yahoo.com>",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,6 +67,8 @@
|
|
|
67
67
|
"torture:soak": "node test/torture/run.mjs --group soak",
|
|
68
68
|
"torture:controls": "node test/torture/run.mjs --controls",
|
|
69
69
|
"torture:peer-preview": "node test/torture/peer-preview.mjs",
|
|
70
|
+
"gate": "node test/gate.mjs",
|
|
71
|
+
"cookbook": "node cookbook/run.mjs",
|
|
70
72
|
"spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
|
|
71
73
|
"spike:emit": "node --expose-gc spikes/emit/probe.mjs",
|
|
72
74
|
"spike:ownership": "node --expose-gc spikes/ownership.mjs",
|
|
@@ -74,7 +76,11 @@
|
|
|
74
76
|
"spike:manual": "node --expose-gc spikes/manual-call.mjs",
|
|
75
77
|
"spike:poison": "node --expose-gc spikes/poison.mjs",
|
|
76
78
|
"spike:buildless": "node --expose-gc spikes/buildless.mjs",
|
|
77
|
-
"fixtures:regen": "node spikes/emit/regen.mjs"
|
|
79
|
+
"fixtures:regen": "node spikes/emit/regen.mjs",
|
|
80
|
+
"demo:build": "node demo/build.mjs",
|
|
81
|
+
"demo:check": "node demo/build.mjs --check",
|
|
82
|
+
"demo:gc": "node --expose-gc demo/gc-lane.mjs",
|
|
83
|
+
"demo:storm": "node --expose-gc demo/storm-lane.mjs"
|
|
78
84
|
},
|
|
79
85
|
"peerDependencies": {
|
|
80
86
|
"@zakkster/lite-signal": ">=1.5.0 <2.0.0"
|
|
@@ -83,10 +89,15 @@
|
|
|
83
89
|
"@babel/core": "^7.25.0",
|
|
84
90
|
"@babel/plugin-proposal-decorators": "^7.25.0",
|
|
85
91
|
"@babel/preset-typescript": "^7.29.7",
|
|
92
|
+
"@zakkster/lite-arena": "1.9.0",
|
|
93
|
+
"@zakkster/lite-await": "1.1.1",
|
|
86
94
|
"@zakkster/lite-devtools": "^1.4.0",
|
|
87
95
|
"@zakkster/lite-gc-profiler": "^1.16.0",
|
|
88
96
|
"@zakkster/lite-leak": "^1.10.0",
|
|
89
97
|
"@zakkster/lite-signal": "1.5.0",
|
|
98
|
+
"@zakkster/lite-store": "1.2.0",
|
|
99
|
+
"@zakkster/lite-watch-ex": "^1.1.0",
|
|
100
|
+
"esbuild": "0.28.2",
|
|
90
101
|
"typescript": "^5.6.0"
|
|
91
102
|
}
|
|
92
103
|
}
|