@zakkster/lite-signal-decorators 1.1.1 → 1.3.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 +187 -0
- package/README.md +57 -21
- package/SignalDecorators.d.ts +112 -2
- package/SignalDecorators.js +432 -17
- package/llms.txt +63 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,193 @@ 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.3.0] - 2026-08-30
|
|
8
|
+
|
|
9
|
+
The introspection/migration rung of the decisions/0013 strategic-admission
|
|
10
|
+
ladder, and the release that proves criterion (c): `snapshotOf` ships as the
|
|
11
|
+
MobX-`toJS`-parity export nobody could reach cleanly by composition, and it is
|
|
12
|
+
the REAL, NAMED in-package consumer that finally admits `forEachReactive` under
|
|
13
|
+
the ORIGINAL 0009 bar (a new export needs a named consumer, and a recipe is not
|
|
14
|
+
one). decisions/0009 recorded both as candidates 2 and 3 with the absence
|
|
15
|
+
stated plainly; this release admits them together -- `forEachReactive` because
|
|
16
|
+
`snapshotOf` is built ON it, `snapshotOf` because it is the MobX-`toJS` parity a
|
|
17
|
+
recipe cannot honestly serve. The recorded absence in 0009 is preserved as
|
|
18
|
+
pre-admission history, not rewritten. Surface 19 -> 21.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **`forEachReactive(vm, fn, arg) -> count`** (20th export) -- a cold walk over
|
|
23
|
+
every value-bearing member. Calls `fn(key, box, kind, arg)` once per member
|
|
24
|
+
and returns the visit count. `kind` is `"signal" | "local" | "derived"`;
|
|
25
|
+
`@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing -- `boxOf`
|
|
26
|
+
refuses them). Order is PLAN order: signals, then locals, then deriveds, each
|
|
27
|
+
declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is
|
|
28
|
+
stable across reinit). Four scalar args, no descriptor object, and the `arg`
|
|
29
|
+
pass-through kills the caller's closure -- a gated zero-alloc walk. Symbol
|
|
30
|
+
keys are visited. Fails closed on non-reactive/unwired/parked/disposed with the
|
|
31
|
+
same named errors as `rootOf`.
|
|
32
|
+
- **`snapshotOf(vm) -> object`** (21st export) -- a shallow plain-object copy of
|
|
33
|
+
every value-bearing member, keyed by member key, built ON `forEachReactive`.
|
|
34
|
+
Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo`
|
|
35
|
+
compare-on-read resets honestly and a `@derived` computes on read (PD-62). The
|
|
36
|
+
whole walk runs under ONE `untrack` when the caller is tracking, so a
|
|
37
|
+
`snapshotOf` inside an effect subscribes to nothing (PD-63). SHALLOW by design
|
|
38
|
+
(PD-64): a nested VM is copied by reference, recursion deferred to a named
|
|
39
|
+
consumer. Symbol keys included (`Reflect.ownKeys` law). Fails closed on
|
|
40
|
+
parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and
|
|
41
|
+
non-reactive values. It ALLOCATES the returned object by design -- reported,
|
|
42
|
+
never gated; the walk under it stays zero-alloc.
|
|
43
|
+
- **`test/18-introspection.test.mjs`** (22 cases) -- both emit lanes + buildless:
|
|
44
|
+
plan-order walk, symbol keys, kind tags, effect/batched exclusion, count
|
|
45
|
+
return + `arg` pass-through, the untracked-read law (a `snapshotOf` inside an
|
|
46
|
+
effect fires ONCE, then never as every member is written), r7
|
|
47
|
+
`{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the
|
|
48
|
+
fail-closed non-reactive/unwired/parked/disposed matrix.
|
|
49
|
+
- **The `introspection-torture` lane** (scenario 18 of 18) with its own
|
|
50
|
+
`TORTURE_BREAK` sabotage control: 1e6 hoisted-callback `forEachReactive` walks
|
|
51
|
+
at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles
|
|
52
|
+
whose bytes/op are REPORTED in the summary line, never gated -- the snapshot
|
|
53
|
+
allocates by design and the harness says so out loud.
|
|
54
|
+
|
|
55
|
+
### Changed
|
|
56
|
+
|
|
57
|
+
- **The export surface: 19 -> 21** -- an additive MINOR under the 1.0.0 semver
|
|
58
|
+
promise (new exports are minors). The 1.0.0 hot canon
|
|
59
|
+
(`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: both new exports
|
|
60
|
+
are cold and neither moves an accessor byte.
|
|
61
|
+
- Three-place version sync to 1.3.0 (`package.json`, the `VERSION` const,
|
|
62
|
+
`llms.txt`); the `test/15` surface-freeze recount 19 -> 21.
|
|
63
|
+
- `throwNoBox` message widened to name `@localTo`: `boxOf` serves `@reactive`,
|
|
64
|
+
`@localTo`, and `@derived` members only (locals pass `boxOf`; the message had
|
|
65
|
+
listed only `@reactive`/`@derived`).
|
|
66
|
+
|
|
67
|
+
### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
|
|
68
|
+
|
|
69
|
+
- 1e6 hoisted-callback `forEachReactive` walks: **0.002 B/walk** (vs the 0.000
|
|
70
|
+
B/op zero-alloc control, within a +2-byte limit), gc major **0**,
|
|
71
|
+
`maxPauseMs <= 4.0`.
|
|
72
|
+
- 1e5 `snapshotOf` cycles: **95.8 B/op** -- the returned object, reported as
|
|
73
|
+
"allocates by design", never gated.
|
|
74
|
+
- 1e5 construct -> snapshot -> dispose cycles: `tracker.size()` 0, findings 0,
|
|
75
|
+
warnings 0, `activeNodes`/`nodes` back to exact pre-loop baseline; snapshots
|
|
76
|
+
hold no box reference.
|
|
77
|
+
|
|
78
|
+
Records: decisions/0013 (strategic-admission track, criterion (c)),
|
|
79
|
+
decisions/0009 (candidates 2 + 3, now stamped ADMITTED with the pre-admission
|
|
80
|
+
absence preserved).
|
|
81
|
+
|
|
82
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
86
|
+
test OK exit 0 -- 313 pass / 0 fail
|
|
87
|
+
test:gc OK exit 0 -- 313 pass / 0 fail
|
|
88
|
+
torture OK exit 0 -- 16 passed, 2 skipped, 0 warned, 0 failed in 34.9s
|
|
89
|
+
torture:controls OK exit 0 -- 18 passed, 0 skipped, 0 warned, 0 failed in 3.7s
|
|
90
|
+
torture:peer-preview REPORTED NON-BLOCKING -- lane completed (exit 0)
|
|
91
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
92
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok; controls 8/8 fail correctly
|
|
93
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
94
|
+
----------------------------------------------------------------------
|
|
95
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## [1.2.0] - 2026-08-30
|
|
99
|
+
|
|
100
|
+
The flagship of the decisions/0013 strategic-admission track: a story-grade,
|
|
101
|
+
release-after-release ladder (S8 `@localTo` -> S9 `snapshotOf` -> S10
|
|
102
|
+
`costOfInstance` -> S11 fleet helpers), and this is its first rung. `@localTo`
|
|
103
|
+
clears strategic criterion (a) -- impossible to compose correctly on the shipped
|
|
104
|
+
surface: PD-51 measured the effect-based recipe clobbering a user write one tick
|
|
105
|
+
late, so glitch-free reset needs in-package compare-on-read machinery. The one
|
|
106
|
+
release nobody else can copy from a recipe.
|
|
107
|
+
|
|
108
|
+
### Added
|
|
109
|
+
|
|
110
|
+
- **`@localTo(source, { equals? })`** -- upstream-keyed resettable local state
|
|
111
|
+
(19th export; the 18-member surface grows by exactly one). A field FOLLOWS
|
|
112
|
+
`source(self)` until someone writes it, a write OVERRIDES, and a changed
|
|
113
|
+
upstream RESETS it -- decided by compare-on-read, so it is glitch-free,
|
|
114
|
+
synchronous, and PURE (no box write on the read path; legal inside any
|
|
115
|
+
`@derived`). `source` is a REQUIRED tracked `(self) => value` fn read inline
|
|
116
|
+
(no extra node). `equals` (default `Object.is`) governs the upstream compare
|
|
117
|
+
ONLY -- the write path never compares.
|
|
118
|
+
- **The `locals` buildless section** -- `defineReactive(Class, { ..., locals })`
|
|
119
|
+
with `locals: { key: { source, equals?, initial? } }`, the buildless twin of
|
|
120
|
+
`@localTo` (fail closed on a missing/non-fn source), full parity with the
|
|
121
|
+
decorator path on both emit lanes.
|
|
122
|
+
- **The initial-value unification rule** (decisions/0014): a declared
|
|
123
|
+
initializer means the member STARTS there and resets on the first upstream
|
|
124
|
+
move (the `@trackedReset` flavor); an OMITTED initializer means the initial is
|
|
125
|
+
the source evaluated once at wiring and the member follows upstream from the
|
|
126
|
+
first read (the `@localCopy` flavor). One decorator, both field semantics,
|
|
127
|
+
selected by the natural syntax.
|
|
128
|
+
- **The ABA contract, stated plainly** (decisions/0014, shipped + asserted): no
|
|
129
|
+
public revision counter exists (NodeDescriptor is `{id, kind, value}`), so the
|
|
130
|
+
upstream compare is VALUE-based. Upstream A -> local write X -> upstream B ->
|
|
131
|
+
upstream back to an equals-A value leaves the read showing the STALE LOCAL X --
|
|
132
|
+
the reset requires upstream to change relative to the last adoption, not to
|
|
133
|
+
have moved transitively. tracked-toolbox's `@localCopy` has the same property;
|
|
134
|
+
it is documented in README/llms.txt, pinned in torture (S8-A6), never softened.
|
|
135
|
+
- **The `localto-torture` lane** (`test/torture/localto-torture.mjs`, scenario
|
|
136
|
+
13 of 17) with its own `TORTURE_BREAK` sabotage control: the zero-alloc
|
|
137
|
+
read/write storm, the ABA-stale write/reset interleave asserted AS the
|
|
138
|
+
contract, pooled park/reinit box+seen reset, and tracking-edge + pure-compute
|
|
139
|
+
pins.
|
|
140
|
+
- **`test/17-localto.test.mjs`** (34 cases) -- the full lattice on both emit
|
|
141
|
+
lanes plus buildless `locals`: read/write/reset, both initial flavors, the ABA
|
|
142
|
+
contract, `equals` override survival, park/reinit reset, `costOf` accounting,
|
|
143
|
+
source-throw fail-closed, and the option/source rejection matrix.
|
|
144
|
+
- Emit fixtures extended: `fixture.src.ts` gains a `@localTo` member; the two
|
|
145
|
+
compiled outs + the source hash regenerate (no new fixture row or file).
|
|
146
|
+
|
|
147
|
+
### Changed
|
|
148
|
+
|
|
149
|
+
- **The per-instance cost formula is now `P + L + D + E + 1`** (was `P + D + E +
|
|
150
|
+
1`): one signal box per local plus the anchor; the plain per-instance seen-slot
|
|
151
|
+
is never a node (+0). `costOf` returns the `L` term; `capacityFor` sizes it.
|
|
152
|
+
Reflected everywhere the formula appears in README/llms.txt.
|
|
153
|
+
- The shipped `SignalDecorators.d.ts` header de-staged to emitter-named
|
|
154
|
+
phrasing (PD-49) -- a 1.1.1 zero-grep escapee (the sweep listed the `.js`
|
|
155
|
+
but not the `.d.ts`), caught by the S8 review.
|
|
156
|
+
- **The export surface: 18 -> 19** -- an additive MINOR under the 1.0.0 semver
|
|
157
|
+
promise (new exports are minors). The 1.0.0 hot canon
|
|
158
|
+
(`makeGet`/`makeSet`/`makeDerivedGet`) stays byte-identical: `@localTo` ships
|
|
159
|
+
its own accessor bodies and pays its own measured cost.
|
|
160
|
+
- Three-place version sync to 1.2.0 (`package.json`, the `VERSION` const,
|
|
161
|
+
`llms.txt`); the `test/15` surface-freeze recount 18 -> 19.
|
|
162
|
+
|
|
163
|
+
### Measured (rig: Node v26.3.1, arm64 Apple M4 Pro, lite-signal 1.5.0)
|
|
164
|
+
|
|
165
|
+
- A `@localTo` read measures **1.69x** a plain decorated read (two tracked reads
|
|
166
|
+
+ a compare, versus one box read) -- documented as-is, not softened.
|
|
167
|
+
- Read AND write storms at N and 8N: **0.000 B/op** (control-relative +2 B), gc
|
|
168
|
+
major **0**, `maxPauseMs <= 0.08`; a derived over one local holds EXACTLY 2
|
|
169
|
+
source edges (upstream + box), 0 extra nodes over 1e5 reads.
|
|
170
|
+
- 4096 park/reinit cycles: `tracker.size()` 0, findings 0, warnings 0,
|
|
171
|
+
`activeNodes` to exact baseline, zero pool growths; release frees exactly
|
|
172
|
+
`P + L + D + E + 1` nodes.
|
|
173
|
+
|
|
174
|
+
Records: decisions/0013 (strategic-admission track), decisions/0014 (the localTo
|
|
175
|
+
contract + the ratified spike numbers), `spikes/localto-contract.mjs` (Q1..Q6,
|
|
176
|
+
EXIT A green against peer 1.5.0).
|
|
177
|
+
|
|
178
|
+
### Gate output (section-10 chain, archived verbatim)
|
|
179
|
+
|
|
180
|
+
```
|
|
181
|
+
fixtures OK exit 0 -- emit fixtures regenerated
|
|
182
|
+
test OK exit 0 -- 291 pass / 0 fail
|
|
183
|
+
test:gc OK exit 0 -- 291 pass / 0 fail
|
|
184
|
+
torture OK exit 0 -- 15 passed, 2 skipped, 0 warned, 0 failed in 33.5s
|
|
185
|
+
torture:controls OK exit 0 -- 17 passed, 0 skipped, 0 warned, 0 failed in 2.8s
|
|
186
|
+
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]
|
|
187
|
+
bench:selftest OK exit 0 -- ALL PASS -- 22 passed, 0 failed
|
|
188
|
+
cookbook OK exit 0/0 -- corpus 18/18 companions ok in 2.1s; controls 8/8 controls fail correctly in 5.1s
|
|
189
|
+
pack OK exit 0 -- 7/7 files, exact 7-name set, no demo/ no Publications/
|
|
190
|
+
----------------------------------------------------------------------
|
|
191
|
+
GATE PASS -- 8 blocking steps + 1 non-blocking (peer-preview)
|
|
192
|
+
```
|
|
193
|
+
|
|
7
194
|
## [1.1.1] - 2026-08-30
|
|
8
195
|
|
|
9
196
|
A docs-accuracy patch. No runtime, fixture, or emit-matrix byte changed; the
|
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
> view-model with a measured per-property cost, one deterministic teardown, and
|
|
16
16
|
> poison-on-dispose safety -- built on @zakkster/lite-signal.
|
|
17
17
|
|
|
18
|
-
**`@reactive accessor` fields, `@derived` getters, `@reactiveEffect` methods, `@batched` actions, one `@reactiveHost` wiring site -- and `disposeReactive()` tears the whole instance down in one call, every time, with nothing left dangling. A decorated read costs ~1.0x a hand-written instance-field signal read. An instance costs exactly P + D + E + 1 pool nodes and gives all of them back on dispose. A buildless twin, `defineReactive()`, delivers the identical feature set with zero transpiler.**
|
|
18
|
+
**`@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.**
|
|
19
19
|
|
|
20
20
|
```js
|
|
21
21
|
import { reactive, derived, reactiveHost, disposeReactive } from "@zakkster/lite-signal-decorators";
|
|
@@ -100,7 +100,7 @@ class Player {
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
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)
|
|
104
104
|
p.hit(80); // shield 0, hp 70 -> status still "healthy": effect does NOT re-run
|
|
105
105
|
p.hit(20); // hp 50 -> "critical": effect runs once
|
|
106
106
|
disposeReactive(p); // effect stopped, deriveds + boxes disposed, slots poisoned
|
|
@@ -138,6 +138,7 @@ const ReactivePlayer = defineReactive(Player, {
|
|
|
138
138
|
|
|
139
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.
|
|
140
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`.
|
|
141
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).
|
|
142
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.
|
|
143
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.
|
|
@@ -145,6 +146,7 @@ const ReactivePlayer = defineReactive(Player, {
|
|
|
145
146
|
- **One deterministic teardown** -- `disposeReactive(vm)` (or a `using` block): anchor cascade, box disposal, poison swap, idempotent, allocation-free on the success path.
|
|
146
147
|
- **Fail-closed everything** -- statics, private `#` members, unknown options, duplicate keys, orphaned members, invalid registries, half-valid specs: all named throws at decoration time, with a nearest-key did-you-mean where a typo is likely.
|
|
147
148
|
- **Interop that stays raw** -- `boxOf(vm, key)` hands you the live engine box; `rootOf(vm)` hands the anchor descriptor to `forEachOwned` / lite-devtools. Decorated and hand-written signals share one graph.
|
|
149
|
+
- **Introspection & migration (1.3.0)** -- `forEachReactive(vm, fn, arg)` walks every value-bearing member in plan order (`signal`/`local`/`derived`, effects excluded) with a zero-alloc `fn(key, box, kind, arg)` callback; `snapshotOf(vm)` returns a shallow plain-object copy read through the accessors under one `untrack` -- the native `toJS` this package now ships, safe to call inside an effect.
|
|
148
150
|
|
|
149
151
|
---
|
|
150
152
|
|
|
@@ -205,7 +207,7 @@ The failure mode this package is built against is not "reactivity doesn't work"
|
|
|
205
207
|
|
|
206
208
|
### Member decorators
|
|
207
209
|
|
|
208
|
-
|
|
210
|
+
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).
|
|
209
211
|
|
|
210
212
|
| Decorator | Placement | Options | Behavior |
|
|
211
213
|
|---|---|---|---|
|
|
@@ -213,6 +215,24 @@ All four take a bare form and a factory form (`@reactive` and `@reactive({...})`
|
|
|
213
215
|
| `@derived` | `get y()` | `equals(a, b)` | Lazy computed owned by the anchor. Recomputes on dependency change; `equals` cuts propagation when the result is unchanged. |
|
|
214
216
|
| `@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. |
|
|
215
217
|
| `@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). |
|
|
218
|
+
| `@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. |
|
|
219
|
+
|
|
220
|
+
### `localTo(source, { equals? }?)`
|
|
221
|
+
|
|
222
|
+
`@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.
|
|
223
|
+
|
|
224
|
+
Two field flavors, selected by the natural syntax (the [initial-value unification rule](decisions/0014-localto-contract.md)):
|
|
225
|
+
|
|
226
|
+
```js
|
|
227
|
+
@reactiveHost
|
|
228
|
+
class Field {
|
|
229
|
+
@reactive accessor upstream = "server";
|
|
230
|
+
@localTo((self) => self.upstream) accessor draft; // no initial: FOLLOWS upstream from wiring
|
|
231
|
+
@localTo((self) => self.upstream) accessor pinned = ""; // initial: STARTS "", resets on first upstream move
|
|
232
|
+
}
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
**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).
|
|
216
236
|
|
|
217
237
|
### Class decorator
|
|
218
238
|
|
|
@@ -229,6 +249,7 @@ The buildless twin. Installs the members on `Class.prototype`, wraps the class t
|
|
|
229
249
|
|---|---|---|
|
|
230
250
|
| `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). |
|
|
231
251
|
| `deriveds` | `{ key: (self) => value \| { get, equals } }` | |
|
|
252
|
+
| `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. |
|
|
232
253
|
| `effects` | `{ key: (self) => void \| { run, scheduler } }` | Map only. |
|
|
233
254
|
| `host` | `{ registry }` or omitted | Same validation as `@reactiveHost`. |
|
|
234
255
|
|
|
@@ -248,19 +269,26 @@ Symbol keys work (`Reflect.ownKeys`). A spec key colliding with an own property
|
|
|
248
269
|
|
|
249
270
|
| Export | Signature | Behavior |
|
|
250
271
|
|---|---|---|
|
|
251
|
-
| `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. |
|
|
272
|
+
| `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. |
|
|
252
273
|
| `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). |
|
|
253
274
|
| `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. |
|
|
254
275
|
| `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. |
|
|
255
276
|
|
|
256
277
|
With labels and audit off, the zero-GC budgets are byte-identical to 0.3.0 -- the hot accessor canon is untouched by all four (review-diffed against the published 0.3.0 tarball).
|
|
257
278
|
|
|
279
|
+
### Introspection walk & snapshot (1.3.0)
|
|
280
|
+
|
|
281
|
+
| Export | Signature | Behavior |
|
|
282
|
+
|---|---|---|
|
|
283
|
+
| `forEachReactive` | `(vm, fn, arg) => count` | Cold value-member walk. Calls `fn(key, box, kind, arg)` once per value-bearing member and returns the visit count. `kind` is `"signal" \| "local" \| "derived"`; `@reactiveEffect`/`@batched` are EXCLUDED (non-value-bearing). Order is PLAN order -- signals, then locals, then deriveds, each declaration-ordered and ancestor-first (never `Reflect.ownKeys`, so it is stable across reinit). Four scalar args, zero descriptor object, and the `arg` pass-through kills the caller's closure: the walk is a gated zero-alloc body. Symbol keys are visited. Fails closed on a non-reactive, unwired, parked, or disposed value with the same named errors as `rootOf`. |
|
|
284
|
+
| `snapshotOf` | `(vm) => object` | A shallow plain-object copy of every value-bearing member, keyed by member key. Values are read through the ACCESSOR `vm[key]`, NOT `box.get`, so a `@localTo` compare-on-read resets honestly and a `@derived` computes on read (PD-62: reading the box directly would show a stale local after an untracked upstream move -- the accessor is the documented read). The whole walk runs under ONE `untrack` when the caller is tracking, so `snapshotOf` inside an effect subscribes to nothing. SHALLOW by design: a nested VM is copied by reference, not recursed. Symbol keys included. Fails closed on parked/disposed (`ReactiveDisposedError`, parked vs disposed flavor) and non-reactive values. This export ALLOCATES the returned object by design (~96 B/op measured) -- reported, never gated; the walk under it stays zero-alloc. |
|
|
285
|
+
|
|
258
286
|
### Errors & constants
|
|
259
287
|
|
|
260
288
|
| Export | Value |
|
|
261
289
|
|---|---|
|
|
262
290
|
| `ReactiveDisposedError` | `extends Error`; `name: "ReactiveDisposedError"`; fields `className`, `key`. Thrown on ANY touch of a disposed instance's surface. |
|
|
263
|
-
| `VERSION` | `"1.
|
|
291
|
+
| `VERSION` | `"1.3.0"` |
|
|
264
292
|
|
|
265
293
|
### The rejection matrix
|
|
266
294
|
|
|
@@ -349,6 +377,8 @@ The fair baseline for a decorated property is a hand-written instance field (`th
|
|
|
349
377
|
|
|
350
378
|
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.
|
|
351
379
|
|
|
380
|
+
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.
|
|
381
|
+
|
|
352
382
|
### `@batched` per call (`spikes/batched-cost.mjs`)
|
|
353
383
|
|
|
354
384
|
| Path | ns/op |
|
|
@@ -361,7 +391,7 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
361
391
|
|
|
362
392
|
### Per instance
|
|
363
393
|
|
|
364
|
-
`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.
|
|
394
|
+
`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.
|
|
365
395
|
|
|
366
396
|
<details>
|
|
367
397
|
<summary><strong>Zero-GC design notes: the allocation table + the gates</strong></summary>
|
|
@@ -373,15 +403,17 @@ The ~7 ns over raw batch is the guarded thunk + rest-array the decorator allocat
|
|
|
373
403
|
| `@derived get` read | none | lazy computed read |
|
|
374
404
|
| Effect re-run | none retained | gated: zero major GC across the read/write torture lanes |
|
|
375
405
|
| `@batched m()` call | 1 thunk + 1 rest array | the documented, measured exception (+7 ns vs raw batch); action-grade only |
|
|
376
|
-
| `new Host()` | P + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
|
|
406
|
+
| `new Host()` | P + L + D + E + 1 pool nodes | plus the instance itself; nodes recycle on dispose (F-0 conservation) |
|
|
377
407
|
| `disposeReactive(vm)` | none | allocation-free success path; poison handles are prebuilt per member at decoration time |
|
|
378
408
|
| `boxOf` / `rootOf` / any throw | cold path | introspection and failure paths may allocate; never on the hot path |
|
|
409
|
+
| `forEachReactive` walk | none | gated: 1e6 hoisted-callback walks measure **0.002 B/walk** (vs the 0.000 B/op zero-alloc control -- within a +2-byte limit), `gc.major === 0`; the 4-scalar `fn(key, box, kind, arg)` carries no descriptor object and the `arg` pass-through kills the caller's closure |
|
|
410
|
+
| `snapshotOf(vm)` | 1 plain object | **by design** -- the returned copy allocates (**95.8 B/op measured**, 1e5 cycles); REPORTED in the torture summary line, never gated. The walk *under* it stays zero-alloc; cold, off any frame path |
|
|
379
411
|
|
|
380
|
-
The gates that hold it (run on every change, all green at 1.
|
|
412
|
+
The gates that hold it (run on every change, all green at 1.3.0):
|
|
381
413
|
|
|
382
|
-
- `npm test` / `npm run test:gc` -- **
|
|
414
|
+
- `npm test` / `npm run test:gc` -- **313/313** on both lanes.
|
|
383
415
|
- Suite gate (lite-leak + lite-gc-profiler): `leak=size 0/0 findings=0 warnings=0 | gc major=0 minor=0 maxMs=0.00 | ok`.
|
|
384
|
-
- Torture: **
|
|
416
|
+
- Torture: **18 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; the `introspection-torture` 1e6 hoisted-callback `forEachReactive` walk at `maxMajor 0` with the snapshot-allocates figure reported, never gated) -- **16 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 -- **18/18 controls** prove each gate can actually fail.
|
|
385
417
|
- `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.
|
|
386
418
|
|
|
387
419
|
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.
|
|
@@ -406,18 +438,19 @@ Full rationale lives in [`decisions/`](decisions/) -- each is a numbered, dated
|
|
|
406
438
|
- **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.
|
|
407
439
|
- **Statics and `#` privates are rejected, not half-supported.** A module-level signal belongs to raw lite-signal; a private member can't be reached by the wiring protocol -- both are named decoration-time throws.
|
|
408
440
|
- **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)).
|
|
441
|
+
- **`@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)).
|
|
409
442
|
|
|
410
443
|
---
|
|
411
444
|
|
|
412
445
|
## Testing (for clients & QA)
|
|
413
446
|
|
|
414
447
|
```bash
|
|
415
|
-
npm test # node --test,
|
|
416
|
-
npm run test:gc # the same
|
|
448
|
+
npm test # node --test, 313 tests
|
|
449
|
+
npm run test:gc # the same 313 with --expose-gc (enables the allocation assertions)
|
|
417
450
|
npm run gate # the full pre-publish chain (section 10): fixtures -> test -> test:gc -> torture -> controls -> peer-preview (non-blocking) -> bench selftest -> cookbook -> pack
|
|
418
451
|
```
|
|
419
452
|
|
|
420
|
-
**
|
|
453
|
+
**313 tests** across eighteen files, all green at 1.3.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.
|
|
421
454
|
|
|
422
455
|
| File | Tests | Covers |
|
|
423
456
|
|---|---:|---|
|
|
@@ -435,8 +468,10 @@ npm run gate # the full pre-publish chain (section 10): fixtures -> test
|
|
|
435
468
|
| `12-accounting` | 11 | `costOf` node/link/shape grid (double-probe, frozen + cached, fail-closed) + `capacityFor` budget sizing |
|
|
436
469
|
| `13-labels-audit` | 10 | `enableLabels`/`labelOf` per-registry identity + `auditReactive` leak reporting, both opt-in and default-OFF |
|
|
437
470
|
| `14-qa-s4-boundary` | 21 | S4 adversarial edges: stats-less facade closure, signals-only capacity floor, label/audit boundary matrix |
|
|
438
|
-
| `15-cookbook` | 14 | [`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) drift/parity: each fenced block byte-compared against its tagged companion `#region` (both directions + both-way coverage), surface freeze (exactly
|
|
471
|
+
| `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 21 exports), citation allowlist, link law, static-cost probe |
|
|
439
472
|
| `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 |
|
|
473
|
+
| `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 |
|
|
474
|
+
| `18-introspection` | 22 | `forEachReactive`/`snapshotOf` on both emit lanes + buildless: plan-order walk (signals, locals, deriveds; ancestor-first), symbol keys, the `signal`/`local`/`derived` kind tags, effect/batched exclusion, count return + `arg` pass-through, the untracked-read law (snapshotOf inside an effect fires once), r7 `{name,hp,mp,alive}` parity, the PD-62 accessor-read reset honesty, and the fail-closed non-reactive/unwired/parked/disposed matrix |
|
|
440
475
|
|
|
441
476
|
### Emit-support matrix
|
|
442
477
|
|
|
@@ -447,14 +482,14 @@ Generated by `node test/fixtures/emit-matrix.mjs` from `test/fixtures/hashes.jso
|
|
|
447
482
|
|
|
448
483
|
| Source | Emitter | Emit lane | Compiled output | sha256 | At decoration time |
|
|
449
484
|
|---|---|---|---|---|---|
|
|
450
|
-
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `
|
|
451
|
-
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `
|
|
485
|
+
| `fixture.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/fixture.src.js` | `5d6837710b34` | accepted -- full decorator surface wired + pinned green |
|
|
486
|
+
| `fixture.src.ts` | Babel | standard 2023-11 | `babel-out/fixture.src.js` | `f7d8b3e5ed19` | accepted -- full decorator surface wired + pinned green |
|
|
452
487
|
| `static.src.ts` | TypeScript 5 | standard 2023-11 | `ts-out/static.src.js` | `d2a03e3d5f70` | rejected -- static member is a named throw at decoration time |
|
|
453
488
|
| `static.src.ts` | Babel | standard 2023-11 | `babel-out/static.src.js` | `dc936c5aa235` | rejected -- static member is a named throw at decoration time |
|
|
454
489
|
| `legacy.src.ts` | TypeScript 5 | legacy (experimental) | `ts-legacy-out/legacy.src.js` | `c1059b1d37b1` | rejected -- legacy emit -> named rejection at decoration time |
|
|
455
490
|
| `legacy.src.ts` | Babel | legacy (experimental) | `babel-legacy-out/legacy.src.js` | `1d35a02c57ce` | rejected -- legacy emit -> named rejection at decoration time |
|
|
456
491
|
|
|
457
|
-
Source hashes: `fixture.src.ts` `
|
|
492
|
+
Source hashes: `fixture.src.ts` `339c40148a70`, `static.src.ts` `81fb649965e6`, `legacy.src.ts` `30ac3dabaf7c`.
|
|
458
493
|
<!-- EMIT-MATRIX:END -->
|
|
459
494
|
|
|
460
495
|
### The torture suite (dev-side, never shipped)
|
|
@@ -462,13 +497,13 @@ Source hashes: `fixture.src.ts` `fb492a396340`, `static.src.ts` `81fb649965e6`,
|
|
|
462
497
|
Process-isolated stress scenarios built on `@zakkster/lite-leak` + `@zakkster/lite-gc-profiler`:
|
|
463
498
|
|
|
464
499
|
```bash
|
|
465
|
-
npm run torture # all
|
|
500
|
+
npm run torture # all 18 scenarios (16 run + 2 floor-gated skips)
|
|
466
501
|
npm run torture:semantic # the correctness lane (CI)
|
|
467
502
|
npm run torture:soak # the wall-clock churn + fleet soaks
|
|
468
503
|
npm run torture:controls # sabotage self-test: every scenario must FAIL when broken
|
|
469
504
|
```
|
|
470
505
|
|
|
471
|
-
|
|
506
|
+
Eighteen 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 `introspection-torture` gate (1e6 hoisted-callback `forEachReactive` walks at `maxMajor 0` with control-relative minors, plus 1e5 `snapshotOf` cycles whose bytes/op are REPORTED in the summary line, never gated -- the snapshot allocates by design), 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: 16 pass, 2 skip. Every scenario carries a `TORTURE_BREAK` sabotage control that must exit non-zero -- a gate that cannot fail is not a gate. Seeded lanes replay exactly via `TORTURE_SEED`.
|
|
472
507
|
|
|
473
508
|
### The cookbook lane (dev-side, never shipped)
|
|
474
509
|
|
|
@@ -523,6 +558,7 @@ The decorator vocabulary maps almost one-to-one; what changes is the lifetime st
|
|
|
523
558
|
| `@action m()` | `@batched m()` |
|
|
524
559
|
| `makeObservable(this, {...})` | `@reactiveHost` -- one wiring site, no mirror object to keep in sync |
|
|
525
560
|
| `reaction(...)` / `autorun(...)` | `@reactiveEffect m()` |
|
|
561
|
+
| `toJS(obj)` | `snapshotOf(vm)` -- a shallow plain-object copy of every member, read through the accessors under one `untrack` (safe inside an effect); nested VMs stay by reference (1.3.0) |
|
|
526
562
|
| reaction disposers only; the instance itself is never disposable | **`disposeReactive(vm)` -- one call, idempotent, node-exact, and every later touch throws by name. MobX has no equivalent; its per-instance graph ends when the collector decides.** |
|
|
527
563
|
|
|
528
564
|
### From signal-utils
|
|
@@ -546,7 +582,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
|
|
|
546
582
|
- **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.
|
|
547
583
|
- **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`).
|
|
548
584
|
- **Not a framework, renderer, or component model.** It ends at the reactive view-model; DOM binding is `lite-signal-dom`'s job.
|
|
549
|
-
- **Not a general meta-programming kit.**
|
|
585
|
+
- **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.
|
|
550
586
|
- **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.
|
|
551
587
|
- **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".
|
|
552
588
|
- **Not usable as `@` syntax without a toolchain** -- that is exactly what `defineReactive` exists for.
|
|
@@ -566,7 +602,7 @@ The cross-framework numbers behind this table are stamped in [`decisions/0006-ki
|
|
|
566
602
|
|
|
567
603
|
### The cookbook
|
|
568
604
|
|
|
569
|
-
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen
|
|
605
|
+
[`COOKBOOK.md`](https://github.com/PeshoVurtoleta/lite-signal-decorators/blob/main/COOKBOOK.md) collects eighteen composition recipes over the frozen 21-export surface -- how to build the things this package deliberately does not ship a decorator for, by composing the ones it does. Its headline is the **MobX-parity-by-composition matrix**, mapping each remaining MobX construct (`observable.array`, `observable.map`, `observable.deep`, `toJS`, `when`, `runInAction`, `observe`/`intercept`) to a decorator, a suite member, or a recipe -- extending the migration tables above to the rest of MobX with the honest note per row. It walks the **two-plane fleet** (a sim plane of arena columns written raw per frame beside a reactive plane of a handful of committed members), the reactive-collection-without-a-node-per-element pattern, and the **lite-store boundary** where document state meets class state -- stated plainly as the one path that is *not* zero-GC, and why. Every code block is byte-verified against a runnable, GC-gated companion in `cookbook/` (`npm run cookbook`), so a quoted recipe cannot drift from working code. It is delivered GitHub-only -- the installed tarball stays the lean 7-file runtime surface (decisions/0009).
|
|
570
606
|
|
|
571
607
|
---
|
|
572
608
|
|
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`. */
|
|
@@ -285,16 +342,69 @@ export function boxOf<T = unknown>(vm: object, key: PropertyKey): SignalBox<T> |
|
|
|
285
342
|
*/
|
|
286
343
|
export function rootOf(vm: object): NodeDescriptor;
|
|
287
344
|
|
|
345
|
+
// --- Reactive walk & snapshot (S9) --------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The literal kind tag {@link forEachReactive} passes for each visited member: a
|
|
349
|
+
* `@reactive` signal, a `@localTo` local, or a `@derived` computed. Effects and
|
|
350
|
+
* batched actions are non-value-bearing and never appear.
|
|
351
|
+
*/
|
|
352
|
+
export type ReactiveKind = "signal" | "local" | "derived";
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Visit every value-bearing reactive member of `vm` in PLAN order -- all signals,
|
|
356
|
+
* then all `@localTo` locals, then all deriveds; within each group declaration-
|
|
357
|
+
* ordered and ancestor-first (a subclass's own members follow its ancestors').
|
|
358
|
+
* `@reactiveEffect` and `@batched` members are EXCLUDED -- they back no box.
|
|
359
|
+
* `fn` receives the member key (symbol keys included), the live {@link SignalBox}
|
|
360
|
+
* / {@link ComputedBox} (exactly what {@link boxOf} returns), the
|
|
361
|
+
* {@link ReactiveKind} literal, and the pass-through `arg` -- which threads caller
|
|
362
|
+
* state without a closure, so the walk is zero-allocation per call and per visit.
|
|
363
|
+
* Returns the number of members visited.
|
|
364
|
+
*
|
|
365
|
+
* @throws {TypeError} if `fn` is not a function.
|
|
366
|
+
* @throws {ReactiveDisposedError} if the instance was disposed or parked.
|
|
367
|
+
* @throws if `vm` is not wired yet, or is not a reactive instance.
|
|
368
|
+
*/
|
|
369
|
+
export function forEachReactive<A = unknown>(
|
|
370
|
+
vm: object,
|
|
371
|
+
fn: (
|
|
372
|
+
key: PropertyKey,
|
|
373
|
+
box: SignalBox<unknown> | ComputedBox<unknown>,
|
|
374
|
+
kind: ReactiveKind,
|
|
375
|
+
arg: A,
|
|
376
|
+
) => void,
|
|
377
|
+
arg?: A,
|
|
378
|
+
): number;
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Return a plain object snapshot of every value-bearing reactive member of `vm`
|
|
382
|
+
* -- signals, `@localTo` locals, and deriveds -- keyed by member key (symbol keys
|
|
383
|
+
* included). Each value is read through the ACCESSOR `vm[key]`, NOT the raw box,
|
|
384
|
+
* so `@localTo` compare-on-read and derived compute stay honest. SHALLOW by
|
|
385
|
+
* design: a nested reactive VM is copied by reference, never recursed. The whole
|
|
386
|
+
* read pass runs under one untracked scope when a tracking context is active, so
|
|
387
|
+
* calling this inside an effect does NOT subscribe that effect to every member.
|
|
388
|
+
* The returned object allocates by design -- this is a cold introspection call,
|
|
389
|
+
* never a gated hot path.
|
|
390
|
+
*
|
|
391
|
+
* @throws {ReactiveDisposedError} if the instance was disposed or parked.
|
|
392
|
+
* @throws if `vm` is not wired yet, or is not a reactive instance.
|
|
393
|
+
*/
|
|
394
|
+
export function snapshotOf(vm: object): Record<PropertyKey, unknown>;
|
|
395
|
+
|
|
288
396
|
// --- Introspection & audit (S4) -----------------------------------------------
|
|
289
397
|
|
|
290
398
|
/** The measured per-instance cost of a reactive class, returned by {@link costOf}. */
|
|
291
399
|
export interface ReactiveCost {
|
|
292
|
-
/** Total nodes = `signals + deriveds + effects + 1` (the anchor). */
|
|
400
|
+
/** Total nodes = `signals + locals + deriveds + effects + 1` (the anchor). */
|
|
293
401
|
nodes: number;
|
|
294
402
|
/** Dependency links held after every `@derived` has been read once (0007). */
|
|
295
403
|
links: number;
|
|
296
404
|
/** Count of `@reactive` members. */
|
|
297
405
|
signals: number;
|
|
406
|
+
/** Count of `@localTo` members (each one box node; its seen slot is a plain field). */
|
|
407
|
+
locals: number;
|
|
298
408
|
/** Count of `@derived` members. */
|
|
299
409
|
deriveds: number;
|
|
300
410
|
/** Count of `@reactiveEffect` members. */
|