@xaendar/signals 0.4.5 → 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 +239 -0
- package/dist/xaendar-signals.es.js +913 -186
- package/package.json +4 -4
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
|
+
[](https://www.npmjs.com/package/@xaendar/signals)
|
|
6
|
+
[](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)
|