@esmj/signals 0.0.4 → 0.1.2

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 CHANGED
@@ -1,3 +1,569 @@
1
- # Signals
1
+ # @esmj/signals
2
2
 
3
- // TODO
3
+ A tiny, fine-grained reactive signals library for JavaScript. Built as a lightweight wrapper around the [TC39 Signals proposal](https://github.com/tc39/proposal-signals), providing a ready-to-use API today that aligns with the future standard.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @esmj/signals
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```javascript
14
+ import { state, computed, effect } from '@esmj/signals';
15
+
16
+ const count = state(0);
17
+ const doubled = computed(() => count.get() * 2);
18
+
19
+ effect(() => {
20
+ console.log(`Count: ${count.get()}, Doubled: ${doubled.get()}`);
21
+ });
22
+ // logs: "Count: 0, Doubled: 0"
23
+
24
+ count.set(5);
25
+ // logs: "Count: 5, Doubled: 10"
26
+ ```
27
+
28
+ ## Motivation
29
+
30
+ The [TC39 Signals proposal](https://github.com/tc39/proposal-signals) aims to bring reactive primitives to the JavaScript language. This library provides a lightweight implementation of the same concepts so you can start using signals today with minimal overhead. When the proposal lands natively, migration should be straightforward.
31
+
32
+ ## API
33
+
34
+ ### `state(value, options?)`
35
+
36
+ Creates a reactive signal (also exported as `createSignal`).
37
+
38
+ ```javascript
39
+ import { state } from '@esmj/signals';
40
+
41
+ const name = state('Alice');
42
+
43
+ // Read the value
44
+ name.get(); // 'Alice'
45
+
46
+ // Write a new value
47
+ name.set('Bob');
48
+ name.get(); // 'Bob'
49
+ ```
50
+
51
+ #### Options
52
+
53
+ | Option | Type | Default | Description |
54
+ |--------|------|---------|-------------|
55
+ | `equals` | `(a, b) => boolean` | `Object.is` | Custom equality function. Notifications are skipped when `equals` returns `true`. |
56
+
57
+ ```javascript
58
+ // Signal that always notifies on set, even with the same value
59
+ const counter = state(0, { equals: () => false });
60
+
61
+ // Signal with deep equality (e.g. using a library)
62
+ const data = state({ a: 1 }, { equals: deepEqual });
63
+ ```
64
+
65
+ ### `computed(callback, options?)`
66
+
67
+ Creates a lazy, memoized derived signal. The callback is not executed until `.get()` is first called. Recomputation only occurs when a dependency changes.
68
+
69
+ ```javascript
70
+ import { state, computed } from '@esmj/signals';
71
+
72
+ const firstName = state('John');
73
+ const lastName = state('Doe');
74
+
75
+ const fullName = computed(() => `${firstName.get()} ${lastName.get()}`);
76
+
77
+ fullName.get(); // 'John Doe'
78
+
79
+ firstName.set('Jane');
80
+ fullName.get(); // 'Jane Doe'
81
+ ```
82
+
83
+ #### Chained computeds
84
+
85
+ Computed signals can depend on other computed signals:
86
+
87
+ ```javascript
88
+ const a = state(1);
89
+ const b = computed(() => a.get() * 2);
90
+ const c = computed(() => b.get() + 10);
91
+
92
+ c.get(); // 12
93
+
94
+ a.set(5);
95
+ c.get(); // 20
96
+ ```
97
+
98
+ #### Options
99
+
100
+ Same as `state` options (`equals`).
101
+
102
+ ### `effect(callback, options?)`
103
+
104
+ Creates a side effect that automatically re-runs whenever its dependencies change. Returns a dispose function to stop the effect.
105
+
106
+ ```javascript
107
+ import { state, effect } from '@esmj/signals';
108
+
109
+ const count = state(0);
110
+
111
+ const dispose = effect(() => {
112
+ console.log('Count is:', count.get());
113
+ });
114
+ // logs: "Count is: 0"
115
+
116
+ count.set(1);
117
+ // logs: "Count is: 1"
118
+
119
+ // Stop the effect
120
+ dispose();
121
+ count.set(2);
122
+ // (nothing logged)
123
+ ```
124
+
125
+ #### Explicit Resource Management (`using`)
126
+
127
+ The dispose function supports [`Symbol.dispose`](https://github.com/tc39/proposal-explicit-resource-management), enabling automatic cleanup with the `using` keyword:
128
+
129
+ ```javascript
130
+ {
131
+ using dispose = effect(() => {
132
+ console.log('Count is:', count.get());
133
+ });
134
+
135
+ count.set(1);
136
+ // effect is active
137
+ }
138
+ // ← effect automatically disposed when block exits
139
+ ```
140
+
141
+ #### Cleanup / Destructor
142
+
143
+ If the effect callback returns a function, it will be called before each re-execution and on disposal:
144
+
145
+ ```javascript
146
+ const visible = state(true);
147
+
148
+ const dispose = effect(() => {
149
+ if (visible.get()) {
150
+ const handler = () => console.log('clicked');
151
+ document.addEventListener('click', handler);
152
+
153
+ // Cleanup: runs before next effect execution or on dispose
154
+ return () => {
155
+ document.removeEventListener('click', handler);
156
+ };
157
+ }
158
+ });
159
+
160
+ visible.set(false); // cleanup runs, listener removed
161
+ dispose();
162
+ ```
163
+
164
+ ### `batch(callback)`
165
+
166
+ Batches multiple signal updates into a single notification. Computed signals and effects are only notified once after the batch completes, preventing intermediate (glitchy) states.
167
+
168
+ ```javascript
169
+ import { state, computed, batch } from '@esmj/signals';
170
+
171
+ const a = state(1);
172
+ const b = state(2);
173
+ let computeCount = 0;
174
+
175
+ const sum = computed(() => {
176
+ computeCount++;
177
+ return a.get() + b.get();
178
+ });
179
+
180
+ sum.get(); // 3, computeCount === 1
181
+
182
+ batch(() => {
183
+ a.set(10);
184
+ b.set(20);
185
+ // No recomputation happens here
186
+ });
187
+
188
+ sum.get(); // 30, computeCount === 2 (only one recomputation!)
189
+ ```
190
+
191
+ #### Nested batches
192
+
193
+ Inner batches do not flush until the outermost batch completes:
194
+
195
+ ```javascript
196
+ batch(() => {
197
+ a.set(10);
198
+ batch(() => {
199
+ b.set(20);
200
+ c.set(30);
201
+ });
202
+ // Still batched — nothing flushed yet
203
+ });
204
+ // Now all three updates are flushed at once
205
+ ```
206
+
207
+ ### Efficient Updates (Pull-based Validation)
208
+
209
+ The library uses pull-based validation with revision tracking to avoid redundant recomputations in diamond dependency graphs:
210
+
211
+ ```
212
+ state A
213
+ / \
214
+ computed B computed C
215
+ \ /
216
+ computed D
217
+ ```
218
+
219
+ When `A` changes, both `B` and `C` are marked dirty, which also marks `D` dirty. However, when `D.get()` is called, it first validates its sources by pulling their current values. Each source is validated recursively before `D` decides whether to recompute. This means `D` recomputes **exactly once**, not twice.
220
+
221
+ ```javascript
222
+ import { state, computed } from '@esmj/signals';
223
+
224
+ const a = state(1);
225
+ const b = computed(() => a.get() * 2);
226
+ const c = computed(() => a.get() * 3);
227
+ const d = computed(() => b.get() + c.get());
228
+
229
+ d.get(); // 5
230
+
231
+ a.set(2);
232
+ d.get(); // 10 — d recomputed only once, not twice
233
+ ```
234
+
235
+ #### Revision tracking
236
+
237
+ Every signal tracks a revision number that increments on each value change. This allows downstream computed signals to detect whether a source actually changed or if the dirty flag was a false alarm.
238
+
239
+ ```javascript
240
+ const s = state(1);
241
+ s.getRevision(); // 0
242
+
243
+ s.set(2);
244
+ s.getRevision(); // 1
245
+
246
+ // Same value — revision does not increment
247
+ s.set(2);
248
+ s.getRevision(); // 1
249
+ ```
250
+
251
+ ### `untrack(callback)`
252
+
253
+ Executes a callback without tracking any signal dependencies. Useful inside effects or computed signals when you want to read a signal without subscribing to it.
254
+
255
+ ```javascript
256
+ import { state, computed, untrack } from '@esmj/signals';
257
+
258
+ const a = state(1);
259
+ const b = state(2);
260
+
261
+ const result = computed(() => {
262
+ // `a` is tracked — changes to `a` will recompute
263
+ const aVal = a.get();
264
+
265
+ // `b` is NOT tracked — changes to `b` will NOT recompute
266
+ const bVal = untrack(() => b.get());
267
+
268
+ return aVal + bVal;
269
+ });
270
+
271
+ result.get(); // 3
272
+
273
+ b.set(100);
274
+ result.get(); // 3 (not recomputed because b is untracked)
275
+
276
+ a.set(10);
277
+ result.get(); // 110 (recomputed, picks up current b value)
278
+ ```
279
+
280
+ ### `signal.peek()`
281
+
282
+ Reads the current value of a signal without subscribing to it. Available on both `state` and `computed` signals. A concise alternative to `untrack(() => signal.get())`.
283
+
284
+ ```javascript
285
+ import { state, computed } from '@esmj/signals';
286
+
287
+ const count = state(5);
288
+ count.peek(); // 5 — no tracking
289
+
290
+ const doubled = computed(() => count.get() * 2);
291
+ doubled.peek(); // 10 — no tracking
292
+
293
+ // Useful inside computed/effects to read without creating a dependency
294
+ const a = state(1);
295
+ const b = state(2);
296
+
297
+ const result = computed(() => {
298
+ // a is tracked, b is not
299
+ return a.get() + b.peek();
300
+ });
301
+
302
+ result.get(); // 3
303
+
304
+ b.set(100);
305
+ result.get(); // 3 (b is not tracked)
306
+
307
+ a.set(10);
308
+ result.get(); // 110 (recomputed, picks up current b)
309
+ ```
310
+
311
+ ### `watch(signal)` / `unwatch(signal)` / `getPending()`
312
+
313
+ Low-level API for building custom scheduling. Used internally to manage effect execution.
314
+
315
+ ```javascript
316
+ import { computed, watch, unwatch, getPending } from '@esmj/signals';
317
+
318
+ const c = computed(() => /* ... */);
319
+
320
+ // Register a signal with the global watcher
321
+ watch(c);
322
+
323
+ // Get all signals with pending updates
324
+ const pending = getPending();
325
+ pending.forEach((p) => p.get());
326
+
327
+ // Unregister a signal
328
+ unwatch(c);
329
+ ```
330
+
331
+ ### `createWatcher(notify)`
332
+
333
+ Creates a custom watcher with a custom notification strategy. Replaces the default watcher (which uses `queueMicrotask`).
334
+
335
+ ```javascript
336
+ import { createWatcher, getPending } from '@esmj/signals';
337
+
338
+ // Synchronous flush strategy
339
+ createWatcher(() => {
340
+ for (const pending of getPending()) {
341
+ pending.get();
342
+ }
343
+ });
344
+
345
+ // Or requestAnimationFrame-based strategy for UI
346
+ createWatcher(() => {
347
+ requestAnimationFrame(() => {
348
+ for (const pending of getPending()) {
349
+ pending.get();
350
+ }
351
+ });
352
+ });
353
+ ```
354
+
355
+ ### `onFlush(callback)`
356
+
357
+ Registers a one-shot callback that runs **once** after the next flush cycle completes (i.e. after all pending effects have run). Useful for DOM measurements, post-update coordination, or any work that depends on effects being settled.
358
+
359
+ ```javascript
360
+ import { state, effect, onFlush } from '@esmj/signals';
361
+
362
+ const count = state(0);
363
+
364
+ effect(() => {
365
+ document.title = `Count: ${count.get()}`;
366
+ });
367
+
368
+ count.set(42);
369
+
370
+ onFlush(() => {
371
+ // DOM is now updated — safe to measure
372
+ console.log(document.title); // "Count: 42"
373
+ });
374
+ ```
375
+
376
+ Multiple callbacks are supported and run in registration order:
377
+
378
+ ```javascript
379
+ onFlush(() => console.log('first'));
380
+ onFlush(() => console.log('second'));
381
+ // After flush: "first", "second"
382
+ ```
383
+
384
+ Callbacks are one-shot — they do not persist across flush cycles:
385
+
386
+ ```javascript
387
+ onFlush(() => console.log('once'));
388
+
389
+ count.set(1);
390
+ // after flush: logs "once"
391
+
392
+ count.set(2);
393
+ // after flush: (nothing — callback was cleared)
394
+ ```
395
+
396
+ ### `afterFlush()`
397
+
398
+ Returns a promise that resolves after the next flush cycle completes. A convenience wrapper around `onFlush`. Especially useful in async code and tests:
399
+
400
+ ```javascript
401
+ import { state, effect, afterFlush } from '@esmj/signals';
402
+
403
+ const count = state(0);
404
+
405
+ effect(() => {
406
+ console.log(count.get());
407
+ });
408
+
409
+ count.set(42);
410
+
411
+ await afterFlush();
412
+ // All effects have run, all side effects settled
413
+ ```
414
+
415
+ Works seamlessly with `batch`:
416
+
417
+ ```javascript
418
+ import { state, effect, batch, afterFlush } from '@esmj/signals';
419
+
420
+ const a = state(1);
421
+ const b = state(2);
422
+ let sum = null;
423
+
424
+ effect(() => {
425
+ sum = a.get() + b.get();
426
+ });
427
+
428
+ batch(() => {
429
+ a.set(10);
430
+ b.set(20);
431
+ });
432
+
433
+ await afterFlush();
434
+ console.log(sum); // 30
435
+ ```
436
+
437
+ ## Flush Strategy
438
+
439
+ Effects are scheduled to run via `queueMicrotask` after signal updates. This means they run **before the next paint** but **after the current synchronous code finishes**:
440
+
441
+ ```javascript
442
+ const count = state(0);
443
+ let logged = null;
444
+
445
+ effect(() => {
446
+ logged = count.get();
447
+ });
448
+ // logged === 0
449
+
450
+ count.set(1);
451
+ // logged === 0 (microtask hasn't run yet)
452
+
453
+ await afterFlush();
454
+ // logged === 1 (microtask ran)
455
+ ```
456
+
457
+ Multiple `set()` calls are coalesced — the effect runs only once:
458
+
459
+ ```javascript
460
+ count.set(1);
461
+ count.set(2);
462
+ count.set(3);
463
+ await afterFlush();
464
+ // effect ran once with count === 3
465
+ ```
466
+
467
+ ## Error Handling
468
+
469
+ Errors in `computed` callbacks are captured and re-thrown on `.get()` or `.peek()`. The error state is tracked separately from the value, so **state signals can hold `Error` objects as legitimate values**:
470
+
471
+ ```javascript
472
+ import { state, computed } from '@esmj/signals';
473
+
474
+ // State signals can store Error objects — they are values, not errors
475
+ const validationError = state(new Error('field required'));
476
+ validationError.get(); // Error { message: 'field required' } — returned, not thrown
477
+
478
+ // Computed signals throw when their callback throws
479
+ const a = state(0);
480
+ const safe = computed(() => {
481
+ if (a.get() === 0) {
482
+ throw new Error('Cannot be zero');
483
+ }
484
+ return 100 / a.get();
485
+ });
486
+
487
+ try {
488
+ safe.get();
489
+ } catch (e) {
490
+ console.log(e.message); // 'Cannot be zero'
491
+ }
492
+
493
+ // Recovers when dependency changes
494
+ a.set(5);
495
+ safe.get(); // 20
496
+ ```
497
+
498
+ Errors propagate through computed chains:
499
+
500
+ ```javascript
501
+ const source = state(0);
502
+ const a = computed(() => {
503
+ if (source.get() === 0) throw new Error('bad');
504
+ return source.get() * 2;
505
+ });
506
+ const b = computed(() => a.get() + 10);
507
+
508
+ try {
509
+ b.get(); // throws 'bad' — propagated from a
510
+ } catch (e) {}
511
+
512
+ source.set(5);
513
+ b.get(); // 20 — recovered
514
+ ```
515
+
516
+ ### Cycle Detection
517
+
518
+ Circular dependencies between computed signals are detected and throw a clear error instead of causing a stack overflow:
519
+
520
+ ```javascript
521
+ import { computed } from '@esmj/signals';
522
+
523
+ const a = computed(() => b.get() + 1);
524
+ const b = computed(() => a.get() + 1);
525
+
526
+ try {
527
+ a.get();
528
+ } catch (e) {
529
+ console.log(e.message); // 'Cycle detected in computed signal'
530
+ }
531
+ ```
532
+
533
+ This applies to any cycle length — self-referencing, two-node, three-node, etc. Diamond dependencies (where multiple paths lead to the same signal without a cycle) are handled correctly and do not trigger false positives.
534
+
535
+ ## TC39 Signals Proposal Alignment
536
+
537
+ This library follows the API shape and semantics of the [TC39 Signals proposal](https://github.com/tc39/proposal-signals):
538
+
539
+ | TC39 Proposal | @esmj/signals | Status |
540
+ |---------------|---------------|--------|
541
+ | `Signal.State` | `state` / `createSignal` | ✅ |
542
+ | `Signal.Computed` | `computed` | ✅ |
543
+ | `Signal.subtle.Watcher` | `createWatcher` / `watch` / `unwatch` | ✅ |
544
+ | `Signal.subtle.untrack` | `untrack` | ✅ |
545
+ | `Signal.subtle.Watcher.prototype.getPending` | `getPending` | ✅ |
546
+ | Effect (userland in proposal) | `effect` | ✅ |
547
+ | Batch (userland in proposal) | `batch` | ✅ |
548
+
549
+ ## Exports
550
+
551
+ | Export | Description |
552
+ |--------|-------------|
553
+ | `state` | Create a reactive signal (alias: `createSignal`) |
554
+ | `createSignal` | Create a reactive signal |
555
+ | `computed` | Create a derived/memoized signal |
556
+ | `effect` | Create a reactive side effect |
557
+ | `batch` | Batch multiple updates |
558
+ | `untrack` | Read signals without tracking |
559
+ | `signal.peek()` | Read signal value without tracking |
560
+ | `watch` | Register a signal with the watcher |
561
+ | `unwatch` | Unregister a signal from the watcher |
562
+ | `getPending` | Get pending signals |
563
+ | `createWatcher` | Create a custom watcher |
564
+ | `onFlush` | Register a one-shot post-flush callback |
565
+ | `afterFlush` | Returns a promise that resolves after flush |
566
+
567
+ ## License
568
+
569
+ [MIT](./LICENSE)