@zakkster/lite-signal-decorators 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,195 @@ 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.1] - 2026-08-30
8
+
9
+ A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
10
+ accessor canon and every export are byte-identical to 1.1.0. The only test
11
+ changes are `test/15`'s cookbook ground-truth recount for wave 2 and two
12
+ comment-only lines in the gate's step description -- no budget moved anywhere.
13
+ Surface stays at 18 exports; pack stays the same 7-file set.
14
+
15
+ ### Changed
16
+
17
+ - Standards phrasing pass across README, llms.txt, package.json, the main-file
18
+ header, and the catalog card. TC39 lists the decorators proposal (and
19
+ Decorator Metadata) at Stage 2.7 since the 2026-05 plenary, down from Stage 3;
20
+ the emitters are unchanged -- TypeScript 5.x standard emit and Babel `2023-11`
21
+ remain the only real-world paths, no native engine ships decorators. Each doc
22
+ now names the fact once and refers to the protocol by its emitters everywhere
23
+ else, since stage labels drift and emitter names do not. Only the stage LABEL
24
+ moved: the emit matrix, the committed TS/Babel fixtures, and every test are
25
+ byte-untouched by this pass. Recorded in `research/feature-gap-2026-08-30.md` section 1 and
26
+ the `decisions/0011` addendum. Three-place version sync to 1.1.1
27
+ (`package.json`, the `VERSION` const, `llms.txt`).
28
+
29
+ ### Added
30
+
31
+ - Cookbook wave 2: six recipes appended, `r12`..`r17`.
32
+ - `r12` -- Wait for a condition, as a Promise.
33
+ - `r13` -- React to a computed value, not every write (GATED).
34
+ - `r14` -- Tie teardown to an AbortSignal.
35
+ - `r15` -- Async state without async in the graph.
36
+ - `r16` -- Read without subscribing.
37
+ - `r17` -- Start the resource when someone is watching (GATED).
38
+ - Cookbook gated set grows 6 -> 8 (adds `r13`, `r17`).
39
+ - Publications refresh (P3): the outward drafts restamped to the 1.1.x story;
40
+ files stay git-untracked.
41
+ - Bench chart artifact (closes ROADMAP open question 5): `bench/chart.mjs`
42
+ renders `bench/results-chart.svg` -- the CHURN lane (ops/s + heapMed per
43
+ adapter) parsed verbatim from the stamped `bench/results.txt`, deterministic
44
+ and dev-side only (never in `files[]`); wired into the README numbers section.
45
+
46
+ ### Gate output (section-10 chain, archived verbatim)
47
+
48
+ ```
49
+ fixtures OK exit 0 -- emit fixtures regenerated
50
+ test OK exit 0 -- 257 pass / 0 fail
51
+ test:gc OK exit 0 -- 257 pass / 0 fail
52
+ torture OK exit 0 -- 14 passed, 2 skipped, 0 warned, 0 failed in 33.1s
53
+ torture:controls OK exit 0 -- 16 passed, 0 skipped, 0 warned, 0 failed in 2.0s
54
+ 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]
55
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
56
+ cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.0s
57
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
58
+ ----------------------------------------------------------------------
59
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
60
+ ```
61
+
62
+ ## [1.1.0] - 2026-08-30
63
+
64
+ The pooled-lifetime release: an instance is no longer single-lifetime. The
65
+ surface grows 16 -> 18 with the additive `releaseReactive` / `reinitReactive`
66
+ pair (a MINOR under the 1.0.0 semver promise), and this entry also folds in the
67
+ repo-only COOKBOOK work that rode `[Unreleased]` (PD-34). The hot accessor canon
68
+ (`makeGet` / `makeSet` / `makeDerivedGet`) and `wireInstance` are **byte-identical
69
+ to 1.0.0** -- reinit is a cold-path inverse of dispose, the prebuilt closure set
70
+ is built LAZILY at first `releaseReactive`, so the construct-once/dispose-once
71
+ path takes zero new bytes and no new branches (git-diff proven; S6-A4). Dist-tag
72
+ `latest`. Stage gate archived verbatim below: 257/257 tests on both lanes, torture
73
+ 16 scenarios (14 run + 2 forward-compat floor-skips) with 16/16 sabotage controls
74
+ breaking as required, the peer-preview lane SUITE-GREEN per tag, cookbook corpus
75
+ and control sweep green, pack the same 7-file set.
76
+
77
+ ### Added
78
+
79
+ - `releaseReactive(vm) -> boolean` -- park a LIVE instance to the engine pool:
80
+ cascade the anchor, dispose each signal box, swap every slot to a per-class
81
+ PARKED handle (touch throws `ReactiveDisposedError` with a *parked* message),
82
+ keep the prebuilt wiring closures. A parked instance holds ZERO engine nodes.
83
+ `true` on first release, `false` on park->park (idempotent, mirrors
84
+ double-dispose). Fails closed on a disposed, unwired, frozen, or non-reactive
85
+ value; carries the same self-in-`@derived` re-entrancy guard `disposeReactive`
86
+ does. The closure set is built lazily on first release (0011).
87
+ - `reinitReactive(vm, initials?) -> vm` -- revive a PARKED instance: rebuild each
88
+ signal box (`initials[key]` wins, else the plan initial), rebuild anchor +
89
+ deriveds + effects through the SAME prebuilt closures, restore live slots
90
+ (values reset). Atomic -- any throw mid-reinit routes through `disposeCore` and
91
+ lands the instance terminally DISPOSED (a failed revival is final). Fails closed
92
+ on a live, disposed, frozen, unwired, or non-reactive value; `null` is not zero.
93
+ - The pooled-lifetime lattice: LIVE / PARKED / DISPOSED on one axis, with all nine
94
+ transitions pinned in `test/16-reinit.test.mjs` (29 cases, both emit lanes) --
95
+ park->reinit->live (values reset), park->dispose (lands DISPOSED, idempotent
96
+ false thereafter), park->park (false), the five `reinitReactive` fail-closed
97
+ states, parked-touch throwing "parked" not "disposed", and `boxOf`/`rootOf`
98
+ agreement. No new error class (PD-44): the PARKED touch reuses
99
+ `ReactiveDisposedError` with a pooled-lifetime message.
100
+ - `test/torture/reinit-torture.mjs` (group `semantic`, floor 1.5.0) -- the
101
+ acquire/release gate: 4096 pooled cycles at the churn shape (P=4, D=2, E=1) hold
102
+ `maxMajor 0`, `maxPauseMs <= 4.0`, minors CONTROL-RELATIVE (`MINOR_FLOOR + 128`),
103
+ retained delta-heap at/below the in-process zero-alloc control, and exact pool
104
+ conservation (F-0: `activeNodes` to baseline, `poolGrowths` delta 0, ledger
105
+ balanced). Its `TORTURE_BREAK=reinit-torture` control leaks one un-parked
106
+ instance per cycle and exits non-zero. Torture 15 -> 16 scenarios.
107
+ - `bench/scenarios/churn-reuse.mjs` -- the CHURN-shape acquire/release bench lane
108
+ (P=4, D=2, E=1): the `lsd`, `lsd-define`, and hand-wired `lite-raw-boxes` tiers
109
+ pool with retained 0.0KB; `mobx`, `signal-utils`, and `alien-class` return
110
+ `{ unsupported }` with an honest reason (MobX instances are never disposable --
111
+ atoms return only via GC, so there is no release/reinit cycle to pool). Bench
112
+ scenarios 7 -> 8; `bench/results.txt` re-stamped.
113
+ - `spikes/reinit-contract.mjs` -- the S6-T1 spike (four questions answered with
114
+ numbers on the installed 1.5.0 peer before a line of `reinitReactive` was
115
+ written): the engine NODE is fully pooled, the JS HANDLE is fresh-per-call but
116
+ never retains, one prebuilt closure drives N registrations with zero stale
117
+ fires, and stale external handles fail CLOSED (no aliasing). Verdict EXIT A.
118
+ - `decisions/0010-reinit-contract.md` (the spike contract + the four measured
119
+ tables + the peer-registry watch) and `decisions/0011-reinit-api.md` (the
120
+ two-call API shape, the state lattice, the construction-cost finding, and the
121
+ rejected alternatives).
122
+ - `COOKBOOK.md` -- composition recipes over the surface, delivered GitHub-only
123
+ (repo-only, not in `files[]`; decisions/0009): the tarball stays the 7-file
124
+ runtime surface and the shipped README.md and llms.txt point to the cookbook by
125
+ absolute GitHub URL.
126
+ - `cookbook/` (dev-only, never shipped): a runnable companion corpus of 12
127
+ recipes (0-11), six GC-gated with `COOKBOOK_BREAK=<id>` sabotage controls and
128
+ six ungated each carrying a published reason; plus `cookbook/manifest.json`,
129
+ the `cookbook/citations.json` cross-package symbol allowlist, and
130
+ `cookbook/run.mjs` -- the `node --expose-gc` per-recipe runner behind
131
+ `npm run cookbook` (with `--controls` for the sabotage sweep and `--list`).
132
+ - `test/15-cookbook.test.mjs` -- the drift/parity checker: each fenced block in
133
+ `COOKBOOK.md` is byte-compared against its tagged companion `#region`,
134
+ bidirectionally, with a surface-freeze check (now exactly 18 exports; PD-48,
135
+ second-landing session owns the one number), a citation check, a static-cost
136
+ check, and the shipped-doc-link check.
137
+ - `test/gate.mjs` gains a BLOCKING `cookbook` step (the corpus lane plus the
138
+ `COOKBOOK_BREAK` control sweep, both must exit 0), between `bench:selftest` and
139
+ `pack`: the chain is now 8 blocking steps + 1 non-blocking (peer-preview).
140
+ - `test/gate.mjs` pack check upgraded from a bare count to a named-set
141
+ assertion: the tarball names must equal exactly {SignalDecorators.js,
142
+ SignalDecorators.d.ts, llms.txt, CHANGELOG.md, README.md, LICENSE,
143
+ package.json}; `EXPECT_FILES` stays 7 and stays asserted, and a same-count
144
+ swap now fails.
145
+ - `package.json` `scripts.cookbook` (`node cookbook/run.mjs`) and three pinned
146
+ devDependencies: `@zakkster/lite-store` 1.2.0, `@zakkster/lite-arena` 1.9.0,
147
+ `@zakkster/lite-await` 1.1.1.
148
+
149
+ ### Changed
150
+
151
+ - The export surface grows 16 -> 18 (additive MINOR): `releaseReactive`,
152
+ `reinitReactive`. The 1.0.0 semver promise holds -- no existing export's
153
+ signature or behavior changed; the hot accessor canon does not move.
154
+ - Suite 214 -> 257 tests, green on both the plain and `--expose-gc` lanes:
155
+ `test/15-cookbook.test.mjs` added 14 (214 -> 228), `test/16-reinit.test.mjs`
156
+ added 29 (228 -> 257). Torture 15 -> 16 scenarios; sabotage controls 15 -> 16.
157
+ - Docs re-stamped to 1.1.0 across the three version sites
158
+ (`package.json`, `SignalDecorators.js` `VERSION`, `llms.txt`), the README
159
+ Testing/torture/design sections, and the llms.txt Exports header and scope
160
+ note. The llms.txt single-lifetime sentence ("disposed once, never revived") is
161
+ replaced by the pooled-lifetime contract and the three-state lattice, citing
162
+ decisions/0010 and 0011.
163
+ - Construction cost is unchanged: the prebuilt closure set is built LAZILY at
164
+ first `releaseReactive` (not at construction), so a construct-once/dispose-once
165
+ instance carries only one WeakMap `has` beyond 1.0.0; `costOf(Factory).nodes`
166
+ still equals P+D+E+1 and `capacity-torture` is unperturbed. Measured evidence
167
+ for why lazy (a stored or transient per-construction bundle tips the
168
+ `maxMajor 0` churn-soak floor) is in decisions/0011.
169
+
170
+ ### Peer-watch note (PD-46)
171
+
172
+ Outcome (i): nothing promoted. The `npm view @zakkster/lite-signal dist-tags`
173
+ probe on 2026-08-30 shows stable `latest` still at **1.5.0**; 1.6.x
174
+ (`createScope`) and 1.9.x (engine `Symbol.dispose`) exist only as prerelease tags,
175
+ exactly the ones the peer-preview lane already tracks. No per-feature floor is
176
+ promoted, `torture:peer-preview` keeps reporting per tag, the `scope-adoption`
177
+ and `using-dispose` scenarios keep skipping legitimately below their floors, and
178
+ the peer RANGE floor stays `>=1.5.0 <2.0.0`. Recorded in decisions/0010.
179
+
180
+ ### Gate output (section-10 chain, archived verbatim)
181
+
182
+ ```
183
+ fixtures OK exit 0 -- emit fixtures regenerated
184
+ test OK exit 0 -- 257 pass / 0 fail
185
+ test:gc OK exit 0 -- 257 pass / 0 fail
186
+ torture OK exit 0 -- 14 passed, 2 skipped, 0 warned, 0 failed in 33.2s
187
+ torture:controls OK exit 0 -- 16 passed, 0 skipped, 0 warned, 0 failed in 2.0s
188
+ 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]
189
+ bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
190
+ cookbook OK exit 0/0 -- corpus 12/12 companions ok in 1.8s; controls 6/6 controls fail correctly in 4.6s
191
+ pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
192
+ ----------------------------------------------------------------------
193
+ GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
194
+ ```
195
+
7
196
  ## [1.0.0] - 2026-08-29
