@cplieger/reactive 1.0.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/src/signal.ts ADDED
@@ -0,0 +1,687 @@
1
+ // Reactive signals: signal<T>, effect (with cleanup), batch, computed, untracked.
2
+ // Preact-style doubly-linked source/target edges, pull-based glitch-free refresh,
3
+ // global epoch + per-node version fast-skip, bitfield flags.
4
+ // GPL-3.0-or-later.
5
+
6
+ const SIGNAL_BRAND = Symbol("signal");
7
+ const COMPUTED_BRAND = Symbol("computed");
8
+
9
+ // --- Bitfield flags ---
10
+ const DIRTY = 1;
11
+ const CHECK = 2;
12
+ const RUNNING = 4;
13
+ const DISPOSED = 8;
14
+ const HAS_ERROR = 16;
15
+ const TRACKING = 32;
16
+ const NOTIFIED = 64;
17
+
18
+ // --- Linked-list Node for source↔target edges ---
19
+ interface DepNode {
20
+ _source: SourceNode;
21
+ _prevSource: DepNode | undefined;
22
+ _nextSource: DepNode | undefined;
23
+ _target: TargetNode;
24
+ _prevTarget: DepNode | undefined;
25
+ _nextTarget: DepNode | undefined;
26
+ _version: number;
27
+ _rollbackNode: DepNode | undefined;
28
+ }
29
+
30
+ // Anything that can be depended on (signal or computed)
31
+ interface SourceNode {
32
+ _value: unknown;
33
+ _version: number;
34
+ _node: DepNode | undefined;
35
+ _targets: DepNode | undefined;
36
+ _isComputed: boolean; // discriminator
37
+ }
38
+
39
+ // Anything that can depend on sources (computed or effect)
40
+ interface TargetNode {
41
+ _flags: number;
42
+ _sources: DepNode | undefined;
43
+ _isComputed: boolean; // discriminator: true=computed, false=effect
44
+ }
45
+
46
+ // Computed is BOTH source and target (same object)
47
+ interface ComputedNode extends SourceNode, TargetNode {
48
+ _isComputed: true;
49
+ _fn: () => unknown;
50
+ _equals: ((a: unknown, b: unknown) => boolean) | undefined;
51
+ _globalVersion: number;
52
+ }
53
+
54
+ interface EffectNode {
55
+ _fn: (() => Cleanup) | undefined;
56
+ _cleanup: Cleanup;
57
+ _flags: number;
58
+ _sources: DepNode | undefined;
59
+ _nextBatchedEffect: EffectNode | undefined;
60
+ _isComputed: false;
61
+ }
62
+
63
+ // --- Globals ---
64
+ let evalContext: TargetNode | undefined;
65
+ let batchDepth = 0;
66
+ let batchedEffect: EffectNode | undefined;
67
+ let batchIteration = 0;
68
+ let globalVersion = 0;
69
+
70
+ // --- Effect error handler ---
71
+ /** Cleanup function returned by an effect callback, called before re-execution or disposal. */
72
+ export type Cleanup = undefined | (() => void);
73
+
74
+ /** Handler invoked when an effect throws an error. */
75
+ export type EffectErrorHandler = (error: unknown) => void;
76
+ let effectErrorHandler: EffectErrorHandler = (e) => {
77
+ console.error("effect error:", e);
78
+ };
79
+
80
+ function safeCallHandler(e: unknown): void {
81
+ try {
82
+ effectErrorHandler(e);
83
+ } catch {
84
+ // swallow
85
+ }
86
+ }
87
+
88
+ /** Set a global error handler for effect errors. Returns the previous handler. */
89
+ export function setEffectErrorHandler(handler: EffectErrorHandler): EffectErrorHandler {
90
+ const prev = effectErrorHandler;
91
+ effectErrorHandler = handler;
92
+ return prev;
93
+ }
94
+
95
+ // --- Dependency tracking ---
96
+ function addDependency(source: SourceNode): DepNode | undefined {
97
+ if (evalContext === undefined) {
98
+ return undefined;
99
+ }
100
+ let node = source._node;
101
+ if (node?._target !== evalContext) {
102
+ // Create a new node
103
+ node = {
104
+ _version: 0,
105
+ _source: source,
106
+ _prevSource: undefined,
107
+ _nextSource: undefined,
108
+ _target: evalContext,
109
+ _prevTarget: undefined,
110
+ _nextTarget: undefined,
111
+ _rollbackNode: source._node, // F3: save for rollback
112
+ };
113
+
114
+ // Link into target's sources (prepend)
115
+ const evalSources = evalContext._sources;
116
+ if (evalSources !== undefined) {
117
+ evalSources._prevSource = node;
118
+ }
119
+ node._nextSource = evalSources;
120
+ evalContext._sources = node;
121
+
122
+ // Link into source's targets (prepend)
123
+ const sourceTargets = source._targets;
124
+ if (sourceTargets !== undefined) {
125
+ sourceTargets._prevTarget = node;
126
+ }
127
+ node._nextTarget = sourceTargets;
128
+ source._targets = node;
129
+
130
+ source._node = node;
131
+ return node;
132
+ } else if (node._version === -1) {
133
+ // Recycle existing node
134
+ node._version = 0;
135
+
136
+ // Move to head of sources if not already there
137
+ if (node._prevSource !== undefined) {
138
+ node._prevSource._nextSource = node._nextSource;
139
+ if (node._nextSource !== undefined) {
140
+ node._nextSource._prevSource = node._prevSource;
141
+ }
142
+ node._prevSource = undefined;
143
+ const evalSources = evalContext._sources;
144
+ if (evalSources !== undefined) {
145
+ evalSources._prevSource = node;
146
+ }
147
+ node._nextSource = evalSources;
148
+ evalContext._sources = node;
149
+ }
150
+ return node;
151
+ }
152
+ return undefined;
153
+ }
154
+
155
+ // --- Source preparation/cleanup for eval cycle ---
156
+ function prepareSources(target: TargetNode): void {
157
+ let node = target._sources;
158
+ while (node !== undefined) {
159
+ const rollback = node._source._node;
160
+ if (rollback !== undefined) {
161
+ node._rollbackNode = rollback;
162
+ }
163
+ node._source._node = node;
164
+ node._version = -1;
165
+ node = node._nextSource;
166
+ }
167
+ }
168
+
169
+ function cleanupSources(target: TargetNode): void {
170
+ let node = target._sources;
171
+ let head = target._sources;
172
+ while (node !== undefined) {
173
+ const next = node._nextSource;
174
+ if (node._version === -1) {
175
+ // Not re-used — unlink from source's targets
176
+ if (node._prevTarget !== undefined) {
177
+ node._prevTarget._nextTarget = node._nextTarget;
178
+ } else {
179
+ node._source._targets = node._nextTarget;
180
+ }
181
+ if (node._nextTarget !== undefined) {
182
+ node._nextTarget._prevTarget = node._prevTarget;
183
+ }
184
+ // Remove from target's sources
185
+ if (node._prevSource !== undefined) {
186
+ node._prevSource._nextSource = node._nextSource;
187
+ } else {
188
+ head = node._nextSource;
189
+ }
190
+ if (node._nextSource !== undefined) {
191
+ node._nextSource._prevSource = node._prevSource;
192
+ }
193
+ // Restore rollback
194
+ node._source._node = node._rollbackNode;
195
+ node._rollbackNode = undefined;
196
+ } else {
197
+ // Kept — restore rollback pointer (F3)
198
+ node._source._node = node._rollbackNode;
199
+ node._rollbackNode = undefined;
200
+ }
201
+ node = next;
202
+ }
203
+ target._sources = head;
204
+ }
205
+
206
+ // --- Notification propagation ---
207
+ function notifyTargets(source: SourceNode): void {
208
+ let node = source._targets;
209
+ while (node !== undefined) {
210
+ const target = node._target;
211
+ if (target._isComputed) {
212
+ // It's a ComputedNode
213
+ if (!(target._flags & (NOTIFIED | RUNNING))) {
214
+ target._flags |= DIRTY | NOTIFIED;
215
+ notifyTargets(target as unknown as SourceNode);
216
+ target._flags &= ~NOTIFIED;
217
+ }
218
+ } else {
219
+ // It's an EffectNode
220
+ if (!(target._flags & NOTIFIED)) {
221
+ target._flags |= NOTIFIED;
222
+ const eff = target as unknown as EffectNode;
223
+ eff._nextBatchedEffect = batchedEffect;
224
+ batchedEffect = eff;
225
+ }
226
+ }
227
+ node = node._nextTarget;
228
+ }
229
+ }
230
+
231
+ // --- Staleness check ---
232
+ function needsToRecompute(target: TargetNode): boolean {
233
+ let node = target._sources;
234
+ while (node !== undefined) {
235
+ const source = node._source;
236
+ if (source._isComputed) {
237
+ const comp = source as unknown as ComputedNode;
238
+ if (comp._flags & (DIRTY | CHECK)) {
239
+ refreshComputed(comp);
240
+ }
241
+ if (comp._flags & HAS_ERROR) {
242
+ return true;
243
+ }
244
+ }
245
+ if (source._version !== node._version) {
246
+ return true;
247
+ }
248
+ node = node._nextSource;
249
+ }
250
+ return false;
251
+ }
252
+
253
+ // --- Computed refresh (pull-based, depth-first) ---
254
+ function refreshComputed(comp: ComputedNode): void {
255
+ if (comp._flags & RUNNING) {
256
+ throw new Error("Cycle detected");
257
+ }
258
+
259
+ // Fast-path: nothing changed globally since last check
260
+ if (comp._globalVersion === globalVersion && !(comp._flags & (DIRTY | CHECK))) {
261
+ return;
262
+ }
263
+ comp._globalVersion = globalVersion;
264
+
265
+ // If not dirty but CHECK, verify sources first
266
+ if (!(comp._flags & DIRTY) && comp._flags & CHECK) {
267
+ let sn = comp._sources;
268
+ while (sn !== undefined) {
269
+ const src = sn._source;
270
+ if (src._isComputed) {
271
+ const srcComp = src as unknown as ComputedNode;
272
+ if (srcComp._flags & (DIRTY | CHECK)) {
273
+ refreshComputed(srcComp);
274
+ }
275
+ if (srcComp._flags & HAS_ERROR) {
276
+ comp._flags |= DIRTY;
277
+ break;
278
+ }
279
+ }
280
+ if (src._version !== sn._version) {
281
+ comp._flags |= DIRTY;
282
+ break;
283
+ }
284
+ sn = sn._nextSource;
285
+ }
286
+ if (!(comp._flags & DIRTY)) {
287
+ comp._flags &= ~CHECK;
288
+ return;
289
+ }
290
+ }
291
+
292
+ const prevContext = evalContext;
293
+ evalContext = comp;
294
+ const prevFlags = comp._flags;
295
+ comp._flags = RUNNING | TRACKING;
296
+
297
+ prepareSources(comp);
298
+
299
+ let newValue: unknown;
300
+ let fnThrew = false;
301
+ try {
302
+ newValue = comp._fn();
303
+ } catch (err: unknown) {
304
+ // PATH A: fn() threw — cache the error
305
+ fnThrew = true;
306
+ comp._value = err;
307
+ comp._flags = HAS_ERROR | TRACKING;
308
+ comp._version++;
309
+ // Keep old deps (version=-1 nodes) so computed retries when sources change.
310
+ let sn = comp._sources;
311
+ while (sn !== undefined) {
312
+ if (sn._version === -1) {
313
+ sn._version = 0;
314
+ }
315
+ sn = sn._nextSource;
316
+ }
317
+ } finally {
318
+ evalContext = prevContext;
319
+ cleanupSources(comp);
320
+ if (!fnThrew) {
321
+ comp._flags = (comp._flags & ~RUNNING) | TRACKING;
322
+ }
323
+ }
324
+
325
+ // PATH B: fn() succeeded — compare with equals() (F2: separate try/catch)
326
+ if (!fnThrew) {
327
+ const oldHadError = prevFlags & HAS_ERROR;
328
+ let changed: boolean;
329
+
330
+ if (oldHadError || comp._version === 0) {
331
+ changed = true;
332
+ } else {
333
+ try {
334
+ const eq = comp._equals;
335
+ changed = eq ? !eq(comp._value, newValue) : !Object.is(comp._value, newValue);
336
+ } catch {
337
+ // equals() threw — treat as changed (F2: successful value NOT poisoned)
338
+ changed = true;
339
+ }
340
+ }
341
+
342
+ if (changed) {
343
+ comp._value = newValue;
344
+ comp._flags &= ~HAS_ERROR;
345
+ comp._version++;
346
+ } else {
347
+ comp._flags &= ~HAS_ERROR;
348
+ }
349
+ }
350
+ }
351
+
352
+ // --- Batch / Effect scheduling ---
353
+ function startBatch(): void {
354
+ batchDepth++;
355
+ }
356
+
357
+ function endBatch(): void {
358
+ if (--batchDepth > 0) {
359
+ return;
360
+ }
361
+ let error: unknown;
362
+ let hasError = false;
363
+ while (batchedEffect !== undefined) {
364
+ let eff: EffectNode | undefined = batchedEffect;
365
+ batchedEffect = undefined;
366
+ batchIteration++;
367
+ if (batchIteration > 100) {
368
+ batchIteration = 0;
369
+ throw new Error("Cycle detected");
370
+ }
371
+ while (eff !== undefined) {
372
+ const next: EffectNode | undefined = eff._nextBatchedEffect;
373
+ eff._nextBatchedEffect = undefined;
374
+ eff._flags &= ~NOTIFIED;
375
+ if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
376
+ try {
377
+ runEffect(eff);
378
+ } catch (e) {
379
+ if (!hasError) {
380
+ error = e;
381
+ hasError = true;
382
+ }
383
+ }
384
+ }
385
+ eff = next;
386
+ }
387
+ }
388
+ batchIteration = 0;
389
+ if (hasError) {
390
+ throw error;
391
+ }
392
+ }
393
+
394
+ function runEffect(eff: EffectNode): void {
395
+ // Run cleanup first (untracked)
396
+ if (eff._cleanup) {
397
+ const c = eff._cleanup;
398
+ eff._cleanup = undefined;
399
+ const prevContext = evalContext;
400
+ evalContext = undefined;
401
+ try {
402
+ c();
403
+ } catch (e) {
404
+ safeCallHandler(e);
405
+ } finally {
406
+ evalContext = prevContext;
407
+ }
408
+ }
409
+
410
+ // Track dependencies
411
+ const prevContext = evalContext;
412
+ evalContext = eff;
413
+ prepareSources(eff);
414
+
415
+ try {
416
+ if (eff._fn) {
417
+ eff._cleanup = eff._fn();
418
+ }
419
+ } catch (e) {
420
+ safeCallHandler(e);
421
+ } finally {
422
+ evalContext = prevContext;
423
+ cleanupSources(eff);
424
+ }
425
+ }
426
+
427
+ function disposeEffect(eff: EffectNode): void {
428
+ // Run cleanup (untracked)
429
+ if (eff._cleanup) {
430
+ const c = eff._cleanup;
431
+ eff._cleanup = undefined;
432
+ const prevContext = evalContext;
433
+ evalContext = undefined;
434
+ try {
435
+ c();
436
+ } catch (e) {
437
+ safeCallHandler(e);
438
+ } finally {
439
+ evalContext = prevContext;
440
+ }
441
+ }
442
+
443
+ // Unsubscribe from all sources
444
+ let node = eff._sources;
445
+ while (node !== undefined) {
446
+ const next = node._nextSource;
447
+ if (node._prevTarget !== undefined) {
448
+ node._prevTarget._nextTarget = node._nextTarget;
449
+ } else {
450
+ node._source._targets = node._nextTarget;
451
+ }
452
+ if (node._nextTarget !== undefined) {
453
+ node._nextTarget._prevTarget = node._prevTarget;
454
+ }
455
+ node._source._node = node._rollbackNode;
456
+ node = next;
457
+ }
458
+ eff._sources = undefined;
459
+ // F5: null closures for GC
460
+ eff._fn = undefined;
461
+ eff._cleanup = undefined;
462
+ eff._flags |= DISPOSED;
463
+ }
464
+
465
+ // --- Public API ---
466
+
467
+ /** Options for configuring signal equality semantics. */
468
+ export interface SignalOptions<T> {
469
+ equals?: false | ((prev: T, next: T) => boolean);
470
+ }
471
+
472
+ /** A reactive signal holding a mutable value that notifies subscribers on change. */
473
+ export interface Signal<T> {
474
+ value: T;
475
+ peek(): T;
476
+ }
477
+
478
+ /** A read-only reactive signal (e.g. from computed). */
479
+ export interface ReadonlySignal<T> {
480
+ readonly value: T;
481
+ peek(): T;
482
+ }
483
+
484
+ /** Create a reactive signal with an initial value. Reads are tracked; writes notify subscribers. */
485
+ export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
486
+ const eq = options?.equals;
487
+ const node: SourceNode = {
488
+ _value: initial,
489
+ _version: 0,
490
+ _node: undefined,
491
+ _targets: undefined,
492
+ _isComputed: false,
493
+ };
494
+
495
+ const s = {
496
+ [SIGNAL_BRAND]: true,
497
+ get value(): T {
498
+ const dep = addDependency(node);
499
+ if (dep !== undefined) {
500
+ dep._version = node._version;
501
+ }
502
+ return node._value as T;
503
+ },
504
+ set value(v: T) {
505
+ if (eq === false ? false : eq ? eq(node._value as T, v) : Object.is(node._value, v)) {
506
+ return;
507
+ }
508
+ node._value = v;
509
+ node._version++;
510
+ globalVersion++;
511
+ startBatch();
512
+ try {
513
+ notifyTargets(node);
514
+ } finally {
515
+ endBatch();
516
+ }
517
+ },
518
+ peek(): T {
519
+ return node._value as T;
520
+ },
521
+ };
522
+ return s;
523
+ }
524
+
525
+ /** Lazy cached derived signal with equality dedup (glitch-free). */
526
+ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySignal<T> {
527
+ const eq = options?.equals;
528
+ const node: ComputedNode = {
529
+ // SourceNode fields
530
+ _value: undefined,
531
+ _version: 0,
532
+ _node: undefined,
533
+ _targets: undefined,
534
+ _isComputed: true,
535
+ // TargetNode fields
536
+ _flags: DIRTY | TRACKING,
537
+ _sources: undefined,
538
+ // ComputedNode-specific fields
539
+ _fn: fn,
540
+ _equals:
541
+ eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
542
+ _globalVersion: -1,
543
+ };
544
+
545
+ const c = {
546
+ [COMPUTED_BRAND]: true,
547
+ get value(): T {
548
+ // Cycle detection
549
+ if (node._flags & RUNNING) {
550
+ throw new Error("Cycle detected");
551
+ }
552
+ // Refresh before tracking (pull-based glitch-free)
553
+ if (node._flags & (DIRTY | CHECK)) {
554
+ refreshComputed(node);
555
+ }
556
+ const dep = addDependency(node);
557
+ if (dep !== undefined) {
558
+ dep._version = node._version;
559
+ }
560
+ if (node._flags & HAS_ERROR) {
561
+ throw node._value;
562
+ }
563
+ return node._value as T;
564
+ },
565
+ set value(_v: T) {
566
+ throw new Error("Cannot set a computed signal");
567
+ },
568
+ peek(): T {
569
+ // F6: refresh WITHOUT tracking
570
+ if (node._flags & (DIRTY | CHECK)) {
571
+ const prevContext = evalContext;
572
+ evalContext = undefined;
573
+ try {
574
+ refreshComputed(node);
575
+ } finally {
576
+ evalContext = prevContext;
577
+ }
578
+ }
579
+ if (node._flags & HAS_ERROR) {
580
+ throw node._value;
581
+ }
582
+ return node._value as T;
583
+ },
584
+ };
585
+ return c;
586
+ }
587
+
588
+ /** Create a reactive effect that re-runs when its tracked signals change. Returns a dispose function. */
589
+ export function effect(fn: () => Cleanup): () => void {
590
+ const eff: EffectNode = {
591
+ _fn: fn,
592
+ _cleanup: undefined,
593
+ _flags: 0,
594
+ _sources: undefined,
595
+ _nextBatchedEffect: undefined,
596
+ _isComputed: false,
597
+ };
598
+
599
+ // Run immediately
600
+ try {
601
+ runEffect(eff);
602
+ } catch (e) {
603
+ safeCallHandler(e);
604
+ }
605
+
606
+ return () => {
607
+ if (eff._flags & DISPOSED) {
608
+ return;
609
+ }
610
+ disposeEffect(eff);
611
+ };
612
+ }
613
+
614
+ /** Coalesce signal writes; effects flush synchronously at end of outermost batch. */
615
+ export function batch(fn: () => void): void {
616
+ startBatch();
617
+ try {
618
+ fn();
619
+ } finally {
620
+ endBatch();
621
+ }
622
+ }
623
+
624
+ /** Flush all pending effects synchronously. No-op inside batch(). */
625
+ export function flushSync(): void {
626
+ if (batchDepth > 0) {
627
+ return;
628
+ }
629
+ startBatch();
630
+ endBatch();
631
+ }
632
+
633
+ /** Run fn without tracking any signal reads. */
634
+ export function untracked<T>(fn: () => T): T {
635
+ const prev = evalContext;
636
+ evalContext = undefined;
637
+ try {
638
+ return fn();
639
+ } finally {
640
+ evalContext = prev;
641
+ }
642
+ }
643
+
644
+ /** Subscribe to a signal, calling cb on every change. Returns dispose function. */
645
+ export function subscribe<T>(
646
+ sig: Signal<T> | ReadonlySignal<T>,
647
+ cb: (value: T) => void,
648
+ ): () => void {
649
+ return effect(() => {
650
+ cb(sig.value);
651
+ return undefined;
652
+ });
653
+ }
654
+
655
+ /** Type guard: returns true if value is a Signal created by signal(). */
656
+ export function isSignal(value: unknown): value is Signal<unknown> {
657
+ return value !== null && typeof value === "object" && SIGNAL_BRAND in value;
658
+ }
659
+
660
+ /** Type guard: returns true if value is a computed ReadonlySignal. */
661
+ export function isComputed(value: unknown): value is ReadonlySignal<unknown> {
662
+ return value !== null && typeof value === "object" && COMPUTED_BRAND in value;
663
+ }
664
+
665
+ /** Explicit dependency declaration helper. Mirrors Solid's on(). */
666
+ export function on<T, U>(
667
+ deps: (() => T) | (() => unknown)[],
668
+ fn: (value: T | unknown[], prev: T | unknown[] | undefined, prevResult: U | undefined) => U,
669
+ options?: { defer?: boolean },
670
+ ): () => U | undefined {
671
+ let prevInput: T | unknown[] | undefined;
672
+ let prevResult: U | undefined;
673
+ let first = true;
674
+ return () => {
675
+ const input: T | unknown[] = Array.isArray(deps) ? deps.map((d) => d()) : deps();
676
+ if (first && options?.defer) {
677
+ first = false;
678
+ prevInput = input;
679
+ return undefined;
680
+ }
681
+ const result = untracked(() => fn(input, prevInput, prevResult));
682
+ prevInput = input;
683
+ prevResult = result;
684
+ first = false;
685
+ return result;
686
+ };
687
+ }