@zakkster/lite-signal-decorators 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +151 -0
- package/README.md +59 -30
- package/SignalDecorators.d.ts +61 -2
- package/SignalDecorators.js +327 -17
- package/llms.txt +43 -9
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,157 @@ 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.2.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
The flagship of the decisions/0013 strategic-admission track: a story-grade,
|
|
10
|
+
release-after-release ladder (S8 `@localTo` -> S9 `snapshotOf` -> S10
|
|
11
|
+
`costOfInstance` -> S11 fleet helpers), and this is its first rung. `@localTo`
|
|
12
|
+
clears strategic criterion (a) -- impossible to compose correctly on the shipped
|
|
13
|
+
surface: PD-51 measured the effect-based recipe clobbering a user write one tick
|
|
14
|
+
late, so glitch-free reset needs in-package compare-on-read machinery. The one
|
|
15
|
+
release nobody else can copy from a recipe.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **`@localTo(source, { equals? })`** -- upstream-keyed resettable local state
|
|
20
|
+
(19th export; the 18-member surface grows by exactly one). A field FOLLOWS
|
|
21
|
+
`source(self)` until someone writes it, a write OVERRIDES, and a changed
|
|
22
|
+
upstream RESETS it -- decided by compare-on-read, so it is glitch-free,
|
|
23
|
+
synchronous, and PURE (no box write on the read path; legal inside any
|
|
24
|
+
`@derived`). `source` is a REQUIRED tracked `(self) => value` fn read inline
|
|
25
|
+
(no extra node). `equals` (default `Object.is`) governs the upstream compare
|
|
26
|
+
ONLY -- the write path never compares.
|
|
27
|
+
- **The `locals` buildless section** -- `defineReactive(Class, { ..., locals })`
|
|
28
|
+
with `locals: { key: { source, equals?, initial? } }`, the buildless twin of
|
|
29
|
+
`@localTo` (fail closed on a missing/non-fn source), full parity with the
|
|
30
|
+
decorator path on both emit lanes.
|
|
31
|
+
- **The initial-value unification rule** (decisions/0014): a declared
|
|
32
|
+
initializer means the member STARTS there and resets on the first upstream
|
|
33
|
+
move (the `@trackedReset` flavor); an OMITTED initializer means the initial is
|
|
34
|
+
the source evaluated once at wiring and the member follows upstream from the
|
|
35
|
+
first read (the `@localCopy` flavor). One decorator, both field semantics,
|
|
36
|
+
selected by the natural syntax.
|
|
37
|
+
- **The ABA contract, stated plainly** (decisions/0014, shipped + asserted): no
|
|
38
|
+
public revision counter exists (NodeDescriptor is `{id, kind, value}`), so the
|
|
39
|
+
upstream compare is VALUE-based. Upstream A -> local write X -> upstream B ->
|
|
40
|
+
upstream back to an equals-A value leaves the read showing the STALE LOCAL X --
|
|
41
|
+
the reset requires upstream to change relative to the last adoption, not to
|
|
42
|
+
have moved transitively. tracked-toolbox's `@localCopy` has the same property;
|
|
43
|
+
it is documented in README/llms.txt, pinned in torture (S8-A6), never softened.
|
|
44
|
+
- **The `localto-torture` lane** (`test/torture/localto-torture.mjs`, scenario
|
|
45
|
+
13 of 17) with its own `TORTURE_BREAK` sabotage control: the zero-alloc
|
|
46
|
+
read/write storm, the ABA-stale write/reset interleave asserted AS the
|
|
47
|
+
contract, pooled park/reinit box+seen reset, and tracking-edge + pure-compute
|
|
48
|
+
pins.
|
|
49
|
+
- **`test/17-localto.test.mjs`** (34 cases) -- the full lattice on both emit
|
|
50
|
+
lanes plus buildless `locals`: read/write/reset, both initial flavors, the ABA
|
|
51
|
+
contract, `equals` override survival, park/reinit reset, `costOf` accounting,
|
|
52
|
+
source-throw fail-closed, and the option/source rejection matrix.
|
|
53
|
+
- Emit fixtures extended: `fixture.src.ts` gains a `@localTo` member; the two
|
|
54
|
+
compiled outs + the source hash regenerate (no new fixture row or file).
|
|
55
|
+
|
|
56
|
+
### Changed
|
|
57
|
+
|
|
58
|
+
- **The per-instance cost formula is now `P + L + D + E + 1`** (was `P + D + E +
|
|
59
|
+
1`): one signal box per local plus the anchor; the plain per-instance seen-slot
|
|
60
|
+
is never a node (+0). `costOf` returns the `L` term; `capacityFor` sizes it.
|
|
61
|
+
Reflected everywhere the formula appears in README/llms.txt.
|
|
62
|
+
- The shipped `SignalDecorators.d.ts` header de-staged to emitter-named
|
|
63
|
+
phrasing (PD-49) -- a 1.1.1 zero-grep escapee (the sweep listed the `.js`
|
|
64
|
+
but not the `.d.ts`), caught by the S8 review.
|
|
65
|
+
- **The export surface: 18 -> 19** -- an additive MINOR under the 1.0.0 semver
|
|
66
|
+
promise (new exports are minors). The 1.0.0 hot canon
|
|
67
|
+
(`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `@localTo` ships
|
|
68
|
+
its own accessor bodies and pays its own measured cost.
|
|
69
|
+
- Three-place version sync to 1.2.0 (`package.json`, the `VERSION` const,
|
|
70
|
+
`llms.txt`); the `test/15` surface-freeze recount 18 -> 19.
|
|
71
|
+
|
|
72
|
+
### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
|
|
73
|
+
|
|
74
|
+
- A `@localTo` read measures **1.69x** a plain decorated read (two tracked reads
|
|
75
|
+
+ a compare, versus one box read) -- documented as-is, not softened.
|
|
76
|
+
- Read AND write storms at N and 8N: **0.000 B/op** (control-relative +2 B), gc
|
|
77
|
+
major **0**, `maxPauseMs <= 0.08`; a derived over one local holds EXACTLY 2
|
|
78
|
+
source edges (upstream + box), 0 extra nodes over 1e5 reads.
|
|
79
|
+
- 4096 park/reinit cycles: `tracker.size()` 0, findings 0, warnings 0,
|
|
80
|
+
`activeNodes` to exact baseline, zero pool growths; release frees exactly
|
|
81
|
+
`P + L + D + E + 1` nodes.
|
|
82
|
+
|
|
83
|
+
Records: decisions/0013 (strategic-admission track), decisions/0014 (the localTo
|
|
84
|
+
contract + the ratified spike numbers), `spikes/localto-contract.mjs` (Q1..Q6,
|
|
85
|
+
EXIT A green against peer 1.5.0).
|
|
86
|
+
|
|
87
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
91
|
+
test OK exit 0 -- 291 pass / 0 fail
|
|
92
|
+
test:gc OK exit 0 -- 291 pass / 0 fail
|
|
93
|
+
torture OK exit 0 -- 15 passed, 2 skipped, 0 warned, 0 failed in 33.5s
|
|
94
|
+
torture:controls OK exit 0 -- 17 passed, 0 skipped, 0 warned, 0 failed in 2.8s
|
|
95
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0) [preview 1.9.0-preview.6 SUITE-GREEN 17/0/0/0; canary 1.9.0-canary.1 SUITE-GREEN 17/0/0/0]
|
|
96
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
97
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
|
|
98
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
99
|
+
----------------------------------------------------------------------
|
|
100
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## [1.1.1] - 2026-08-30
|
|
104
|
+
|
|
105
|
+
A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
|
|
106
|
+
accessor canon and every export are byte-identical to 1.1.0. The only test
|
|
107
|
+
changes are `test/15`'s cookbook ground-truth recount for wave 2 and two
|
|
108
|
+
comment-only lines in the gate's step description -- no budget moved anywhere.
|
|
109
|
+
Surface stays at 18 exports; pack stays the same 7-file set.
|
|
110
|
+
|
|
111
|
+
### Changed
|
|
112
|
+
|
|
113
|
+
- Standards phrasing pass across README, llms.txt, package.json, the main-file
|
|
114
|
+
header, and the catalog card. TC39 lists the decorators proposal (and
|
|
115
|
+
Decorator Metadata) at Stage 2.7 since the 2026-05 plenary, down from Stage 3;
|
|
116
|
+
the emitters are unchanged -- TypeScript 5.x standard emit and Babel `2023-11`
|
|
117
|
+
remain the only real-world paths, no native engine ships decorators. Each doc
|
|
118
|
+
now names the fact once and refers to the protocol by its emitters everywhere
|
|
119
|
+
else, since stage labels drift and emitter names do not. Only the stage LABEL
|
|
120
|
+
moved: the emit matrix, the committed TS/Babel fixtures, and every test are
|
|
121
|
+
byte-untouched by this pass. Recorded in `research/feature-gap-2026-08-30.md` section 1 and
|
|
122
|
+
the `decisions/0011` addendum. Three-place version sync to 1.1.1
|
|
123
|
+
(`package.json`, the `VERSION` const, `llms.txt`).
|
|
124
|
+
|
|
125
|
+
### Added
|
|
126
|
+
|
|
127
|
+
- Cookbook wave 2: six recipes appended, `r12`..`r17`.
|
|
128
|
+
- `r12` -- Wait for a condition, as a Promise.
|
|
129
|
+
- `r13` -- React to a computed value, not every write (GATED).
|
|
130
|
+
- `r14` -- Tie teardown to an AbortSignal.
|
|
131
|
+
- `r15` -- Async state without async in the graph.
|
|
132
|
+
- `r16` -- Read without subscribing.
|
|
133
|
+
- `r17` -- Start the resource when someone is watching (GATED).
|
|
134
|
+
- Cookbook gated set grows 6 -> 8 (adds `r13`, `r17`).
|
|
135
|
+
- Publications refresh (P3): the outward drafts restamped to the 1.1.x story;
|
|
136
|
+
files stay git-untracked.
|
|
137
|
+
- Bench chart artifact (closes ROADMAP open question 5): `bench/chart.mjs`
|
|
138
|
+
renders `bench/results-chart.svg` -- the CHURN lane (ops/s + heapMed per
|
|
139
|
+
adapter) parsed verbatim from the stamped `bench/results.txt`, deterministic
|
|
140
|
+
and dev-side only (never in `files[]`); wired into the README numbers section.
|
|
141
|
+
|
|
142
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
143
|
+
|
|
144
|
+
```
|
|
145
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
146
|
+
test OK exit 0 -- 257 pass / 0 fail
|
|
147
|
+
test:gc OK exit 0 -- 257 pass / 0 fail
|
|
148
|
+
torture OK exit 0 -- 14 passed, 2 skipped, 0 warned, 0 failed in 33.1s
|
|
149
|
+
torture:controls OK exit 0 -- 16 passed, 0 skipped, 0 warned, 0 failed in 2.0s
|
|
150
|
+
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]
|
|
151
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
152
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.0s
|
|
153
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
154
|
+
----------------------------------------------------------------------
|
|
155
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
156
|
+
```
|
|
157
|
+
|
|
7
158
|
## [1.1.0] - 2026-08-30
|
|
8
159
|
|
|
9
160
|
The pooled-lifetime release: an instance is no longer single-lifetime. The
|
package/README.md
CHANGED
|
@@ -10,11 +10,12 @@
|
|
|
10
10
|

|
|
11
11
|
[](https://opensource.org/licenses/MIT)
|
|
12
12
|
|
|
13
|
-
>
|
|
14
|
-
>
|
|
15
|
-
>
|
|
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
|
-
**`@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
|
+
**`@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 + L + 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
|
|
|
19
20
|
```js
|
|
20
21
|
import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
|
|
@@ -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
|
|
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
|
|
|
@@ -99,7 +100,7 @@ class Player {
|
|
|
99
100
|
}
|
|
100
101
|
}
|
|
101
102
|
|
|
102
|
-
const p = new Player(); // exactly 2 + 2 + 1 + 1 = 6 pool nodes (P + D + E + 1)
|
|
103
|
+
const p = new Player(); // exactly 2 + 0 + 2 + 1 + 1 = 6 pool nodes (P + L + D + E + 1)
|
|
103
104
|
p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
|
|
104
105
|
p.hit(20); // hp 50 -> "critical": effect runs once
|
|
105
106
|
disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
|
|
@@ -137,6 +138,7 @@ const ReactivePlayer = defineReactive(Player, {
|
|
|
137
138
|
|
|
138
139
|
- **`@reactive accessor x = v`** -- a per-instance signal box in a unique symbol slot. The read body is one slot load + one monomorphic box call: zero branches, zero allocation.
|
|
139
140
|
- **`@derived get y()`** -- a lazy computed owned by the instance's anchor, with optional custom `equals` for change-cutoff.
|
|
141
|
+
- **`@localTo(source) accessor x = v`** -- upstream-keyed resettable local state: reads follow `source(self)` until you write, a write overrides, and a changed upstream resets it -- glitch-free by compare-on-read, no effect, no extra tick. With an initializer the field starts there and resets on the first upstream move; without one it follows upstream from wiring. One signal box + one plain seen-slot per member; the read is pure, so it is legal inside any `@derived`.
|
|
140
142
|
- **`@reactiveEffect m()`** -- a method that auto-runs as an effect after wiring. Manual calls are leak-guarded (a call inside a foreign tracking scope is untracked, so it records zero stray dependencies) and identity-guarded (a foreign receiver throws by name instead of running against garbage).
|
|
141
143
|
- **`@batched m()`** -- the method body inside one engine batch: N writes, one flush. Action-grade by design, with a measured per-call cost -- not a per-frame path.
|
|
142
144
|
- **`@reactiveHost`** -- the single wiring site. Its most-derived constructor builds the anchor, every derived, and every effect exactly once, after all fields of all classes in the chain initialize. `@reactiveHost({ registry })` binds the whole chain to an isolated lite-signal registry.
|
|
@@ -204,7 +206,7 @@ The failure mode this package is built against is not "reactivity doesn't work"
|
|
|
204
206
|
|
|
205
207
|
### Member decorators
|
|
206
208
|
|
|
207
|
-
|
|
209
|
+
The first four take a bare form and a factory form (`@reactive` and `@reactive({...})` both work); `@localTo` is the exception -- it always takes a required `source` argument (detailed below the table).
|
|
208
210
|
|
|
209
211
|
| Decorator | Placement | Options | Behavior |
|
|
210
212
|
|---|---|---|---|
|
|
@@ -212,6 +214,24 @@ All four take a bare form and a factory form (`@reactive` and `@reactive({...})`
|
|
|
212
214
|
| `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
|
|
213
215
|
| `@reactiveEffect` | `m()` | `scheduler(run)` | Auto-runs as an effect at wiring, re-runs on tracked changes. `scheduler` defers re-runs (frame coalescing etc.). Manual calls: leak-guarded + identity-guarded. |
|
|
214
216
|
| `@batched` | `m()` | -- | Runs the body inside one engine batch: all writes flush once, at close. Nesting flushes at the outermost close. Action-grade -- see [the numbers](#the-numbers). |
|
|
217
|
+
| `@localTo` | `accessor x = v` | `equals(a, b)` | Upstream-keyed resettable local state. Read follows `source(self)` until written, a write overrides, a changed upstream resets. Compare-on-read (pure); `equals` governs the upstream compare only. Takes a REQUIRED `source` argument -- see below. |
|
|
218
|
+
|
|
219
|
+
### `localTo(source, { equals? }?)`
|
|
220
|
+
|
|
221
|
+
`@localTo(source)` declares a field that **follows an upstream value until someone writes it, then resets when upstream changes** -- the "local copy you can edit, that re-syncs on a real update" pattern, done glitch-free with no effect and no extra tick. `source` is a REQUIRED tracked `(self) => value` function, read inline on every get (no extra node). The get is **pure** -- it compares `source(self)` to a per-instance last-seen slot and returns the local box when upstream is unchanged, else the upstream value; it never writes a box, so a `@localTo` read is legal inside any `@derived`. A write always overrides (the write path never compares). `{ equals }` (default `Object.is`) governs the **upstream** compare only.
|
|
222
|
+
|
|
223
|
+
Two field flavors, selected by the natural syntax (the [initial-value unification rule](decisions/0014-localto-contract.md)):
|
|
224
|
+
|
|
225
|
+
```js
|
|
226
|
+
@reactiveHost
|
|
227
|
+
class Field {
|
|
228
|
+
@reactive accessor upstream = "server";
|
|
229
|
+
@localTo((self) => self.upstream) accessor draft; // no initial: FOLLOWS upstream from wiring
|
|
230
|
+
@localTo((self) => self.upstream) accessor pinned = ""; // initial: STARTS "", resets on first upstream move
|
|
231
|
+
}
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
**The ABA contract (honest, shipped, never softened).** The upstream compare is VALUE-based -- lite-signal exposes no public revision counter, and reaching for a private one would be impure. So the reset triggers when upstream *changes relative to the last adoption*, not when it has moved transitively: upstream `A` -> local write `X` -> upstream `B` -> upstream back to an equals-`A` value leaves the read showing the **stale local `X`**. tracked-toolbox's `@localCopy` has the same property. A coarse custom `equals` widens override survival on purpose. See the [compare-on-read design bullet](#design-decisions-worth-knowing).
|
|
215
235
|
|
|
216
236
|
### Class decorator
|
|
217
237
|
|
|
@@ -228,6 +248,7 @@ The buildless twin. Installs the members on `Class.prototype`, wraps the class t
|
|
|
228
248
|
|---|---|---|
|
|
229
249
|
| `signals` | `["a", "b"]` or `{ key: value \| { initial \| init \| equals } }` | A plain non-function value is the initial. `initial` is taken verbatim; `init(self)` computes per instance; a bare function is a named throw (ambiguous -- wrap it). |
|
|
230
250
|
| `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
|
|
251
|
+
| `locals` | `{ key: { source, equals?, initial? } }` | Map only. The buildless twin of `@localTo`. `source` REQUIRED and a `(self) => value` fn (missing/non-fn is a named throw); `equals` governs the upstream compare; `initial` (verbatim) selects the reset-from flavor, its absence the follow-from-wiring flavor. |
|
|
231
252
|
| `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
|
|
232
253
|
| `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
|
|
233
254
|
|
|
@@ -247,7 +268,7 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
247
268
|
|
|
248
269
|
| Export | Signature | Behavior |
|
|
249
270
|
|---|---|---|
|
|
250
|
-
| `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + D + E + 1; `links` is the first-full-read link count. |
|
|
271
|
+
| `costOf` | `(Factory) => { nodes, links, signals, deriveds, effects }` | The measured, settled per-instance cost, probed on the class's bound registry (frozen result, cached per class). Double-probed: an inconclusive or polluted probe THROWS -- never a guess. `nodes` is exactly P + L + D + E + 1; `links` is the first-full-read link count. |
|
|
251
272
|
| `capacityFor` | `(inventory, { headroom }?) => RegistryConfig` | Sizes a `createRegistry` config from `[Factory, count]` pairs: nodes exact, links x `headroom` (floored at the engine minimum of 1), `prealloc: "eager"`, `onCapacityExceeded: "throw"`. Fail-closed inventory and options validation. Link policy + caveats: [decisions/0007](decisions/0007-capacity-policy.md). |
|
|
252
273
|
| `enableLabels` / `labelOf` | `(on)` / `(idOrHandle, registry?) => string \| undefined` | Opt-in devtools identity (default OFF): while on, wiring registers per-registry `nodeId -> "Class.prop"` / `"Class#method"` / `"Class@anchor"`; dispose unregisters. `labelOf` misses return `undefined`, never throw. |
|
|
253
274
|
| `auditReactive` | `(on)` | Opt-in leak auditor (default OFF): a lazily-created `FinalizationRegistry` reports any instance collected WITHOUT `disposeReactive`, naming class and shape. Holds no instance references itself; zero cost and zero registrations while off. |
|
|
@@ -259,7 +280,7 @@ With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- th
|
|
|
259
280
|
| Export | Value |
|
|
260
281
|
|---|---|
|
|
261
282
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
262
|
-
| `VERSION` | `"1.
|
|
283
|
+
| `VERSION` | `"1.2.0"` |
|
|
263
284
|
|
|
264
285
|
### The rejection matrix
|
|
265
286
|
|
|
@@ -348,6 +369,8 @@ The fair baseline for a decorated property is a hand-written instance field (`th
|
|
|
348
369
|
|
|
349
370
|
A decorated reactive property costs **~1.0x a hand-written instance-field signal read**; both are ~2x a module-level signal because that is the cost of per-instance storage, paid either way -- an engine indirection, not a decorator tax. Writes are ~2.5-3x a module-const read across all instance layouts (box `.set` propagation dominates; inherent to any reactive write). The rejected dictionary layout is the one that *degrades at fleet scale* (cross-instance IC megamorphism) -- the hazard only a class-shaped benchmark exposes, and the reason this package doesn't use one.
|
|
350
371
|
|
|
372
|
+
A `@localTo` read measures **1.69x** a plain decorated read -- two tracked reads (upstream `source` + the local box) plus a value compare, versus the one box read of `@reactive` -- and it pays that cost with **0.000 B/op** under the read/write storms (`gc.major 0`, observed `maxPauseMs` 0.07-0.08 against the 4.0 gate); the compare-on-read is honest arithmetic, not free.
|
|
373
|
+
|
|
351
374
|
### `@batched` per call (`spikes/batched-cost.mjs`)
|
|
352
375
|
|
|
353
376
|
| Path | ns/op |
|
|
@@ -360,7 +383,7 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
360
383
|
|
|
361
384
|
### Per instance
|
|
362
385
|
|
|
363
|
-
`P + D + E + 1` pool nodes -- one per signal, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
|
|
386
|
+
`P + L + D + E + 1` pool nodes -- one per signal, per local, per derived, per effect, plus the anchor. All of them return to the pool on dispose: conservation is node-exact (`activeNodes` to baseline, zero pool growths, allocations minus disposals reconciled) over 4096-cycle churn and a wall-clock soak.
|
|
364
387
|
|
|
365
388
|
<details>
|
|
366
389
|
<summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
|
|
@@ -372,19 +395,23 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
372
395
|
| `@derived get` read | none | lazy computed read |
|
|
373
396
|
| Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
|
|
374
397
|
| `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
|
|
375
|
-
| `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
|
|
398
|
+
| `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
|
|
376
399
|
| `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
|
|
377
400
|
| `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
|
|
378
401
|
|
|
379
|
-
The gates that hold it (run on every change, all green at 1.
|
|
402
|
+
The gates that hold it (run on every change, all green at 1.2.0):
|
|
380
403
|
|
|
381
|
-
- `npm test` / `npm run test:gc` -- **
|
|
404
|
+
- `npm test` / `npm run test:gc` -- **291/291** on both lanes.
|
|
382
405
|
- Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
|
|
383
|
-
- Torture: **
|
|
406
|
+
- Torture: **17 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; the `localto-torture` zero-alloc read/write storm + ABA-stale interleave lattice + pooled park/reinit) -- **15 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 -- **17/17 controls** prove each gate can actually fail.
|
|
384
407
|
- `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.
|
|
385
408
|
|
|
386
409
|
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.
|
|
387
410
|
|
|
411
|
+

|
|
412
|
+
|
|
413
|
+
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.
|
|
414
|
+
|
|
388
415
|
</details>
|
|
389
416
|
|
|
390
417
|
---
|
|
@@ -401,22 +428,23 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
|
|
|
401
428
|
- **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.
|
|
402
429
|
- **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
430
|
- **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)).
|
|
431
|
+
- **`@localTo` resets by compare-on-read, and its ABA limit is stated, not hidden (1.2.0).** The reset is decided *on the read* -- `source(self)` compared to a per-instance last-seen slot -- so it is glitch-free, synchronous, and pure (no box write on read, legal inside a `@derived`). The rejected alternative was an effect that watches upstream and clears the local: PD-51 measured that recipe clobbering a user's write one tick late, which is the whole reason the feature lives in-package instead of a cookbook recipe. Because the compare is value-based (lite-signal exposes no public revision counter and a private one would be impure), the honest limit is ABA: upstream `A` -> local write -> upstream `B` -> upstream back to an equals-`A` value shows the stale local -- the same property tracked-toolbox's `@localCopy` ships, documented rather than papered over ([decisions/0013](decisions/0013-strategic-admission-track.md), [0014](decisions/0014-localto-contract.md)).
|
|
404
432
|
|
|
405
433
|
---
|
|
406
434
|
|
|
407
435
|
## Testing (for clients & QA)
|
|
408
436
|
|
|
409
437
|
```bash
|
|
410
|
-
npm test # node --test,
|
|
411
|
-
npm run test:gc # the same
|
|
438
|
+
npm test # node --test, 291 tests
|
|
439
|
+
npm run test:gc # the same 291 with --expose-gc (enables the allocation assertions)
|
|
412
440
|
npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
|
|
413
441
|
```
|
|
414
442
|
|
|
415
|
-
**
|
|
443
|
+
**291 tests** across seventeen files, all green at 1.2.0. 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.
|
|
416
444
|
|
|
417
445
|
| File | Tests | Covers |
|
|
418
446
|
|---|---:|---|
|
|
419
|
-
| `01-protocol-mock` | 30 | Decorator protocol on the mock
|
|
447
|
+
| `01-protocol-mock` | 30 | Decorator protocol on the mock standard-decorators emitter: wiring, values, options, rejection matrix |
|
|
420
448
|
| `02-fixtures-ts` | 19 | The same laws on real TypeScript 5 emit (committed fixtures) |
|
|
421
449
|
| `03-fixtures-babel` | 19 | The same laws on real Babel `2023-11` emit |
|
|
422
450
|
| `04-fixture-freshness` | 2 | Fixture hashes match the sources (stale-emit guard) + the README emit-matrix block matches its generator |
|
|
@@ -430,26 +458,27 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
|
|
|
430
458
|
| `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
|
|
431
459
|
| `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
|
|
432
460
|
| `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
|
|
461
|
+
| `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 19 exports), citation allowlist, link law, static-cost probe |
|
|
434
462
|
| `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 |
|
|
463
|
+
| `17-localto` | 34 | `@localTo` on both emit lanes + buildless `locals`: the read/write/upstream-reset lattice, both initial flavors (follow-from-wiring vs reset-from-initial), the ABA stale-local contract, `equals` override survival, park/reinit box+seen reset, `costOf` = P+L+D+E+1, source-throw fail-closed, and the fail-closed option/source matrix |
|
|
435
464
|
|
|
436
465
|
### Emit-support matrix
|
|
437
466
|
|
|
438
|
-
Three fixture sources, two
|
|
467
|
+
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:
|
|
439
468
|
|
|
440
469
|
<!-- EMIT-MATRIX:START -->
|
|
441
470
|
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
471
|
|
|
443
472
|
| Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
|
|
444
473
|
|---|---|---|---|---|---|
|
|
445
|
-
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `
|
|
446
|
-
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `
|
|
474
|
+
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `5d6837710b34` | accepted -- full decorator surface wired + pinned green |
|
|
475
|
+
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `f7d8b3e5ed19` | accepted -- full decorator surface wired + pinned green |
|
|
447
476
|
| `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
477
|
| `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
|
|
449
478
|
| `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
|
|
450
479
|
| `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
|
|
451
480
|
|
|
452
|
-
Source hashes: `fixture.src.ts` `
|
|
481
|
+
Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
|
|
453
482
|
<!-- EMIT-MATRIX:END -->
|
|
454
483
|
|
|
455
484
|
### The torture suite (dev-side, never shipped)
|
|
@@ -457,13 +486,13 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
|
|
|
457
486
|
Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
|
|
458
487
|
|
|
459
488
|
```bash
|
|
460
|
-
npm run torture # all
|
|
489
|
+
npm run torture # all 17 scenarios (15 run + 2 floor-gated skips)
|
|
461
490
|
npm run torture:semantic # the correctness lane (CI)
|
|
462
491
|
npm run torture:soak # the wall-clock churn + fleet soaks
|
|
463
492
|
npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
|
|
464
493
|
```
|
|
465
494
|
|
|
466
|
-
|
|
495
|
+
Seventeen 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 `localto-torture` gate (zero-alloc `@localTo` read/write storm at `maxMajor 0`, the ABA-stale write/reset interleave asserted AS the shipped contract, pooled park/reinit box+seen reset, tracking-edge and pure-compute-read pins), 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: 15 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
496
|
|
|
468
497
|
### The cookbook lane (dev-side, never shipped)
|
|
469
498
|
|
|
@@ -496,7 +525,7 @@ The `demo/` directory is dev-only -- it never enters `package.json` `files[]` an
|
|
|
496
525
|
|
|
497
526
|
| Path | Requirement |
|
|
498
527
|
|---|---|
|
|
499
|
-
| `@` decorator syntax | A
|
|
528
|
+
| `@` 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. |
|
|
500
529
|
| `defineReactive` | Nothing. Any ESM runtime. |
|
|
501
530
|
| Runtime | Node >= 18 (ESM-only, `sideEffects: false`); browsers via any ESM bundler or native modules. No DOM dependency anywhere in the package. |
|
|
502
531
|
| Peer | `@zakkster/lite-signal` `>=1.5.0 <2.0.0`, installed at the top level (one engine instance, one graph). |
|
|
@@ -529,7 +558,7 @@ Verified against the installed `signal-utils@0.21.1`: `@signal` (on accessors or
|
|
|
529
558
|
| `@signal accessor x` (or `@signal get x`) | `@reactive accessor x` |
|
|
530
559
|
| `@cached get y()` | `@derived get y()` |
|
|
531
560
|
| no disposal API at all | `disposeReactive(vm)` -- **and it disposes**: cascade teardown, poison swap, node-exact conservation |
|
|
532
|
-
|
|
|
561
|
+
| Standard-decorators build required | `defineReactive(Class, spec)` -- the buildless door signal-utils has no equivalent for |
|
|
533
562
|
|
|
534
563
|
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
564
|
|
|
@@ -541,7 +570,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
|
|
|
541
570
|
- **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.
|
|
542
571
|
- **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`).
|
|
543
572
|
- **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
|
|
544
|
-
- **Not a general meta-programming kit.**
|
|
573
|
+
- **Not a general meta-programming kit.** Six 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
574
|
- **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
575
|
- **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".
|
|
547
576
|
- **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
|
|
@@ -561,14 +590,14 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
|
|
|
561
590
|
|
|
562
591
|
### The cookbook
|
|
563
592
|
|
|
564
|
-
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects
|
|
593
|
+
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 19-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
594
|
|
|
566
595
|
---
|
|
567
596
|
|
|
568
597
|
## FAQ
|
|
569
598
|
|
|
570
599
|
**Why the `accessor` keyword?**
|
|
571
|
-
It is the
|
|
600
|
+
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`.
|
|
572
601
|
|
|
573
602
|
**How is this different from MobX's `@observable`?**
|
|
574
603
|
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.
|
package/SignalDecorators.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators --
|
|
2
|
+
* @zakkster/lite-signal-decorators -- Standard-decorators layer over
|
|
3
3
|
* @zakkster/lite-signal.
|
|
4
4
|
*
|
|
5
5
|
* Public type surface for the JavaScript implementation in
|
|
@@ -30,6 +30,16 @@ export interface DerivedOptions<V> {
|
|
|
30
30
|
equals?: (a: V, b: V) => boolean;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
+
/** Options for a `@localTo` member. `equals` governs the UPSTREAM compare only. */
|
|
34
|
+
export interface LocalToOptions<V> {
|
|
35
|
+
/**
|
|
36
|
+
* Custom equality predicate for the UPSTREAM compare (source vs the last-seen
|
|
37
|
+
* value). A coarse predicate widens how long a local override survives an
|
|
38
|
+
* upstream move. Default: `Object.is`. The write path never compares.
|
|
39
|
+
*/
|
|
40
|
+
equals?: (a: V, b: V) => boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
33
43
|
/** Options for a `@reactiveEffect` method. */
|
|
34
44
|
export interface ReactiveEffectOptions {
|
|
35
45
|
/** Optional scheduler forwarded straight to the underlying `effect(fn, { scheduler })`. */
|
|
@@ -101,6 +111,37 @@ export function derived<V>(
|
|
|
101
111
|
ctx: ClassGetterDecoratorContext<This, V>,
|
|
102
112
|
) => (this: This) => V;
|
|
103
113
|
|
|
114
|
+
// --- localTo ------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* `@localTo(source) accessor x = v` -- upstream-keyed resettable local state.
|
|
118
|
+
* Each read compares the tracked `source(self)` against a per-instance last-seen
|
|
119
|
+
* slot: an unchanged upstream yields the local override, a changed upstream
|
|
120
|
+
* resets to the upstream value (no write on read -- pure). A write always
|
|
121
|
+
* overrides. With an initializer the member STARTS there and resets on the first
|
|
122
|
+
* upstream move (the `@trackedReset` flavor); without one it FOLLOWS upstream
|
|
123
|
+
* from wiring (the `@localCopy` flavor). `source` is REQUIRED. `equals` governs
|
|
124
|
+
* the upstream compare only.
|
|
125
|
+
*
|
|
126
|
+
* The ABA contract (shipped, documented): the reset requires the upstream to
|
|
127
|
+
* change relative to the last adoption, not to have merely moved -- upstream
|
|
128
|
+
* A -> local write X -> upstream B -> upstream back to an equals-A value shows
|
|
129
|
+
* the STALE local X.
|
|
130
|
+
*
|
|
131
|
+
* @example
|
|
132
|
+
* class Editor {
|
|
133
|
+
* `@reactive` accessor saved = "";
|
|
134
|
+
* `@localTo`((self) => self.saved) accessor draft = "";
|
|
135
|
+
* }
|
|
136
|
+
*/
|
|
137
|
+
export function localTo<This, V>(
|
|
138
|
+
source: (this: This, self: This) => V,
|
|
139
|
+
options?: LocalToOptions<V>,
|
|
140
|
+
): <T>(
|
|
141
|
+
target: ClassAccessorDecoratorTarget<T, V>,
|
|
142
|
+
ctx: ClassAccessorDecoratorContext<T, V>,
|
|
143
|
+
) => ClassAccessorDecoratorResult<T, V>;
|
|
144
|
+
|
|
104
145
|
// --- reactiveHost -------------------------------------------------------------
|
|
105
146
|
|
|
106
147
|
/**
|
|
@@ -177,6 +218,20 @@ export interface SignalSpec<This = unknown, V = unknown> {
|
|
|
177
218
|
equals?: (a: V, b: V) => boolean;
|
|
178
219
|
}
|
|
179
220
|
|
|
221
|
+
/** Local descriptor for a `defineReactive` `locals` map entry (`@localTo` twin). */
|
|
222
|
+
export interface LocalSpec<This = unknown, V = unknown> {
|
|
223
|
+
/** The tracked upstream read `(self) => value`. REQUIRED. */
|
|
224
|
+
source: (this: This, self: This) => V;
|
|
225
|
+
/** Custom equality predicate for the upstream compare. Default: `Object.is`. */
|
|
226
|
+
equals?: (a: V, b: V) => boolean;
|
|
227
|
+
/**
|
|
228
|
+
* The initial value. Present -> the member starts here and resets to upstream
|
|
229
|
+
* on the first upstream move (`@trackedReset`). Absent -> the member follows
|
|
230
|
+
* upstream from wiring (`@localCopy`).
|
|
231
|
+
*/
|
|
232
|
+
initial?: V;
|
|
233
|
+
}
|
|
234
|
+
|
|
180
235
|
/** Derived descriptor for a `defineReactive` `deriveds` map entry. */
|
|
181
236
|
export interface DerivedSpec<This = unknown, V = unknown> {
|
|
182
237
|
/** The compute body `(self) => value`. */
|
|
@@ -204,6 +259,8 @@ export interface DefineReactiveSpec<This = any> {
|
|
|
204
259
|
* throw (ambiguous -- use `{ initial: fn }` or `{ init: (self) => value }`).
|
|
205
260
|
*/
|
|
206
261
|
signals?: PropertyKey[] | Record<PropertyKey, unknown | SignalSpec<This>>;
|
|
262
|
+
/** Upstream-keyed locals (`@localTo` twin): a map of `key -> LocalSpec`. */
|
|
263
|
+
locals?: Record<PropertyKey, LocalSpec<This>>;
|
|
207
264
|
/** Lazy deriveds: a map of `key -> (self) => value | DerivedSpec`. */
|
|
208
265
|
deriveds?: Record<PropertyKey, ((this: This, self: This) => unknown) | DerivedSpec<This>>;
|
|
209
266
|
/** Auto-effects: a map of `key -> (self) => void | EffectSpec`. */
|
|
@@ -289,12 +346,14 @@ export function rootOf(vm: object): NodeDescriptor;
|
|
|
289
346
|
|
|
290
347
|
/** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
|
|
291
348
|
export interface ReactiveCost {
|
|
292
|
-
/** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
|
|
349
|
+
/** Total nodes = `signals + locals + deriveds + effects + 1` (the anchor). */
|
|
293
350
|
nodes: number;
|
|
294
351
|
/** Dependency links held after every `@derived` has been read once (0007). */
|
|
295
352
|
links: number;
|
|
296
353
|
/** Count of `@reactive` members. */
|
|
297
354
|
signals: number;
|
|
355
|
+
/** Count of `@localTo` members (each one box node; its seen slot is a plain field). */
|
|
356
|
+
locals: number;
|
|
298
357
|
/** Count of `@derived` members. */
|
|
299
358
|
deriveds: number;
|
|
300
359
|
/** Count of `@reactiveEffect` members. */
|
package/SignalDecorators.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @zakkster/lite-signal-decorators v1.
|
|
2
|
+
* @zakkster/lite-signal-decorators v1.2.0
|
|
3
3
|
* --------------------
|
|
4
|
-
*
|
|
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
|
|
@@ -96,6 +96,13 @@ const CLOSURES = Symbol("lite-signal-decorators.closures");
|
|
|
96
96
|
// wins. Keyed by the frozen signal rec (bounded by the class member count).
|
|
97
97
|
const SIG_INITIAL = new WeakMap();
|
|
98
98
|
|
|
99
|
+
// S8/PD-58: decorator-local field-initial for reinit reset. A @localTo member's
|
|
100
|
+
// field-initializer value is captured (per member, first-seen) in makeLocalInit --
|
|
101
|
+
// undefined means "no initializer" (the @localCopy flavor: reinit reseeds the box
|
|
102
|
+
// from the current upstream). Buildless locals carry hasInitial + initFn on the
|
|
103
|
+
// rec instead. Keyed by the frozen local rec (bounded by the class member count).
|
|
104
|
+
const LOCAL_INITIAL = new WeakMap();
|
|
105
|
+
|
|
99
106
|
// Scratch-frame stack (D-2h): decorator signal boxes are created in accessor
|
|
100
107
|
// `init` during super()'s field initialization -- BEFORE wireInstance's
|
|
101
108
|
// try/catch exists. Each init pushes its box here; the wrapper constructor
|
|
@@ -139,6 +146,7 @@ let AUDIT_FR = null;
|
|
|
139
146
|
|
|
140
147
|
// Known option keys per decorator (unknown-key did-you-mean sets, PD-8/PD-11).
|
|
141
148
|
const KNOWN_OPTION_KEYS = ["equals"];
|
|
149
|
+
const LOCAL_OPTION_KEYS = ["equals"];
|
|
142
150
|
const EFFECT_OPTION_KEYS = ["scheduler"];
|
|
143
151
|
const HOST_OPTION_KEYS = ["registry"];
|
|
144
152
|
const CAP_OPTION_KEYS = ["headroom"];
|
|
@@ -285,6 +293,12 @@ function throwBadScheduler(what) {
|
|
|
285
293
|
);
|
|
286
294
|
}
|
|
287
295
|
|
|
296
|
+
function throwLocalSource() {
|
|
297
|
+
throw new TypeError(
|
|
298
|
+
`${ERR}localTo requires a source function: write \`@localTo((self) => self.upstream) accessor x = ...\`. \`source\` is a tracked (self) -> value read, not an option.`,
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
|
|
288
302
|
function throwUnknownOption(what, key, known) {
|
|
289
303
|
const near = nearestKey(String(key), known);
|
|
290
304
|
throw new TypeError(
|
|
@@ -423,9 +437,10 @@ function throwReinitInitials(ctorName) {
|
|
|
423
437
|
function throwReinitInitialsKey(ctorName, key, plan) {
|
|
424
438
|
const avail = [];
|
|
425
439
|
for (let i = 0; i < plan.signals.length; i++) avail.push(keyLabel(plan.signals[i].key));
|
|
440
|
+
for (let i = 0; i < plan.locals.length; i++) avail.push(keyLabel(plan.locals[i].key));
|
|
426
441
|
const near = nearestKey(keyLabel(key), avail);
|
|
427
442
|
throw new Error(
|
|
428
|
-
`${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal${near ? ` -- did you mean \`${near}\`?` : ""}
|
|
443
|
+
`${ERR}reinitReactive(${ctorName}) initials carries key \`${keyLabel(key)}\` that is not a @reactive signal or @localTo member${near ? ` -- did you mean \`${near}\`?` : ""} Resettable keys: ${avail.join(", ")}.`,
|
|
429
444
|
);
|
|
430
445
|
}
|
|
431
446
|
|
|
@@ -451,9 +466,9 @@ function throwDefineSpec() {
|
|
|
451
466
|
}
|
|
452
467
|
|
|
453
468
|
function throwUnknownSection(key) {
|
|
454
|
-
const near = nearestKey(keyLabel(key), ["signals", "deriveds", "effects", "host"]);
|
|
469
|
+
const near = nearestKey(keyLabel(key), ["signals", "locals", "deriveds", "effects", "host"]);
|
|
455
470
|
throw new TypeError(
|
|
456
|
-
`${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, deriveds, effects, host.`,
|
|
471
|
+
`${ERR}defineReactive spec got unknown section \`${keyLabel(key)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known sections: signals, locals, deriveds, effects, host.`,
|
|
457
472
|
);
|
|
458
473
|
}
|
|
459
474
|
|
|
@@ -538,6 +553,25 @@ function throwBadEffect(key) {
|
|
|
538
553
|
);
|
|
539
554
|
}
|
|
540
555
|
|
|
556
|
+
function throwSpecLocals() {
|
|
557
|
+
throw new TypeError(
|
|
558
|
+
`${ERR}defineReactive spec.locals must be a map of key -> { source, equals?, initial? }.`,
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function throwUnknownLocalDescKey(key, dk) {
|
|
563
|
+
const near = nearestKey(keyLabel(dk), ["source", "equals", "initial"]);
|
|
564
|
+
throw new TypeError(
|
|
565
|
+
`${ERR}defineReactive local ${keyLabel(key)} got unknown descriptor key \`${keyLabel(dk)}\`${near ? ` -- did you mean \`${near}\`?` : ""} Known keys: source, equals, initial.`,
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function throwLocalSourceNotFn(key) {
|
|
570
|
+
throw new TypeError(
|
|
571
|
+
`${ERR}defineReactive local ${keyLabel(key)} \`source\` must be a function (self) -> value.`,
|
|
572
|
+
);
|
|
573
|
+
}
|
|
574
|
+
|
|
541
575
|
function throwSpecCollision(className, key) {
|
|
542
576
|
throw new TypeError(
|
|
543
577
|
`${ERR}defineReactive spec declares ${keyLabel(key)}, but ${className}.prototype already owns that member -- a spec-declared member cannot collide with a hand-written one.`,
|
|
@@ -550,6 +584,49 @@ function makeGet(slot) { return function () { return this[slot].get(); }; }
|
|
|
550
584
|
function makeSet(slot) { return function (v) { this[slot].set(v); }; }
|
|
551
585
|
function makeDerivedGet(slot) { return function () { return this[slot].get(); }; }
|
|
552
586
|
|
|
587
|
+
// S8 hot bodies (0014): the @localTo accessor pair. NEW bodies -- the 1.0.0 canon
|
|
588
|
+
// above stays byte-identical (S8-A5); @localTo pays its own measured cost. Both
|
|
589
|
+
// are prebuilt ONCE per member and close over the member's slots + source + equals,
|
|
590
|
+
// so a read/write allocates nothing (the same prebuilt-closure discipline as
|
|
591
|
+
// makeGet/makeSet). Two per-instance stores back a local: the box slot (a signal
|
|
592
|
+
// node, the local value) and the seen slot (a PLAIN field, the last-adopted
|
|
593
|
+
// upstream value). makeLocalGet is PURE (0014 read law): it calls the tracked
|
|
594
|
+
// source, equals-compares it against the seen slot, and returns the local box on
|
|
595
|
+
// an UNCHANGED upstream, else the upstream value -- never writing any box on the
|
|
596
|
+
// read path, so a localTo read is legal inside any @derived compute.
|
|
597
|
+
function makeLocalGet(rec) {
|
|
598
|
+
const source = rec.source;
|
|
599
|
+
const eq = rec.eq;
|
|
600
|
+
const boxSlot = rec.slot;
|
|
601
|
+
const seenSlot = rec.seenSlot;
|
|
602
|
+
return function () {
|
|
603
|
+
const up = source.call(this, this);
|
|
604
|
+
if (eq(up, this[seenSlot])) return this[boxSlot].get();
|
|
605
|
+
return up;
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
// makeLocalSet: box.set (a write always overrides -- PD-56 never compares) + seen
|
|
609
|
+
// slot = upstream-at-write. The seen capture must NOT subscribe: a write inside an
|
|
610
|
+
// effect body runs under that effect's tracking scope, so a tracked source read
|
|
611
|
+
// there would silently link the effect to the upstream (a fail-open dep leak,
|
|
612
|
+
// measured: reg.isTracking() -> a bare source read re-fires the effect on an
|
|
613
|
+
// upstream move). The isTracking() gate untracks ONLY under an active scope --
|
|
614
|
+
// the same idiom makeEffectPublic uses (D-4b) -- so the plain-code write path
|
|
615
|
+
// (the hot path) stays a zero-alloc source.call, and only the rare in-effect write
|
|
616
|
+
// pays the untrack thunk. reg comes from the frozen rec.plan (set at claimPlan).
|
|
617
|
+
function makeLocalSet(rec) {
|
|
618
|
+
const source = rec.source;
|
|
619
|
+
const boxSlot = rec.slot;
|
|
620
|
+
const seenSlot = rec.seenSlot;
|
|
621
|
+
return function (v) {
|
|
622
|
+
this[boxSlot].set(v);
|
|
623
|
+
const reg = rec.plan.reg;
|
|
624
|
+
this[seenSlot] = reg.isTracking()
|
|
625
|
+
? reg.untrack(() => source.call(this, this))
|
|
626
|
+
: source.call(this, this);
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
553
630
|
function makeInit(rec) {
|
|
554
631
|
return function (v) {
|
|
555
632
|
if (rec.plan === null) throwMissingHost(rec);
|
|
@@ -563,6 +640,69 @@ function makeInit(rec) {
|
|
|
563
640
|
};
|
|
564
641
|
}
|
|
565
642
|
|
|
643
|
+
// S8 (cold): seed one @localTo member's two stores on an instance. seen = the
|
|
644
|
+
// source read at wiring, UNTRACKED (wiring/reinit must register no dependency);
|
|
645
|
+
// the box starts at the declared initial when present, else at that same upstream
|
|
646
|
+
// value (the initial-value unification rule, 0014: an initializer -> @trackedReset
|
|
647
|
+
// flavor; no initializer -> @localCopy flavor). Returns the box for SCRATCH
|
|
648
|
+
// rollback. Shared by the decorator init, the buildless wire loop, and reinit.
|
|
649
|
+
function seedLocal(inst, rec, reg, hasInitial, initialValue) {
|
|
650
|
+
const source = rec.source;
|
|
651
|
+
const seen = reg.isTracking()
|
|
652
|
+
? reg.untrack(() => source.call(inst, inst))
|
|
653
|
+
: source.call(inst, inst);
|
|
654
|
+
// The box is created WITHOUT the equals opts: {equals} governs the UPSTREAM
|
|
655
|
+
// compare only (PD-56); the box uses default equals so a local write always
|
|
656
|
+
// propagates (a write overrides, never suppresses).
|
|
657
|
+
const box = reg.signalBox(hasInitial ? initialValue : seen);
|
|
658
|
+
inst[rec.slot] = box;
|
|
659
|
+
inst[rec.seenSlot] = seen;
|
|
660
|
+
return box;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// S8 (cold): the frozen local rec, shared by the decorator (@localTo) and buildless
|
|
664
|
+
// (spec.locals) paths. eq defaults to Object.is (0014 read law). initFn is null for
|
|
665
|
+
// the decorator path (the box is born at field-init time, like a decorator signal)
|
|
666
|
+
// and a per-instance factory for the buildless path (born in wireInstance).
|
|
667
|
+
function makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial) {
|
|
668
|
+
const eq = opts !== undefined && opts.equals !== undefined ? opts.equals : Object.is;
|
|
669
|
+
const rec = {
|
|
670
|
+
kind: "local",
|
|
671
|
+
key,
|
|
672
|
+
slot,
|
|
673
|
+
seenSlot,
|
|
674
|
+
source,
|
|
675
|
+
eq,
|
|
676
|
+
opts,
|
|
677
|
+
get: null,
|
|
678
|
+
set: null,
|
|
679
|
+
fn: null,
|
|
680
|
+
plan: null,
|
|
681
|
+
poison: null,
|
|
682
|
+
prewired: null,
|
|
683
|
+
parked: null,
|
|
684
|
+
initFn,
|
|
685
|
+
hasInitial,
|
|
686
|
+
};
|
|
687
|
+
rec.get = makeLocalGet(rec);
|
|
688
|
+
rec.set = makeLocalSet(rec);
|
|
689
|
+
return rec;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
// S8 (cold): the decorator @localTo init -- mirrors makeInit. The box + seen are
|
|
693
|
+
// born during super()'s field initialization; the box joins the SCRATCH frame for
|
|
694
|
+
// init-phase rollback. The field-initial value is captured per member (undefined
|
|
695
|
+
// means "no initializer" -> the @localCopy reset flavor) for reinit.
|
|
696
|
+
function makeLocalInit(rec) {
|
|
697
|
+
return function (v) {
|
|
698
|
+
if (rec.plan === null) throwMissingHost(rec);
|
|
699
|
+
const box = seedLocal(this, rec, rec.plan.reg, v !== undefined, v);
|
|
700
|
+
SCRATCH.push(box); // D-2h: track for init-phase rollback
|
|
701
|
+
if (!LOCAL_INITIAL.has(rec)) LOCAL_INITIAL.set(rec, v);
|
|
702
|
+
return v; // emitter backing store, unused
|
|
703
|
+
};
|
|
704
|
+
}
|
|
705
|
+
|
|
566
706
|
// --- Option validation (cold) -------------------------------------------------
|
|
567
707
|
|
|
568
708
|
function isStandardContext(c) {
|
|
@@ -583,6 +723,21 @@ function validateOptions(what, opts) {
|
|
|
583
723
|
return Object.freeze({ equals: opts.equals });
|
|
584
724
|
}
|
|
585
725
|
|
|
726
|
+
function validateLocalOptions(opts) {
|
|
727
|
+
// localTo's OWN key set (equals only) -- NOT the shared reactive/derived
|
|
728
|
+
// validator: admitting `source` there would silently accept @derived({source})
|
|
729
|
+
// (fail-open, PLAN-S8 spelling call). Returns a frozen { equals } or undefined.
|
|
730
|
+
if (opts === undefined || opts === null) return undefined;
|
|
731
|
+
if (typeof opts !== "object") throwUsage("localTo");
|
|
732
|
+
const keys = Object.keys(opts);
|
|
733
|
+
for (let i = 0; i < keys.length; i++) {
|
|
734
|
+
if (keys[i] !== "equals") throwUnknownOption("localTo", keys[i], LOCAL_OPTION_KEYS);
|
|
735
|
+
}
|
|
736
|
+
if ("equals" in opts && opts.equals !== undefined && typeof opts.equals !== "function") throwBadEquals("localTo");
|
|
737
|
+
if (opts.equals === undefined) return undefined;
|
|
738
|
+
return Object.freeze({ equals: opts.equals });
|
|
739
|
+
}
|
|
740
|
+
|
|
586
741
|
function validateEffectOptions(opts) {
|
|
587
742
|
// Returns a frozen { scheduler } copy, or undefined for the bare form.
|
|
588
743
|
if (opts === undefined || opts === null) return undefined;
|
|
@@ -668,6 +823,36 @@ export function reactive(target, ctx) {
|
|
|
668
823
|
return function (t, c) { return applyReactive(t, c, opts); };
|
|
669
824
|
}
|
|
670
825
|
|
|
826
|
+
// --- localTo (S8, 0014) -------------------------------------------------------
|
|
827
|
+
|
|
828
|
+
function applyLocalTo(target, ctx, source, opts) {
|
|
829
|
+
if (!isStandardContext(ctx)) throwLegacyEmit("localTo");
|
|
830
|
+
if (ctx.kind !== "accessor") {
|
|
831
|
+
throwWrongKind("localTo", "accessor", ctx.kind, "write `@localTo(source) accessor x = ...`.");
|
|
832
|
+
}
|
|
833
|
+
if (ctx.static === true) throwStatic("localTo", ctx.name);
|
|
834
|
+
if (ctx.private === true) throwPrivate("localTo", ctx.name);
|
|
835
|
+
const nm = typeof ctx.name === "symbol" ? "local" : "local:" + String(ctx.name);
|
|
836
|
+
const rec = makeLocalRec(ctx.name, Symbol(nm), Symbol(nm + ":seen"), source, opts, null, false);
|
|
837
|
+
PENDING.push(rec);
|
|
838
|
+
return { get: rec.get, set: rec.set, init: makeLocalInit(rec) };
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/**
|
|
842
|
+
* `@localTo(source) accessor x = v` -- upstream-keyed resettable local state
|
|
843
|
+
* (0014). Reads compare the tracked `source(self)` against a per-instance
|
|
844
|
+
* last-seen slot: an unchanged upstream yields the local override, a changed
|
|
845
|
+
* upstream resets to it. A write always overrides. With an initializer the member
|
|
846
|
+
* STARTS there and resets on the first upstream move; without one it FOLLOWS
|
|
847
|
+
* upstream from wiring. `@localTo(source)` or `@localTo(source, { equals })` --
|
|
848
|
+
* `equals` governs the upstream compare ONLY. `source` is REQUIRED.
|
|
849
|
+
*/
|
|
850
|
+
export function localTo(source, options) {
|
|
851
|
+
if (typeof source !== "function") throwLocalSource();
|
|
852
|
+
const opts = validateLocalOptions(options);
|
|
853
|
+
return function (t, c) { return applyLocalTo(t, c, source, opts); };
|
|
854
|
+
}
|
|
855
|
+
|
|
671
856
|
// --- derived ------------------------------------------------------------------
|
|
672
857
|
|
|
673
858
|
function applyDerived(value, ctx, opts) {
|
|
@@ -834,7 +1019,7 @@ function buildHandles(rec, ctorName) {
|
|
|
834
1019
|
get() { throw new ReactiveDisposedError(ctorName, key); },
|
|
835
1020
|
set(v) { throw new ReactiveDisposedError(ctorName, key); },
|
|
836
1021
|
});
|
|
837
|
-
const msg = rec.kind === "signal"
|
|
1022
|
+
const msg = rec.kind === "signal" || rec.kind === "local"
|
|
838
1023
|
? `${ERR}${ctorName}.${keyLabel(key)} read/write before its initializer ran (declaration order).`
|
|
839
1024
|
: `${ERR}${ctorName}.${keyLabel(key)} read before construction completed (deriveds are available after wiring).`;
|
|
840
1025
|
rec.prewired = Object.freeze({
|
|
@@ -882,6 +1067,7 @@ function claimPlan(C, ctorName, registry) {
|
|
|
882
1067
|
}
|
|
883
1068
|
|
|
884
1069
|
const signals = [];
|
|
1070
|
+
const locals = [];
|
|
885
1071
|
const deriveds = [];
|
|
886
1072
|
const effects = [];
|
|
887
1073
|
const byKey = new Map();
|
|
@@ -891,6 +1077,12 @@ function claimPlan(C, ctorName, registry) {
|
|
|
891
1077
|
signals.push(r);
|
|
892
1078
|
byKey.set(r.key, r);
|
|
893
1079
|
}
|
|
1080
|
+
// PD-55: locals live in their OWN array; L is a first-class accounting term.
|
|
1081
|
+
for (let i = 0; i < ancestor.locals.length; i++) {
|
|
1082
|
+
const r = ancestor.locals[i];
|
|
1083
|
+
locals.push(r);
|
|
1084
|
+
byKey.set(r.key, r);
|
|
1085
|
+
}
|
|
894
1086
|
for (let i = 0; i < ancestor.deriveds.length; i++) {
|
|
895
1087
|
const r = ancestor.deriveds[i];
|
|
896
1088
|
deriveds.push(r);
|
|
@@ -920,7 +1112,7 @@ function claimPlan(C, ctorName, registry) {
|
|
|
920
1112
|
const rec = own[i];
|
|
921
1113
|
const desc = Object.getOwnPropertyDescriptor(proto, rec.key);
|
|
922
1114
|
let installed;
|
|
923
|
-
if (rec.kind === "signal" || rec.kind === "derived") {
|
|
1115
|
+
if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") {
|
|
924
1116
|
installed = desc !== undefined && desc.get === rec.get;
|
|
925
1117
|
} else {
|
|
926
1118
|
installed = desc !== undefined && desc.value === rec.pub;
|
|
@@ -931,8 +1123,9 @@ function claimPlan(C, ctorName, registry) {
|
|
|
931
1123
|
|
|
932
1124
|
for (let i = 0; i < own.length; i++) {
|
|
933
1125
|
const rec = own[i];
|
|
934
|
-
if (rec.kind === "signal" || rec.kind === "derived") buildHandles(rec, ctorName);
|
|
1126
|
+
if (rec.kind === "signal" || rec.kind === "derived" || rec.kind === "local") buildHandles(rec, ctorName);
|
|
935
1127
|
if (rec.kind === "signal") signals.push(rec);
|
|
1128
|
+
else if (rec.kind === "local") locals.push(rec);
|
|
936
1129
|
else if (rec.kind === "derived") deriveds.push(rec);
|
|
937
1130
|
else if (rec.kind === "effect") effects.push(rec);
|
|
938
1131
|
// batched recs join byKey only (no node) for reg resolution + diagnostics.
|
|
@@ -943,6 +1136,7 @@ function claimPlan(C, ctorName, registry) {
|
|
|
943
1136
|
ctorName,
|
|
944
1137
|
reg,
|
|
945
1138
|
signals: Object.freeze(signals),
|
|
1139
|
+
locals: Object.freeze(locals),
|
|
946
1140
|
deriveds: Object.freeze(deriveds),
|
|
947
1141
|
effects: Object.freeze(effects),
|
|
948
1142
|
byKey,
|
|
@@ -1041,6 +1235,13 @@ function wireInstance(inst, plan) {
|
|
|
1041
1235
|
const r = sigs[i];
|
|
1042
1236
|
if (r.initFn !== null) inst[r.slot] = reg.signalBox(r.initFn(inst), r.opts);
|
|
1043
1237
|
}
|
|
1238
|
+
// Buildless locals: seed box + seen in spec order BEFORE the anchor.
|
|
1239
|
+
// Decorator locals already exist from field-init time (initFn === null).
|
|
1240
|
+
const locs = plan.locals;
|
|
1241
|
+
for (let i = 0; i < locs.length; i++) {
|
|
1242
|
+
const r = locs[i];
|
|
1243
|
+
if (r.initFn !== null) seedLocal(inst, r, reg, r.hasInitial, r.initFn(inst));
|
|
1244
|
+
}
|
|
1044
1245
|
let a;
|
|
1045
1246
|
reg.createRoot(() => { reg.effect(() => { a = reg.getOwner(); }); }); // R-A anchor
|
|
1046
1247
|
inst[ANCHOR] = a;
|
|
@@ -1090,6 +1291,16 @@ function disposeCore(inst, plan) { // assumes not already disposed
|
|
|
1090
1291
|
if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
|
|
1091
1292
|
inst[r.slot] = r.poison;
|
|
1092
1293
|
}
|
|
1294
|
+
// S8: locals dispose exactly like signals -- dispose the box, poison the box
|
|
1295
|
+
// slot (a touch throws the named ReactiveDisposedError, 0014 dispose lattice).
|
|
1296
|
+
// The seen slot is a plain field; it is left as-is (the poisoned box guards it).
|
|
1297
|
+
const locs = plan.locals;
|
|
1298
|
+
for (let i = 0; i < locs.length; i++) {
|
|
1299
|
+
const r = locs[i];
|
|
1300
|
+
const box = inst[r.slot];
|
|
1301
|
+
if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
|
|
1302
|
+
inst[r.slot] = r.poison;
|
|
1303
|
+
}
|
|
1093
1304
|
const ders = plan.deriveds;
|
|
1094
1305
|
for (let i = 0; i < ders.length; i++) {
|
|
1095
1306
|
const r = ders[i];
|
|
@@ -1120,6 +1331,17 @@ function applyReactiveHost(C, ctx, registry) {
|
|
|
1120
1331
|
enumerable: false,
|
|
1121
1332
|
});
|
|
1122
1333
|
}
|
|
1334
|
+
// S8: a local's box slot gets the same prewired guard (the seen slot is a
|
|
1335
|
+
// plain field, undefined until init -- no proto guard needed).
|
|
1336
|
+
for (let i = 0; i < plan.locals.length; i++) {
|
|
1337
|
+
const r = plan.locals[i];
|
|
1338
|
+
Object.defineProperty(C.prototype, r.slot, {
|
|
1339
|
+
value: r.prewired,
|
|
1340
|
+
writable: true,
|
|
1341
|
+
configurable: true,
|
|
1342
|
+
enumerable: false,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1123
1345
|
for (let i = 0; i < plan.deriveds.length; i++) {
|
|
1124
1346
|
const r = plan.deriveds[i];
|
|
1125
1347
|
Object.defineProperty(C.prototype, r.slot, {
|
|
@@ -1261,6 +1483,38 @@ function normalizeSignals(spec, recs) {
|
|
|
1261
1483
|
}
|
|
1262
1484
|
}
|
|
1263
1485
|
|
|
1486
|
+
// S8/PD-57: the buildless `locals` section -- { key: { source, equals?, initial? } }.
|
|
1487
|
+
// Fail closed on a missing/non-fn source (a local without a source is meaningless).
|
|
1488
|
+
// An `initial` present selects the @trackedReset flavor; absent, the @localCopy
|
|
1489
|
+
// flavor (initFn returns undefined, hasInitial false -> seedLocal reads upstream).
|
|
1490
|
+
function normalizeLocalEntry(key, entry) {
|
|
1491
|
+
if (entry === null || typeof entry !== "object" || Array.isArray(entry)) throwLocalSourceNotFn(key);
|
|
1492
|
+
const dkeys = Reflect.ownKeys(entry);
|
|
1493
|
+
for (let i = 0; i < dkeys.length; i++) {
|
|
1494
|
+
const dk = dkeys[i];
|
|
1495
|
+
if (dk !== "source" && dk !== "equals" && dk !== "initial") throwUnknownLocalDescKey(key, dk);
|
|
1496
|
+
}
|
|
1497
|
+
const source = entry.source;
|
|
1498
|
+
if (typeof source !== "function") throwLocalSourceNotFn(key);
|
|
1499
|
+
const opts = normalizeEquals(entry, `defineReactive local ${keyLabel(key)}`);
|
|
1500
|
+
const hasInitial = Object.prototype.hasOwnProperty.call(entry, "initial");
|
|
1501
|
+
const initial = hasInitial ? entry.initial : undefined;
|
|
1502
|
+
const initFn = function () { return initial; };
|
|
1503
|
+
const slot = Symbol(typeof key === "symbol" ? "local" : "local:" + String(key));
|
|
1504
|
+
const seenSlot = Symbol(typeof key === "symbol" ? "local:seen" : "local:" + String(key) + ":seen");
|
|
1505
|
+
return makeLocalRec(key, slot, seenSlot, source, opts, initFn, hasInitial);
|
|
1506
|
+
}
|
|
1507
|
+
|
|
1508
|
+
function normalizeLocals(spec, recs) {
|
|
1509
|
+
const l = spec.locals;
|
|
1510
|
+
if (l === undefined) return;
|
|
1511
|
+
if (l === null || typeof l !== "object" || Array.isArray(l)) throwSpecLocals();
|
|
1512
|
+
const keys = Reflect.ownKeys(l);
|
|
1513
|
+
for (let i = 0; i < keys.length; i++) {
|
|
1514
|
+
recs.push(normalizeLocalEntry(keys[i], l[keys[i]]));
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1264
1518
|
function makeDerivedRec(key, fn, opts) {
|
|
1265
1519
|
const slot = Symbol(typeof key === "symbol" ? "derived" : "derived:" + String(key));
|
|
1266
1520
|
return {
|
|
@@ -1354,6 +1608,13 @@ function installRec(rec, proto) {
|
|
|
1354
1608
|
enumerable: true,
|
|
1355
1609
|
configurable: true,
|
|
1356
1610
|
});
|
|
1611
|
+
} else if (rec.kind === "local") {
|
|
1612
|
+
Object.defineProperty(proto, rec.key, {
|
|
1613
|
+
get: rec.get,
|
|
1614
|
+
set: rec.set,
|
|
1615
|
+
enumerable: true,
|
|
1616
|
+
configurable: true,
|
|
1617
|
+
});
|
|
1357
1618
|
} else if (rec.kind === "derived") {
|
|
1358
1619
|
Object.defineProperty(proto, rec.key, {
|
|
1359
1620
|
get: rec.get,
|
|
@@ -1382,13 +1643,14 @@ export function defineReactive(Class, spec) {
|
|
|
1382
1643
|
const sections = Reflect.ownKeys(spec);
|
|
1383
1644
|
for (let i = 0; i < sections.length; i++) {
|
|
1384
1645
|
const k = sections[i];
|
|
1385
|
-
if (k !== "signals" && k !== "deriveds" && k !== "effects" && k !== "host") {
|
|
1646
|
+
if (k !== "signals" && k !== "locals" && k !== "deriveds" && k !== "effects" && k !== "host") {
|
|
1386
1647
|
throwUnknownSection(k);
|
|
1387
1648
|
}
|
|
1388
1649
|
}
|
|
1389
1650
|
|
|
1390
1651
|
const recs = [];
|
|
1391
1652
|
normalizeSignals(spec, recs); // signals first (spec/wire order)
|
|
1653
|
+
normalizeLocals(spec, recs); // locals second (PD-57)
|
|
1392
1654
|
normalizeDeriveds(spec, recs);
|
|
1393
1655
|
normalizeEffects(spec, recs);
|
|
1394
1656
|
const registry = validateHostOptions(spec.host); // shared PD-11 validation
|
|
@@ -1487,6 +1749,16 @@ function releaseCore(inst, plan) { // assumes a LIVE instance
|
|
|
1487
1749
|
if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
|
|
1488
1750
|
inst[r.slot] = r.parked;
|
|
1489
1751
|
}
|
|
1752
|
+
// S8: a parked local releases its box node (swap the box slot to the parked
|
|
1753
|
+
// handle) but RETAINS the seen slot as a plain record field (0014 park lattice);
|
|
1754
|
+
// reinit re-creates the box and resets both (PD-58).
|
|
1755
|
+
const locs = plan.locals;
|
|
1756
|
+
for (let i = 0; i < locs.length; i++) {
|
|
1757
|
+
const r = locs[i];
|
|
1758
|
+
const box = inst[r.slot];
|
|
1759
|
+
if (box !== undefined && box[NONLIVE] === undefined) reg.dispose(box);
|
|
1760
|
+
inst[r.slot] = r.parked;
|
|
1761
|
+
}
|
|
1490
1762
|
const ders = plan.deriveds;
|
|
1491
1763
|
for (let i = 0; i < ders.length; i++) {
|
|
1492
1764
|
inst[ders[i].slot] = ders[i].parked; // cboxes already cascaded
|
|
@@ -1562,7 +1834,10 @@ export function reinitReactive(vm, initials) {
|
|
|
1562
1834
|
const ikeys = Reflect.ownKeys(initials);
|
|
1563
1835
|
for (let i = 0; i < ikeys.length; i++) {
|
|
1564
1836
|
const rec = plan.byKey.get(ikeys[i]);
|
|
1565
|
-
|
|
1837
|
+
// PD-58: initials[] accepts @reactive signal keys AND @localTo keys.
|
|
1838
|
+
if (rec === undefined || (rec.kind !== "signal" && rec.kind !== "local")) {
|
|
1839
|
+
throwReinitInitialsKey(plan.ctorName, ikeys[i], plan);
|
|
1840
|
+
}
|
|
1566
1841
|
}
|
|
1567
1842
|
}
|
|
1568
1843
|
const reg = plan.reg;
|
|
@@ -1585,6 +1860,30 @@ export function reinitReactive(vm, initials) {
|
|
|
1585
1860
|
}
|
|
1586
1861
|
vm[r.slot] = reg.signalBox(v, r.opts);
|
|
1587
1862
|
}
|
|
1863
|
+
// PD-58: each local resets its box -> initial AND its seen slot -> the
|
|
1864
|
+
// CURRENT upstream (seedLocal reads source untracked). Precedence mirrors
|
|
1865
|
+
// the signal loop: caller override, then the buildless plan initFn (with its
|
|
1866
|
+
// hasInitial flag), then the decorator field-initial captured in
|
|
1867
|
+
// makeLocalInit (undefined -> the @localCopy flavor: reseed the box from
|
|
1868
|
+
// the current upstream). Locals rebuild BEFORE buildGraph so deriveds/effects
|
|
1869
|
+
// see them on the first synchronous run (D-4a), same as construction.
|
|
1870
|
+
const locs = plan.locals;
|
|
1871
|
+
for (let i = 0; i < locs.length; i++) {
|
|
1872
|
+
const r = locs[i];
|
|
1873
|
+
let hasInitial;
|
|
1874
|
+
let v;
|
|
1875
|
+
if (initials !== undefined && Object.prototype.hasOwnProperty.call(initials, r.key)) {
|
|
1876
|
+
hasInitial = true;
|
|
1877
|
+
v = initials[r.key];
|
|
1878
|
+
} else if (r.initFn !== null) {
|
|
1879
|
+
hasInitial = r.hasInitial;
|
|
1880
|
+
v = r.initFn(vm);
|
|
1881
|
+
} else {
|
|
1882
|
+
v = LOCAL_INITIAL.get(r);
|
|
1883
|
+
hasInitial = v !== undefined;
|
|
1884
|
+
}
|
|
1885
|
+
seedLocal(vm, r, reg, hasInitial, v);
|
|
1886
|
+
}
|
|
1588
1887
|
buildGraph(vm, plan, closures);
|
|
1589
1888
|
} catch (e) {
|
|
1590
1889
|
disposeCore(vm, plan); // failed revival is terminal -> DISPOSED
|
|
@@ -1656,7 +1955,7 @@ function throwCostInconclusive(name, a, b) {
|
|
|
1656
1955
|
|
|
1657
1956
|
function throwCostNodeMismatch(name, got, want) {
|
|
1658
1957
|
throw new Error(
|
|
1659
|
-
`${ERR}costOf(${name}) -- probed node count ${got} != P+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
|
|
1958
|
+
`${ERR}costOf(${name}) -- probed node count ${got} != P+L+D+E+1 (${want}); the bound registry was not quiet during the probe.`,
|
|
1660
1959
|
);
|
|
1661
1960
|
}
|
|
1662
1961
|
|
|
@@ -1688,9 +1987,9 @@ function probeCost(Factory, plan, reg) {
|
|
|
1688
1987
|
* Measure the settled per-instance cost of a reactive class on its bound
|
|
1689
1988
|
* registry: construct, read every `@derived` once (forcing the lazy links),
|
|
1690
1989
|
* snapshot, dispose, verify the floor -- twice, requiring identical deltas.
|
|
1691
|
-
* Returns a frozen `{ nodes, links, signals, deriveds, effects }`;
|
|
1692
|
-
* equals P+D+E+1. Cached per class. Throws (never guesses) on an
|
|
1693
|
-
* or polluted probe. Constructs the probe instance with no arguments.
|
|
1990
|
+
* Returns a frozen `{ nodes, links, signals, locals, deriveds, effects }`;
|
|
1991
|
+
* `nodes` equals P+L+D+E+1. Cached per class. Throws (never guesses) on an
|
|
1992
|
+
* inconclusive or polluted probe. Constructs the probe instance with no arguments.
|
|
1694
1993
|
*/
|
|
1695
1994
|
export function costOf(Factory) {
|
|
1696
1995
|
if (typeof Factory !== "function") throwCostFactory();
|
|
@@ -1709,14 +2008,18 @@ export function costOf(Factory) {
|
|
|
1709
2008
|
throwCostInconclusive(plan.ctorName, first, second);
|
|
1710
2009
|
}
|
|
1711
2010
|
const sig = plan.signals.length;
|
|
2011
|
+
const loc = plan.locals.length;
|
|
1712
2012
|
const der = plan.deriveds.length;
|
|
1713
2013
|
const eff = plan.effects.length;
|
|
1714
|
-
|
|
2014
|
+
// S8: each @localTo member is exactly 1 box node (its seen slot is a plain
|
|
2015
|
+
// field, 0 nodes), so the node formula is P + L + D + E + 1 (0014 cost law).
|
|
2016
|
+
const expected = sig + loc + der + eff + 1;
|
|
1715
2017
|
if (first.nodes !== expected) throwCostNodeMismatch(plan.ctorName, first.nodes, expected);
|
|
1716
2018
|
const result = Object.freeze({
|
|
1717
2019
|
nodes: first.nodes,
|
|
1718
2020
|
links: first.links,
|
|
1719
2021
|
signals: sig,
|
|
2022
|
+
locals: loc,
|
|
1720
2023
|
deriveds: der,
|
|
1721
2024
|
effects: eff,
|
|
1722
2025
|
});
|
|
@@ -1820,11 +2123,13 @@ function labelStringsFor(plan) {
|
|
|
1820
2123
|
const name = plan.ctorName;
|
|
1821
2124
|
const sig = [];
|
|
1822
2125
|
for (let i = 0; i < plan.signals.length; i++) sig.push(`${name}.${keyLabel(plan.signals[i].key)}`);
|
|
2126
|
+
const loc = [];
|
|
2127
|
+
for (let i = 0; i < plan.locals.length; i++) loc.push(`${name}.${keyLabel(plan.locals[i].key)}`);
|
|
1823
2128
|
const der = [];
|
|
1824
2129
|
for (let i = 0; i < plan.deriveds.length; i++) der.push(`${name}.${keyLabel(plan.deriveds[i].key)}`);
|
|
1825
2130
|
const eff = [];
|
|
1826
2131
|
for (let i = 0; i < plan.effects.length; i++) eff.push(`${name}#${keyLabel(plan.effects[i].key)}`);
|
|
1827
|
-
s = { anchor: `${name}@anchor`, signals: sig, deriveds: der, effects: eff };
|
|
2132
|
+
s = { anchor: `${name}@anchor`, signals: sig, locals: loc, deriveds: der, effects: eff };
|
|
1828
2133
|
LABEL_STRINGS.set(plan, s);
|
|
1829
2134
|
return s;
|
|
1830
2135
|
}
|
|
@@ -1844,6 +2149,11 @@ function registerLabels(inst, plan, reg, effHandles) {
|
|
|
1844
2149
|
const id = reg.nodeId(inst[sigs[i].slot]);
|
|
1845
2150
|
if (id !== undefined) { map.set(id, strings.signals[i]); ids.push(id); }
|
|
1846
2151
|
}
|
|
2152
|
+
const locs = plan.locals;
|
|
2153
|
+
for (let i = 0; i < locs.length; i++) {
|
|
2154
|
+
const id = reg.nodeId(inst[locs[i].slot]);
|
|
2155
|
+
if (id !== undefined) { map.set(id, strings.locals[i]); ids.push(id); }
|
|
2156
|
+
}
|
|
1847
2157
|
const ders = plan.deriveds;
|
|
1848
2158
|
for (let i = 0; i < ders.length; i++) {
|
|
1849
2159
|
const id = reg.nodeId(inst[ders[i].slot]);
|
|
@@ -1944,4 +2254,4 @@ export function auditReactive(on) {
|
|
|
1944
2254
|
// --- Version ------------------------------------------------------------------
|
|
1945
2255
|
|
|
1946
2256
|
/** Package version. Kept in lockstep with package.json and llms.txt. */
|
|
1947
|
-
export const VERSION = "1.
|
|
2257
|
+
export const VERSION = "1.2.0";
|
package/llms.txt
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# @zakkster/lite-signal-decorators
|
|
2
2
|
|
|
3
|
-
VERSION 1.
|
|
3
|
+
VERSION 1.2.0
|
|
4
4
|
|
|
5
|
-
>
|
|
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,12 +22,34 @@ 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 (
|
|
25
|
+
## Exports (19)
|
|
24
26
|
|
|
25
27
|
- `reactive` -- `@reactive accessor x = v` (bare) or `@reactive({ equals })`
|
|
26
28
|
(factory). Declares a per-instance signal.
|
|
27
29
|
- `derived` -- `@derived get y()` (bare) or `@derived({ equals })` (factory).
|
|
28
30
|
Declares a lazy computed derived from other reactive members.
|
|
31
|
+
- `localTo` -- `@localTo(source) accessor x = v` or
|
|
32
|
+
`@localTo(source, { equals? })`. Upstream-keyed resettable local state
|
|
33
|
+
(decisions/0014). `source` is a REQUIRED tracked `(self) => value` fn, called
|
|
34
|
+
inline in the read body (no extra node, PD-54). The read is PURE (never writes
|
|
35
|
+
a box): it calls `source`, compares the value to a per-instance last-seen slot
|
|
36
|
+
via `equals` (default `Object.is`), and returns the local box `.get()` when
|
|
37
|
+
upstream is unchanged, else the upstream value (a changed upstream resets to
|
|
38
|
+
it). A write always overrides -- the write path never compares (PD-56).
|
|
39
|
+
Initial-value unification (0014): with an initializer the member STARTS at that
|
|
40
|
+
value and resets on the first upstream move (@trackedReset flavor); with NO
|
|
41
|
+
initializer the initial is the source evaluated once at wiring and the member
|
|
42
|
+
FOLLOWS upstream from the first read (@localCopy flavor) -- one decorator, both
|
|
43
|
+
semantics, selected by the natural syntax. `equals` governs the UPSTREAM
|
|
44
|
+
compare ONLY. A throwing source propagates from the read, mutating nothing
|
|
45
|
+
(fail closed). ABA contract (shipped, honest, never softened): no public epoch
|
|
46
|
+
exists (NodeDescriptor is {id, kind, value}), so the compare is VALUE-based --
|
|
47
|
+
upstream A -> local write X -> upstream B -> upstream back to an equals-A value
|
|
48
|
+
-> the read shows the STALE LOCAL X (the reset needs upstream to change
|
|
49
|
+
relative to the last adoption, not to have moved transitively). tracked-toolbox
|
|
50
|
+
@localCopy has the same property. Storage per member: ONE signal box + ONE
|
|
51
|
+
plain per-instance seen-slot (never reactive); cost delta +1 node, +0 for the
|
|
52
|
+
slot -- the per-instance formula becomes P+L+D+E+1.
|
|
29
53
|
- `reactiveEffect` -- `@reactiveEffect m()` (bare) or
|
|
30
54
|
`@reactiveEffect({ scheduler })` (factory). A method that auto-runs as an
|
|
31
55
|
effect once the instance is wired. The auto-effect tracks; the public method is
|
|
@@ -43,7 +67,7 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
43
67
|
(factory). The single wiring site; wraps the class. A `registry` isolates the
|
|
44
68
|
whole host chain (see the registry law).
|
|
45
69
|
- `defineReactive(Class, spec) -> Class'` -- the buildless twin. `spec` is
|
|
46
|
-
`{ signals, deriveds, effects, host }`; it installs the members on
|
|
70
|
+
`{ signals, deriveds, locals, effects, host }`; it installs the members on
|
|
47
71
|
`Class.prototype` and wraps the class through the same host step. See the
|
|
48
72
|
buildless contract.
|
|
49
73
|
- `disposeReactive(vm) -> boolean` -- cascade + poison teardown. Idempotent: a
|
|
@@ -68,7 +92,7 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
68
92
|
- `costOf(Factory) -> { nodes, links, signals, deriveds, effects }` -- the
|
|
69
93
|
measured settled per-instance cost on the class's bound registry (frozen,
|
|
70
94
|
cached). Double-probed: an inconclusive or polluted probe throws, never
|
|
71
|
-
guesses. `nodes` = P+D+E+1; `links` = the first-full-read link count. See
|
|
95
|
+
guesses. `nodes` = P+L+D+E+1; `links` = the first-full-read link count. See
|
|
72
96
|
"Introspection & audit".
|
|
73
97
|
- `capacityFor(inventory, { headroom }?) -> RegistryConfig` -- size a
|
|
74
98
|
`createRegistry` config from `[Factory, count]` pairs. Nodes exact, links x
|
|
@@ -80,7 +104,7 @@ slot for a poison handle, so any later read/write throws a named
|
|
|
80
104
|
`FinalizationRegistry` reports any instance GC'd without `disposeReactive`.
|
|
81
105
|
- `ReactiveDisposedError` -- `extends Error`, `name` `"ReactiveDisposedError"`,
|
|
82
106
|
fields `className` and `key`.
|
|
83
|
-
- `VERSION` -- `"1.
|
|
107
|
+
- `VERSION` -- `"1.2.0"`.
|
|
84
108
|
|
|
85
109
|
## Registry law (one registry per host chain)
|
|
86
110
|
|
|
@@ -123,7 +147,8 @@ no node leaks, on the decorator path (init-phase) and the buildless path alike.
|
|
|
123
147
|
|
|
124
148
|
## Buildless contract (defineReactive)
|
|
125
149
|
|
|
126
|
-
`defineReactive(Class, spec)` is for consumers without a
|
|
150
|
+
`defineReactive(Class, spec)` is for consumers without a standard-decorators
|
|
151
|
+
(2023-11) transpiler. The
|
|
127
152
|
spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
|
|
128
153
|
|
|
129
154
|
- `signals`: an array of keys (each initial `undefined`) OR a map
|
|
@@ -133,6 +158,12 @@ spec normalizes (fail closed; `Reflect.ownKeys`, so symbol keys work):
|
|
|
133
158
|
throw (ambiguous -- wrap it in `{ initial: fn }` or `{ init }`).
|
|
134
159
|
- `deriveds`: a map `{ key: (self) => value | { get, equals } }`.
|
|
135
160
|
- `effects`: a MAP ONLY `{ key: (self) => void | { run, scheduler } }`.
|
|
161
|
+
- `locals`: a MAP ONLY `{ key: { source, equals?, initial? } }` -- the buildless
|
|
162
|
+
twin of `@localTo` (PD-57). `source` is REQUIRED and must be a `(self) => value`
|
|
163
|
+
function (a missing or non-function source is a named throw, fail closed);
|
|
164
|
+
`equals` governs the upstream compare only; `initial` (verbatim, when present)
|
|
165
|
+
selects the @trackedReset flavor, its absence the @localCopy flavor. Same
|
|
166
|
+
read/write laws and ABA contract as the decorator.
|
|
136
167
|
- `host`: `{ registry }` or omitted -- the same validation as `@reactiveHost`.
|
|
137
168
|
|
|
138
169
|
Spec-declared members that collide with an own property of `Class.prototype` are
|
|
@@ -150,7 +181,7 @@ probe on its bound registry: construct, read every `@derived` once (forcing the
|
|
|
150
181
|
lazy links), snapshot, dispose, verify the floor -- run TWICE, requiring
|
|
151
182
|
identical deltas. An inconclusive probe (a data-dependent derived read, or a
|
|
152
183
|
registry mutated mid-probe) or a floor-verify failure THROWS a named error;
|
|
153
|
-
costOf never returns a guess. `nodes` equals P+D+E+1 (the R-A law); `links` is
|
|
184
|
+
costOf never returns a guess. `nodes` equals P+L+D+E+1 (the R-A law); `links` is
|
|
154
185
|
the first-full-read count. The result is frozen and cached per class. costOf
|
|
155
186
|
constructs the probe instance with NO constructor arguments.
|
|
156
187
|
|
|
@@ -211,7 +242,10 @@ runtime requirement -- the shipped surface runs on 1.5.0.
|
|
|
211
242
|
`reinitReactive` (an additive MINOR under that promise) -> 18 exports: the 13
|
|
212
243
|
runtime exports plus `costOf`, `capacityFor`, `enableLabels`, `labelOf`, and
|
|
213
244
|
`auditReactive` (all cold / opt-in; the hot accessor canon is byte-identical to
|
|
214
|
-
0.3.0).
|
|
245
|
+
0.3.0). 1.2.0 adds `localTo` (decisions/0013 strategic track, 0014 contract) as
|
|
246
|
+
an additive MINOR -> 19 exports; the 1.0.0 canon (`makeGet`/`makeSet`/
|
|
247
|
+
`makeDerivedGet`) stays byte-identical -- @localTo ships its own accessor bodies
|
|
248
|
+
and pays its own measured cost. The semver promise from here: any change to an existing export's
|
|
215
249
|
signature or behavior is a MAJOR, recorded in a decision file; new exports are
|
|
216
250
|
minors; the hot accessor canon (`makeGet`/`makeSet`) does not move without a
|
|
217
251
|
major. Also present since
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zakkster/lite-signal-decorators",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.2.0",
|
|
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
|
-
"
|
|
42
|
+
"standard-decorators",
|
|
43
43
|
"computed",
|
|
44
44
|
"derived",
|
|
45
45
|
"class",
|