8
197
 
9
198
  The 1.0 release: docs freeze, the fleet playground, and the standing pre-publish
@@ -358,6 +547,7 @@ Initial release -- the decorator core.
358
547
  - Torture skeleton (`@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`):
359
548
  retention, conservation, lifecycle, and zero-GC lanes.
360
549
 
550
+ [1.1.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.1.0
361
551
  [1.0.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v1.0.0
362
552
  [0.4.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.4.0
363
553
  [0.3.0]: https://github.com/PeshoVurtoleta/lite-signal-decorators/releases/tag/v0.3.0
package/README.md CHANGED
@@ -10,9 +10,10 @@
10
10
  ![TypeScript](https://img.shields.io/badge/TypeScript-Types-informational?style=for-the-badge)
11
11
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg?style=for-the-badge)](https://opensource.org/licenses/MIT)
12
12
 
13
- > Stage-3 decorators that turn a plain class into a reactive view-model with a
14
- > measured per-property cost, one deterministic teardown, and poison-on-dispose
15
- > safety -- built on @zakkster/lite-signal.
13
+ > Standard decorators (TC39 decorators proposal, Stage 2.7 since 2026-05; TS
14
+ > 5.x / Babel 2023-11 emit unchanged) that turn a plain class into a reactive
15
+ > view-model with a measured per-property cost, one deterministic teardown, and
16
+ > poison-on-dispose safety -- built on @zakkster/lite-signal.
16
17
 
17
18
  **`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
18
19
 
@@ -70,7 +71,7 @@ npm i @zakkster/lite-signal-decorators @zakkster/lite-signal
70
71
 
71
72
  `@zakkster/lite-signal` (`>=1.5.0 <2.0.0`) is a **peer dependency**, and that is a correctness requirement, not a formality: your decorated instances, your raw signals, and the engine's node pool must live in ONE reactive graph. A second nested copy of the engine would silently split that graph. Install both at the top level.
72
73
 
73
- ESM-only. Ships TypeScript definitions. Node >= 18. The `@` syntax needs a Stage-3 decorator toolchain (TypeScript 5 or Babel `2023-11` -- see [Compatibility](#compatibility)); [`defineReactive`](#definereactiveclass-spec---class) needs nothing.
74
+ ESM-only. Ships TypeScript definitions. Node >= 18. The `@` syntax needs a standard-decorators toolchain (TypeScript 5 or Babel `2023-11` -- see [Compatibility](#compatibility)); [`defineReactive`](#definereactiveclass-spec---class) needs nothing.
74
75
 
75
76
  ### Quick start
76
77
 
@@ -238,6 +239,8 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
238
239
  | Export | Signature | Behavior |
239
240
  |---|---|---|
240
241
  | `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). |
242
+ | `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). |
243
+ | `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. |
241
244
  | `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`. |
242
245
  | `rootOf` | `(vm) => NodeDescriptor` | The instance's anchor descriptor -- feeds `forEachOwned` and lite-devtools. Throws `ReactiveDisposedError` after dispose. |
243
246
 
@@ -257,7 +260,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
257
260
  | Export | Value |
258
261
  |---|---|
259
262
  | `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
260
- | `VERSION` | `"1.0.0"` |
263
+ | `VERSION` | `"1.1.1"` |
261
264
 
262
265
  ### The rejection matrix
263
266
 
@@ -374,14 +377,18 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
374
377
  | `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
375
378
  | `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
376
379
 
377
- The gates that hold it (run on every change, all green at 1.0.0):
380
+ The gates that hold it (run on every change, all green at 1.1.1):
378
381
 
379
- - `npm test` / `npm run test:gc` -- **214/214** on both lanes.
382
+ - `npm test` / `npm run test:gc` -- **257/257** on both lanes.
380
383
  - Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
381
- - Torture: **15 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) -- **13 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 -- **15/15 controls** prove each gate can actually fail.
384
+ - 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.
382
385
  - `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.
383
386
 
384
- 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 seven class-shaped scenarios, 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.
387
+ 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.
388
+
389
+ ![CHURN benchmark: ops/s and transient heap per adapter](https://raw.githubusercontent.com/PeshoVurtoleta/lite-signal-decorators/main/bench/results-chart.svg)
390
+
391
+ The chart above is generated from the stamped `bench/results.txt` by `bench/chart.mjs` (`node bench/chart.mjs`) -- it plots the CHURN lane for every adapter at full scale, including `alien-class`, the hand-rolled reference that has no disposable instance lifecycle to pool.
385
392
 
386
393
  </details>
387
394
 
@@ -398,22 +405,23 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
398
405
  - **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.
399
406
  - **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.
400
407
  - **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.
408
+ - **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)).
401
409
 
402
410
  ---
403
411
 
404
412
  ## Testing (for clients & QA)
405
413
 
406
414
  ```bash
407
- npm test # node --test, 214 tests
408
- npm run test:gc # the same 214 with --expose-gc (enables the allocation assertions)
409
- npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> pack
415
+ npm test # node --test, 257 tests
416
+ npm run test:gc # the same 257 with --expose-gc (enables the allocation assertions)
417
+ npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
410
418
  ```
411
419
 
412
- **214 tests** across fourteen files, all green at 1.0.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.
420
+ **257 tests** across sixteen files, all green at 1.1.1. The decorator protocol is tested three times over: against a mock standard-decorators emitter *and* against committed real TypeScript 5 and Babel `2023-11` emits, so both toolchains' codegen is pinned, not assumed.
413
421
 
414
422
  | File | Tests | Covers |
415
423
  |---|---:|---|
416
- | `01-protocol-mock` | 30 | Decorator protocol on the mock Stage-3 emitter: wiring, values, options, rejection matrix |
424
+ | `01-protocol-mock` | 30 | Decorator protocol on the mock standard-decorators emitter: wiring, values, options, rejection matrix |
417
425
  | `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
418
426
  | `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
419
427
  | `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
@@ -427,10 +435,12 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
427
435
  | `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
428
436
  | `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
429
437
  | `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
438
+ | `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 |
439
+ | `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 |
430
440
 
431
441
  ### Emit-support matrix
432
442
 
433
- 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:
443
+ Three fixture sources, two standard-decorators 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:
434
444
 
435
445
  <!-- EMIT-MATRIX:START -->
436
446
  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.
@@ -452,13 +462,25 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
452
462
  Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
453
463
 
454
464
  ```bash
455
- npm run torture # all 15 scenarios (13 run + 2 floor-gated skips)
465
+ npm run torture # all 16 scenarios (14 run + 2 floor-gated skips)
456
466
  npm run torture:semantic # the correctness lane (CI)
457
467
  npm run torture:soak # the wall-clock churn + fleet soaks
458
468
  npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
459
469
  ```
460
470
 
461
- Fifteen 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 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: 13 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`.
471
+ 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`.
472
+
473
+ ### The cookbook lane (dev-side, never shipped)
474
+
475
+ 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[]`):
476
+
477
+ ```bash
478
+ npm run cookbook # run all 12 companions under node --expose-gc
479
+ npm run cookbook -- --controls # sabotage sweep: every gated recipe must FAIL when broken
480
+ npm run cookbook -- --list # the manifest: id, title, tier, gc verdict
481
+ ```
482
+
483
+ 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.
462
484
 
463
485
  ### The fleet demo (dev-side, never shipped)
464
486
 
@@ -479,7 +501,7 @@ The `demo/` directory is dev-only -- it never enters `package.json` `files[]` an
479
501
 
480
502
  | Path | Requirement |
481
503
  |---|---|
482
- | `@` decorator syntax | A Stage-3 (2023-11) decorator toolchain: **TypeScript >= 5.0** (standard decorators -- leave `experimentalDecorators` unset/false) or **Babel** with `["@babel/plugin-proposal-decorators", { "version": "2023-11" }]`. Both emits are first-class: the suite pins each with committed fixtures. |
504
+ | `@` decorator syntax | A standard-decorators (2023-11) toolchain: **TypeScript >= 5.0** (standard decorators -- leave `experimentalDecorators` unset/false) or **Babel** with `["@babel/plugin-proposal-decorators", { "version": "2023-11" }]`. Both emits are first-class: the suite pins each with committed fixtures. |
483
505
  | `defineReactive` | Nothing. Any ESM runtime. |
484
506
  | Runtime | Node >= 18 (ESM-only, `sideEffects: false`); browsers via any ESM bundler or native modules. No DOM dependency anywhere in the package. |
485
507
  | Peer | `@zakkster/lite-signal` `>=1.5.0 <2.0.0`, installed at the top level (one engine instance, one graph). |
@@ -512,7 +534,7 @@ Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or
512
534
  | `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
513
535
  | `@cached get y()` | `@derived get y()` |
514
536
  | no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
515
- | Stage-3 build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
537
+ | Standard-decorators build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
516
538
 
517
539
  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).
