@xaendar/signals 0.4.4 → 0.4.6

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/README.md ADDED
@@ -0,0 +1,239 @@
1
+ # @xaendar/signals
2
+
3
+ A complete implementation of the [TC39 Signals proposal](https://github.com/tc39/proposal-signals) — reactive primitives (`State`, `Computed`, `Watcher`) plus a high-level `effect` helper, exposed as the `Signal` global namespace.
4
+
5
+ [![npm version](https://img.shields.io/npm/v/@xaendar/signals)](https://www.npmjs.com/package/@xaendar/signals)
6
+ [![license](https://img.shields.io/npm/l/@xaendar/signals)](https://opensource.org/licenses/MIT)
7
+
8
+ ---
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @xaendar/signals
14
+ ```
15
+
16
+ ---
17
+
18
+ ## Overview
19
+
20
+ | Primitive | Description |
21
+ |-----------|-------------|
22
+ | `Signal.State` | Mutable reactive value — the source of truth |
23
+ | `Signal.Computed` | Lazy derived value — recomputed only when stale and read |
24
+ | `Signal.subtle.Watcher` | Low-level push observer — notified synchronously on change |
25
+ | `effect(fn)` | High-level helper — re-runs `fn` on every dependency change |
26
+ | `loadSignals()` | Bootstraps the `Signal` global — call once at application startup |
27
+
28
+ > **Granular updates** — signals form a fine-grained reactive graph. When a `State` value changes, **only the `Computed` nodes and `Watcher`s that transitively depend on that exact signal** are marked stale or notified. Every other node in the graph is left completely untouched, making updates **O(changed signals)** rather than O(application size).
29
+
30
+ ---
31
+
32
+ ## Initialization
33
+
34
+ Call `loadSignals()` **once** before using any signal primitive. It installs the `Signal` namespace on `globalThis`.
35
+
36
+ ```ts
37
+ import { loadSignals } from '@xaendar/signals';
38
+
39
+ loadSignals(); // production
40
+ loadSignals({ devMode: true }); // enables additional runtime checks
41
+ ```
42
+
43
+ ---
44
+
45
+ ## `Signal.State`
46
+
47
+ The fundamental mutable reactive value.
48
+
49
+ ```ts
50
+ const count = new Signal.State(0);
51
+
52
+ count.get(); // 0 — registers as a dependency if inside a Computed
53
+ count.set(1); // propagates change to all dependents
54
+ count.set(1); // no-op — Object.is(1, 1) === true, no propagation
55
+ ```
56
+
57
+ ### Options
58
+
59
+ ```ts
60
+ const price = new Signal.State(9.99, {
61
+ // Custom equality — prevent propagation when change is negligible
62
+ equals(oldVal, newVal) {
63
+ return Math.abs(oldVal - newVal) < 0.001;
64
+ },
65
+ // Called when the first Watcher/Computed subscribes to this signal
66
+ watched() {
67
+ console.log('price is now observed');
68
+ },
69
+ // Called when the last subscriber unsubscribes
70
+ unwatched() {
71
+ console.log('price is no longer observed');
72
+ },
73
+ });
74
+ ```
75
+
76
+ ---
77
+
78
+ ## `Signal.Computed`
79
+
80
+ A **lazy**, **cached** derived value. The callback is executed only when:
81
+ 1. The computed value is explicitly read via `.get()`, **and**
82
+ 2. At least one of its dependencies has changed since the last evaluation.
83
+
84
+ Between reads, the cached result is reused with zero cost.
85
+
86
+ ```ts
87
+ const firstName = new Signal.State('Ada');
88
+ const lastName = new Signal.State('Lovelace');
89
+
90
+ const fullName = new Signal.Computed(() =>
91
+ `${firstName.get()} ${lastName.get()}`
92
+ );
93
+
94
+ fullName.get(); // 'Ada Lovelace' — computed and cached
95
+
96
+ lastName.set('Byron');
97
+ fullName.get(); // 'Ada Byron' — recomputed (lastName changed)
98
+ fullName.get(); // 'Ada Byron' — served from cache
99
+ ```
100
+
101
+ Dependencies are tracked **dynamically**: if a branch is not entered during an evaluation, signals inside that branch are not tracked.
102
+
103
+ ```ts
104
+ const showTitle = new Signal.State(false);
105
+ const title = new Signal.State('Dr.');
106
+
107
+ const label = new Signal.Computed(() =>
108
+ showTitle.get() ? `${title.get()} ${firstName.get()}` : firstName.get()
109
+ );
110
+ // While showTitle is false, title is NOT a dependency of label.
111
+ ```
112
+
113
+ ---
114
+
115
+ ## `effect(fn)`
116
+
117
+ Runs a side-effectful function and **automatically re-runs it** whenever any signal read inside it changes. Re-execution is scheduled as a **microtask**, so multiple synchronous signal writes are batched into a single re-run.
118
+
119
+ Returns a disposer that permanently stops the effect and releases all subscriptions.
120
+
121
+ ```ts
122
+ import { effect } from '@xaendar/signals';
123
+
124
+ const count = new Signal.State(0);
125
+
126
+ const stop = effect(() => {
127
+ console.log('count is', count.get());
128
+ });
129
+ // → logs: "count is 0" (runs synchronously on creation)
130
+
131
+ count.set(1); // → microtask logs: "count is 1"
132
+ count.set(2); // → microtask logs: "count is 2"
133
+
134
+ stop(); // disposer — unsubscribes everything
135
+ count.set(3); // → silent
136
+ ```
137
+
138
+ ---
139
+
140
+ ## `Signal.subtle.Watcher`
141
+
142
+ The low-level primitive used by frameworks to implement scheduling. The `notify` callback fires **synchronously** the first time a watched dependency changes after each `watch()` call.
143
+
144
+ ```ts
145
+ const sig = new Signal.State(0);
146
+
147
+ const watcher = new Signal.subtle.Watcher(() => {
148
+ // Called synchronously when sig (or any watched computed) changes.
149
+ // No signal reads or writes are allowed here.
150
+ console.log('something changed — schedule a re-read');
151
+ queueMicrotask(() => {
152
+ watcher.getPending().forEach(s => s.get()); // pull new value
153
+ watcher.watch(); // re-arm
154
+ });
155
+ });
156
+
157
+ const derived = new Signal.Computed(() => sig.get() * 2);
158
+ watcher.watch(derived);
159
+ derived.get(); // initial evaluation
160
+
161
+ sig.set(5); // → "something changed — schedule a re-read"
162
+ ```
163
+
164
+ ---
165
+
166
+ ## `Signal.subtle` utilities
167
+
168
+ | Function | Description |
169
+ |----------|-------------|
170
+ | `untrack(fn)` | Executes `fn` without registering any dependency |
171
+ | `currentComputed()` | Returns the `Computed` currently being evaluated, or `null` |
172
+ | `introspectSources(node)` | Lists the signals a `Computed` or `Watcher` depends on |
173
+ | `introspectSinks(node)` | Lists the dependents of a `State` or `Computed` |
174
+ | `hasSources(node)` | `true` if a `Computed` or `Watcher` has at least one source |
175
+ | `hasSinks(node)` | `true` if a `State` or `Computed` has at least one sink |
176
+
177
+ ```ts
178
+ const a = new Signal.State(1);
179
+ const b = new Signal.Computed(() => a.get() + 1);
180
+
181
+ // Read b without tracking it as a dependency
182
+ const value = Signal.subtle.untrack(() => b.get());
183
+
184
+ Signal.subtle.introspectSources(b); // [a]
185
+ Signal.subtle.introspectSinks(a); // [b]
186
+ Signal.subtle.hasSinks(a); // false — b is not yet watched
187
+ ```
188
+
189
+ ---
190
+
191
+ ## How the reactive graph works
192
+
193
+ ```
194
+ Signal.State ──────────► Signal.Computed ──────────► Signal.subtle.Watcher
195
+ (source) (derived) (observer)
196
+ │ │ │
197
+ .set(v) lazy .get() notify() callback
198
+ │ │ │
199
+ └── marks dependents stale ──┘ schedules microtask ──────┘
200
+ ```
201
+
202
+ 1. `State.set()` marks all direct `Computed` dependents as **dirty** and all reachable `Watcher`s as **pending**, invoking their `notify` callback synchronously.
203
+ 2. A `Computed` is only re-evaluated when `.get()` is called on a stale node — **pull-based**, not push-based.
204
+ 3. `Watcher.notify` is push-based and fires synchronously; the actual value read happens separately, in a microtask or scheduler tick.
205
+ 4. Signals with no active `Watcher` are not tracked and can be garbage-collected independently.
206
+
207
+ ---
208
+
209
+ ## TypeScript
210
+
211
+ `SignalOptions` and `SignalEqual` are exported for use in custom signal subclasses.
212
+
213
+ ```ts
214
+ import type { SignalOptions, SignalEqual } from '@xaendar/signals';
215
+
216
+ const myEquals: SignalEqual<number> = (a, b) => Math.abs(a - b) < 0.01;
217
+
218
+ const opts: SignalOptions<number> = {
219
+ equals: myEquals,
220
+ watched() { /* ... */ },
221
+ unwatched() { /* ... */ },
222
+ };
223
+ ```
224
+
225
+ ---
226
+
227
+ ## Related packages
228
+
229
+ | Package | Description |
230
+ |---------|-------------|
231
+ | [`@xaendar/core`](https://www.npmjs.com/package/@xaendar/core) | Web Component base class, decorators, and `InputSignal` |
232
+ | [`@xaendar/types`](https://www.npmjs.com/package/@xaendar/types) | Shared TypeScript utility types |
233
+ | [`@xaendar/compiler`](https://www.npmjs.com/package/@xaendar/compiler) | Template compiler |
234
+
235
+ ---
236
+
237
+ ## License
238
+
239
+ MIT © [Kaitenjo](https://github.com/kaitenjo)