518
540
 
@@ -542,12 +564,16 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
542
564
  | [`@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. |
543
565
  | `@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. |
544
566
 
567
+ ### The cookbook
568
+
569
+ [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 18-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).
570
+
545
571
  ---
546
572
 
547
573
  ## FAQ
548
574
 
549
575
  **Why the `accessor` keyword?**
550
- It is the Stage-3 mechanism that gives a decorator both an `init` hook (create the box during field initialization -- eagerly, so the getter carries no `if (!box)` lazy branch) and replaceable get/set bodies, without per-instance `defineProperty` calls or a base class. `@reactive` on a plain field is a named throw pointing you to `accessor`.
576
+ It is the standard-decorators protocol mechanism that gives a decorator both an `init` hook (create the box during field initialization -- eagerly, so the getter carries no `if (!box)` lazy branch) and replaceable get/set bodies, without per-instance `defineProperty` calls or a base class. `@reactive` on a plain field is a named throw pointing you to `accessor`.
551
577
 
552
578
  **How is this different from MobX's `@observable`?**
553
579
  Philosophy: MobX trades allocation and administration overhead for maximal transparency; this package trades a little syntax (`accessor`, explicit dispose) for zero hot-path allocation, a node-exact instance cost, and a teardown you can prove. There is no proxy, no administration object per instance, and nothing is left for the GC to find "eventually" -- which is precisely what makes 10k-instance fleets and long sessions flat.
@@ -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.
@@ -1,7 +1,7 @@
1
1
  /**
2
- * @zakkster/lite-signal-decorators v1.0.0
2
+ * @zakkster/lite-signal-decorators v1.1.1
3
3
  * --------------------
4
- * Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into
4
+ * Standard-decorators layer over @zakkster/lite-signal. Turns a plain class into
5
5
  * a reactive view-model with measured per-instance cost and deterministic
6
6
  * teardown:
7
7
  * - `@reactive accessor x = v` -- a per-instance signal box, stored in a
@@ -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 without
71
- // calling get() (PD-4): value is "disposed" or "prewired".
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. Carries
166
- * the originating class name and the member key for actionable diagnostics.
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
- `${ERR}${className}.${String(key)} was used after disposeReactive() -- the reactive graph is gone`,
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}disposeReactive(${ctorName}) was called from inside its own @derived ${keyLabel(key)} computation -- derived getters must be pure. Dispose from an effect, a subscription, or plain code instead.`,
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
- if (a !== undefined && a !== DISPOSED) reg.dispose(a); // cascades deriveds + effects
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 = "1.0.0";
1947
+ export const VERSION = "1.1.1";
package/llms.txt CHANGED
@@ -1,8 +1,10 @@
1
1
  # @zakkster/lite-signal-decorators
2
2
 
3
- VERSION 1.0.0
3
+ VERSION 1.1.1
4
4
 
5
- > Stage-3 decorator layer over @zakkster/lite-signal. Turns a plain class into a
5
+ > Standard-decorators layer over @zakkster/lite-signal, built on the TC39
6
+ > decorators proposal (Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit
7
+ > unchanged). Turns a plain class into a
6
8
  > reactive view-model where each instance has a measured per-property cost, a
7
9
  > single deterministic teardown, and poison-on-dispose safety. ESM-only, zero
8
10
  > runtime dependencies beyond the peer. The accessor read/write bodies carry one
@@ -20,7 +22,7 @@ zero decorator syntax, sharing the SAME core by function identity.
20
22
  slot for a poison handle, so any later read/write throws a named
21
23
  `ReactiveDisposedError`.
22
24
 
23
- ## Exports (16)
25
+ ## Exports (18)
24
26
 
25
27
  - `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
26
28
  (factory). Declares a per-instance signal.
@@ -48,6 +50,18 @@ slot for a poison handle, so any later read/write throws a named
48
50
  buildless contract.
49
51
  - `disposeReactive(vm) -> boolean` -- cascade + poison teardown. Idempotent: a
50
52
  second call returns `false` and changes nothing.
53
+ - `releaseReactive(vm) -> boolean` -- park a LIVE instance to the engine pool:
54
+ cascade the anchor, dispose each signal box, swap every slot to a per-class
55
+ PARKED handle, keep the prebuilt wiring closures. A parked instance holds ZERO
56
+ engine nodes. `true` on the first release, `false` on park->park (idempotent,
57
+ mirrors double-dispose). Fails closed on a disposed, unwired, frozen, or
58
+ non-reactive value. See the pooled-lifetime contract (decisions/0010, 0011).
59
+ - `reinitReactive(vm, initials?) -> vm` -- revive a PARKED instance: rebuild each
60
+ signal box (`initials[key]` wins, else the plan initial), rebuild the anchor +
61
+ deriveds + effects through the SAME prebuilt closures, restore live slots.
62
+ Atomic: any throw mid-reinit routes through disposeCore and lands the instance
63
+ DISPOSED (a failed revival is final). Fails closed on a live, disposed, frozen,
64
+ unwired, or non-reactive value. Two-call lifecycle by design (decisions/0011).
51
65
  - `boxOf(vm, key) -> SignalBox | ComputedBox` -- the live box behind a member;
52
66
  throws with a did-you-mean on an unknown key, `ReactiveDisposedError` after
53
67
  dispose.
@@ -68,7 +82,7 @@ slot for a poison handle, so any later read/write throws a named
68
82
  `FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
69
83
  - `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
70
84
  fields `className` and `key`.
71
- - `VERSION` -- `"1.0.0"`.
85
+ - `VERSION` -- `"1.1.1"`.
72
86
 
73
87
  ## Registry law (one registry per host chain)
74
88
 
@@ -111,7 +125,8 @@ no node leaks, on the decorator path (init-phase) and the buildless path alike.
111
125
 
112
126
  ## Buildless contract (defineReactive)
113
127
 
114
- `defineReactive(Class, spec)` is for consumers without a Stage-3 transpiler. The
128
+ `defineReactive(Class, spec)` is for consumers without a standard-decorators
129
+ (2023-11) transpiler. The
115
130
  spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
116
131
 
117
132
  - `signals`: an array of keys (each initial `undefined`) OR a map
@@ -195,16 +210,42 @@ runtime requirement -- the shipped surface runs on 1.5.0.
195
210
 
196
211
  ## Scope note
197
212
 
198
- 1.0.0 FREEZES the export surface -- 16 exports: the 11 runtime exports plus
199
- `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and `auditReactive` (all
200
- cold / opt-in; the hot accessor canon is byte-identical to 0.3.0). The semver
201
- promise from here: any change to an existing export's signature or behavior is a
202
- MAJOR, recorded in a decision file; new exports are minors; the hot accessor
203
- canon (`makeGet`/`makeSet`) does not move without a major. Also present since
213
+ 1.0.0 FROZE the export surface at 16; 1.1.0 adds `releaseReactive` +
214
+ `reinitReactive` (an additive MINOR under that promise) -> 18 exports: the 13
215
+ runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
216
+ `auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
217
+ 0.3.0). The semver promise from here: any change to an existing export's
218
+ signature or behavior is a MAJOR, recorded in a decision file; new exports are
219
+ minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
220
+ major. Also present since
204
221
  0.3.0: the dev-side class-reactivity benchmark (`bench/`, private, never
205
222
  shipped) and the fleet-soak torture scenario; the formal kill-criteria verdicts
206
- live in decisions/0006-kill-criteria.md. Still OUT of the 1.0 surface (each a
223
+ live in decisions/0006-kill-criteria.md. Composition recipes over this frozen
224
+ surface (the MobX-parity-by-composition matrix, the two-plane fleet, the
225
+ lite-store boundary) live in COOKBOOK.md, delivered GitHub-only and NOT in the
226
+ tarball -- read it at
227
+ https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md
228
+ (every code block byte-verified against a GC-gated companion; decisions/0009). Still OUT of the 1.1 surface (each a
207
229
  deliberate, documented exclusion, not an oversight): private (`#`) members,
208
- static members, module/global signals (raw lite-signal territory), and any
209
- instance reinitialization / pooling API -- an instance is single-lifetime,
210
- disposed once, never revived.
230
+ static members, and module/global signals (raw lite-signal territory).
231
+
232
+ ## Pooled lifetime (1.1.0)
233
+
234
+ An instance is no longer single-lifetime. It moves on a three-state lattice --
235
+ LIVE / PARKED / DISPOSED -- driven by a two-call API. `releaseReactive(vm)` parks
236
+ a LIVE instance (cascades the anchor, disposes each box, holds ZERO engine nodes,
237
+ keeps its prebuilt wiring closures); `reinitReactive(vm, initials?)` revives a
238
+ PARKED one (rebuilds boxes + anchor + deriveds + effects through the SAME
239
+ closures, values reset). Two calls, not one: park and revive are separated in
240
+ time by the pool, and `disposeReactive`'s frozen `(vm) -> boolean` signature
241
+ cannot double as park (decisions/0011). Fail closed: every member touch on a
242
+ PARKED instance throws `ReactiveDisposedError` with a *parked* message (not
243
+ "disposed"), and `boxOf`/`rootOf` agree; a throw mid-reinit routes through
244
+ disposeCore and lands the instance DISPOSED (a failed revival is final); DISPOSED
245
+ is TERMINAL -- only a PARKED instance revives, a disposed one is gone and cannot
246
+ be pooled. park->park is idempotent (`false`); dispose-on-parked lands DISPOSED
247
+ and stays idempotent. The prebuilt closure set is built LAZILY at first
248
+ `releaseReactive`, so the construct-once/dispose-once path is byte-untouched
249
+ (0011); the amortization holds -- one closure build per reused instance,
250
+ re-registered across all N acquire/release cycles with zero new allocation. See
251
+ decisions/0010 (the spike contract, measured) and 0011 (the API shape).
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@zakkster/lite-signal-decorators",
3
- "version": "1.0.0",
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.",
3
+ "version": "1.1.1",
4
+ "description": "Standard-decorators layer over @zakkster/lite-signal (TC39 decorators proposal, Stage 2.7 since 2026-05; TS 5.x / Babel 2023-11 emit unchanged). 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",
7
7
  "type": "module",
@@ -39,7 +39,7 @@
39
39
  "reactive",
40
40
  "reactivity",
41
41
  "decorators",
42
- "stage-3",
42
+ "standard-decorators",
43
43
  "computed",
44
44
  "derived",
45
45
  "class",
@@ -68,6 +68,7 @@
68
68
  "torture:controls": "node test/torture/run.mjs --controls",
69
69
  "torture:peer-preview": "node test/torture/peer-preview.mjs",
70
70
  "gate": "node test/gate.mjs",
71
+ "cookbook": "node cookbook/run.mjs",
71
72
  "spikes": "for f in spikes/*.mjs; do echo \"== $f ==\"; node --expose-gc \"$f\" || exit 1; done",
72
73
  "spike:emit": "node --expose-gc spikes/emit/probe.mjs",
73
74
  "spike:ownership": "node --expose-gc spikes/ownership.mjs",
@@ -88,10 +89,13 @@
88
89
  "@babel/core": "^7.25.0",
89
90
  "@babel/plugin-proposal-decorators": "^7.25.0",
90
91
  "@babel/preset-typescript": "^7.29.7",
92
+ "@zakkster/lite-arena": "1.9.0",
93
+ "@zakkster/lite-await": "1.1.1",
91
94
  "@zakkster/lite-devtools": "^1.4.0",
92
95
  "@zakkster/lite-gc-profiler": "^1.16.0",
93
96
  "@zakkster/lite-leak": "^1.10.0",
94
97
  "@zakkster/lite-signal": "1.5.0",
98
+ "@zakkster/lite-store": "1.2.0",
95
99
  "@zakkster/lite-watch-ex": "^1.1.0",
96
100
  "esbuild": "0.28.2",
97
101
  "typescript": "^5.6.0"