@pithyjs/signals 0.1.0-beta.0 → 0.1.0-beta.1

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/dist/signal.js CHANGED
@@ -5,25 +5,529 @@
5
5
  "category": "feature"
6
6
  }
7
7
  */
8
+ // The dependency graph (link, unlink, propagate, checkDirty) is adapted from alien-signals, MIT, Copyright (c) 2024-present Johnson Chu.
8
9
  import { SIGNAL_SYMBOL } from './types';
9
- /**
10
- * Tracks the current reactive effect being registered.
11
- * Used to record dependencies when a signal is read.
12
- */
13
- let currentEffect = null;
14
- /**
15
- * Internal cleanup registry to track cleanup functions for each registered effect.
16
- * Each effect can have multiple cleanups that are called before re-execution.
17
- */
10
+ import { disposerCalled, effectWrappers, installIntrospection, nodesOf, rootOwners, } from './internals';
11
+ const DEV = !!import.meta.env?.DEV;
12
+ const MUTABLE = 1;
13
+ const RUNNING = 2;
14
+ const DIRTY = 8;
15
+ const PENDING = 16;
16
+ const QUEUED = 32;
17
+ const DISPOSED = 64;
18
+ const DRAINING = 128;
19
+ const DEFERRED = 256;
20
+ const FAILED = 512;
21
+ const CHECKING = 1024;
22
+ const OWNS = 2048;
23
+ let activeSub = null;
24
+ let currentOwner = null;
25
+ let computing = null;
26
+ let version = 0;
27
+ let markEpoch = 0;
28
+ let batchEpoch = 0;
29
+ const effectNodes = new WeakSet();
30
+ const noop = () => { };
18
31
  const cleanupMap = new WeakMap();
32
+ const selfRegistered = new WeakMap();
33
+ const warnedImpure = new WeakSet();
34
+ function registerCleanup(owner, fn) {
35
+ if (!cleanupMap.has(owner))
36
+ return;
37
+ let stack = cleanupMap.get(owner);
38
+ if (!stack) {
39
+ stack = [];
40
+ cleanupMap.set(owner, stack);
41
+ }
42
+ stack.push(fn);
43
+ }
19
44
  /**
20
- * Flag to indicate if we are currently inside a batched update.
45
+ * Runs every callback even if one throws, then rethrows the first failure.
46
+ * The caller has already dropped its reference to the stack, so a callback
47
+ * skipped here is unreachable forever — a throwing cleanup would otherwise
48
+ * leave the rest of an owner's children subscribed with no way to dispose them.
49
+ *
50
+ * Tracking and ownership are neutralised for the duration: a cleanup is
51
+ * teardown, not reactive work, so a signal it reads must not subscribe whatever
52
+ * effect happens to be mid-run, and an `onCleanup` it registers must not land
53
+ * on an owner that outlives it. Every disposal path shares this, not just the
54
+ * re-run one — a disposer called from inside another effect reaches here too.
21
55
  */
22
- let isBatching = false;
56
+ function drain(stack) {
57
+ const callerSub = activeSub;
58
+ const callerOwner = currentOwner;
59
+ activeSub = null;
60
+ currentOwner = null;
61
+ let failure;
62
+ let failed = false;
63
+ try {
64
+ for (const cleanup of stack) {
65
+ try {
66
+ cleanup();
67
+ }
68
+ catch (error) {
69
+ if (!failed) {
70
+ failed = true;
71
+ failure = error;
72
+ }
73
+ }
74
+ }
75
+ }
76
+ finally {
77
+ activeSub = callerSub;
78
+ currentOwner = callerOwner;
79
+ }
80
+ if (failed)
81
+ throw failure;
82
+ }
83
+ function link(dep, sub) {
84
+ const prevDep = sub.depsTail;
85
+ if (prevDep !== undefined && prevDep.dep === dep)
86
+ return;
87
+ const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps;
88
+ if (nextDep !== undefined && nextDep.dep === dep) {
89
+ nextDep.version = sub.version;
90
+ sub.depsTail = nextDep;
91
+ return;
92
+ }
93
+ const prevSub = dep.subsTail;
94
+ if (prevSub !== undefined &&
95
+ prevSub.version === sub.version &&
96
+ prevSub.sub === sub) {
97
+ return;
98
+ }
99
+ const newLink = {
100
+ version: sub.version,
101
+ dep,
102
+ sub,
103
+ prevDep,
104
+ nextDep,
105
+ prevSub,
106
+ nextSub: undefined,
107
+ };
108
+ sub.depsTail = newLink;
109
+ dep.subsTail = newLink;
110
+ if (nextDep !== undefined)
111
+ nextDep.prevDep = newLink;
112
+ if (prevDep !== undefined)
113
+ prevDep.nextDep = newLink;
114
+ else
115
+ sub.deps = newLink;
116
+ if (prevSub !== undefined)
117
+ prevSub.nextSub = newLink;
118
+ else
119
+ dep.subs = newLink;
120
+ }
121
+ function unlink(target, sub) {
122
+ const { dep, prevDep, nextDep, nextSub, prevSub } = target;
123
+ if (nextDep !== undefined)
124
+ nextDep.prevDep = prevDep;
125
+ else
126
+ sub.depsTail = prevDep;
127
+ if (prevDep !== undefined)
128
+ prevDep.nextDep = nextDep;
129
+ else
130
+ sub.deps = nextDep;
131
+ if (nextSub !== undefined)
132
+ nextSub.prevSub = prevSub;
133
+ else
134
+ dep.subsTail = prevSub;
135
+ if (prevSub !== undefined)
136
+ prevSub.nextSub = nextSub;
137
+ else
138
+ dep.subs = nextSub;
139
+ return nextDep;
140
+ }
141
+ function unlinkAll(sub) {
142
+ let dep = sub.deps;
143
+ while (dep !== undefined)
144
+ dep = unlink(dep, sub);
145
+ }
146
+ function purgeDeps(sub) {
147
+ const tail = sub.depsTail;
148
+ let dep = tail !== undefined ? tail.nextDep : sub.deps;
149
+ while (dep !== undefined)
150
+ dep = unlink(dep, sub);
151
+ }
152
+ function settleDeps(sub, kept) {
153
+ if (sub.flags & DISPOSED) {
154
+ unlinkAll(sub);
155
+ }
156
+ else if (kept || sub.depsTail === undefined) {
157
+ let tail = sub.deps;
158
+ while (tail?.nextDep !== undefined)
159
+ tail = tail.nextDep;
160
+ sub.depsTail = tail;
161
+ }
162
+ else {
163
+ purgeDeps(sub);
164
+ }
165
+ }
166
+ function isValidLink(check, sub) {
167
+ let candidate = sub.depsTail;
168
+ while (candidate !== undefined) {
169
+ if (candidate === check)
170
+ return true;
171
+ candidate = candidate.prevDep;
172
+ }
173
+ return false;
174
+ }
175
+ function isComputed(node) {
176
+ return (node.flags & MUTABLE) !== 0 && 'getter' in node;
177
+ }
178
+ let queue = [];
179
+ let queuedLength = 0;
180
+ let flushLevel = 0;
181
+ let segment = 0;
182
+ function isQueuedHere(node) {
183
+ return (node.flags & QUEUED) !== 0 && node.slot >= segment;
184
+ }
185
+ let ownersQueued = false;
186
+ function enqueue(node) {
187
+ node.flags |= QUEUED;
188
+ node.slot = queuedLength;
189
+ queue[queuedLength++] = node;
190
+ if (node.flags & OWNS)
191
+ ownersQueued = true;
192
+ }
193
+ function runQueuedAncestors(node) {
194
+ const chain = [];
195
+ for (let ancestor = node.parent; ancestor !== null; ancestor = ancestor.parent) {
196
+ if ((ancestor.flags & (QUEUED | DISPOSED)) === QUEUED)
197
+ chain.push(ancestor);
198
+ }
199
+ for (let at = chain.length - 1; at >= 0; at--)
200
+ run(chain[at]);
201
+ }
202
+ function notify(effectNode, direct) {
203
+ const flags = effectNode.flags;
204
+ if (flags & DISPOSED)
205
+ return;
206
+ effectNode.flags = flags | (direct ? DIRTY : PENDING);
207
+ if (!isQueuedHere(effectNode))
208
+ enqueue(effectNode);
209
+ }
210
+ function propagate(first) {
211
+ const epoch = ++markEpoch;
212
+ const stack = [];
213
+ let current = first;
214
+ do {
215
+ while (current !== undefined) {
216
+ const sub = current.sub;
217
+ const next = current.nextSub;
218
+ const flags = sub.flags;
219
+ const direct = stack.length === 0;
220
+ if (flags & DISPOSED) {
221
+ current = next;
222
+ continue;
223
+ }
224
+ if (!isComputed(sub)) {
225
+ notify(sub, direct);
226
+ current = next;
227
+ continue;
228
+ }
229
+ const node = sub;
230
+ if (flags & DRAINING) {
231
+ current = next;
232
+ continue;
233
+ }
234
+ if (flags & RUNNING) {
235
+ if (isValidLink(current, node)) {
236
+ node.flags = flags | (direct ? DIRTY : PENDING);
237
+ }
238
+ current = next;
239
+ continue;
240
+ }
241
+ node.flags = flags | (direct ? DIRTY : PENDING);
242
+ const settled = batchDepth > 0 && node.marked > batchEpoch && flags & (DIRTY | PENDING);
243
+ if (node.marked !== epoch && !settled) {
244
+ node.marked = epoch;
245
+ if (node.subs !== undefined) {
246
+ stack.push(next);
247
+ current = node.subs;
248
+ continue;
249
+ }
250
+ }
251
+ current = next;
252
+ }
253
+ current = stack.pop();
254
+ } while (current !== undefined || stack.length > 0);
255
+ }
256
+ function shallowPropagate(first) {
257
+ let current = first;
258
+ while (current !== undefined) {
259
+ const sub = current.sub;
260
+ const flags = sub.flags;
261
+ if ((flags & (PENDING | DIRTY)) === PENDING)
262
+ sub.flags = flags | DIRTY;
263
+ current = current.nextSub;
264
+ }
265
+ }
266
+ function checkDirty(start, origin) {
267
+ const stack = [];
268
+ origin.flags |= CHECKING;
269
+ try {
270
+ return walkDirty(start, origin, stack);
271
+ }
272
+ finally {
273
+ origin.flags &= ~CHECKING;
274
+ for (const link of stack)
275
+ link.dep.flags &= ~CHECKING;
276
+ }
277
+ }
278
+ function walkDirty(start, origin, stack) {
279
+ let current = start;
280
+ let sub = origin;
281
+ let dirty = false;
282
+ for (;;) {
283
+ const dep = current.dep;
284
+ const flags = dep.flags;
285
+ if (sub.flags & DIRTY) {
286
+ dirty = true;
287
+ }
288
+ else if (isComputed(dep) && !(flags & (DISPOSED | RUNNING | CHECKING))) {
289
+ if (flags & DIRTY) {
290
+ if (updateComputed(dep)) {
291
+ if (dep.subs?.nextSub !== undefined)
292
+ shallowPropagate(dep.subs);
293
+ dirty = true;
294
+ }
295
+ else if (dep.flags & DIRTY) {
296
+ dirty = true;
297
+ }
298
+ }
299
+ else if (flags & PENDING && dep.deps !== undefined) {
300
+ dep.flags |= CHECKING;
301
+ stack.push(current);
302
+ current = dep.deps;
303
+ sub = dep;
304
+ continue;
305
+ }
306
+ }
307
+ if (!dirty && current.nextDep !== undefined) {
308
+ current = current.nextDep;
309
+ continue;
310
+ }
311
+ let resumed = false;
312
+ let parent;
313
+ while ((parent = stack.pop()) !== undefined) {
314
+ const node = sub;
315
+ node.flags &= ~CHECKING;
316
+ if (dirty) {
317
+ const changed = updateComputed(node);
318
+ if (changed && node.subs?.nextSub !== undefined) {
319
+ shallowPropagate(node.subs);
320
+ }
321
+ if (changed || node.flags & DIRTY) {
322
+ sub = parent.sub;
323
+ current = parent;
324
+ continue;
325
+ }
326
+ dirty = false;
327
+ }
328
+ else {
329
+ node.flags &= ~PENDING;
330
+ }
331
+ sub = parent.sub;
332
+ if (parent.nextDep !== undefined) {
333
+ current = parent.nextDep;
334
+ resumed = true;
335
+ break;
336
+ }
337
+ current = parent;
338
+ }
339
+ if (resumed)
340
+ continue;
341
+ return dirty && !(sub.flags & DISPOSED);
342
+ }
343
+ }
344
+ let errorScopes = 0;
345
+ let recordedError;
346
+ let inBatchBody = false;
347
+ function record(error, node, compute) {
348
+ if (errorScopes === 0) {
349
+ node.flags |= DIRTY;
350
+ throw error;
351
+ }
352
+ if (compute && inBatchBody) {
353
+ node.flags |= DIRTY;
354
+ return;
355
+ }
356
+ recordedError ?? (recordedError = [error]);
357
+ }
358
+ function warnImpure(what) {
359
+ const node = computing;
360
+ if (!node || warnedImpure.has(node))
361
+ return;
362
+ warnedImpure.add(node);
363
+ console.warn(`[PithyJS] A computed ${what} while computing. A computed must be pure: move the work into an effect() that reads the computed.`);
364
+ }
365
+ function updateComputed(node) {
366
+ const getter = node.getter;
367
+ if (!getter)
368
+ return false;
369
+ const previousComputing = computing;
370
+ const stale = cleanupMap.get(node);
371
+ cleanupMap.set(node, null);
372
+ node.flags = (node.flags & ~(DIRTY | PENDING)) | RUNNING;
373
+ computing = node;
374
+ if (stale && stale.length > 0) {
375
+ node.flags |= DRAINING;
376
+ try {
377
+ drain(stale);
378
+ }
379
+ catch (error) {
380
+ node.flags &= ~(RUNNING | DRAINING);
381
+ computing = previousComputing;
382
+ record(error, node, false);
383
+ return false;
384
+ }
385
+ finally {
386
+ node.flags &= ~DRAINING;
387
+ }
388
+ }
389
+ if (node.flags & DISPOSED) {
390
+ node.flags &= ~RUNNING;
391
+ computing = previousComputing;
392
+ unlinkAll(node);
393
+ return false;
394
+ }
395
+ const previousSub = activeSub;
396
+ const previousOwner = currentOwner;
397
+ const previousTracking = isTrackingContext;
398
+ node.depsTail = undefined;
399
+ node.version = ++version;
400
+ activeSub = node;
401
+ currentOwner = node;
402
+ isTrackingContext = true;
403
+ let next;
404
+ let ok = false;
405
+ try {
406
+ next = getter();
407
+ ok = true;
408
+ }
409
+ catch (error) {
410
+ record(error, node, true);
411
+ return false;
412
+ }
413
+ finally {
414
+ activeSub = previousSub;
415
+ currentOwner = previousOwner;
416
+ isTrackingContext = previousTracking;
417
+ computing = previousComputing;
418
+ node.flags &= ~RUNNING;
419
+ settleDeps(node, !ok);
420
+ }
421
+ const changed = node.value !== next;
422
+ node.value = next;
423
+ return changed;
424
+ }
425
+ function refresh(node) {
426
+ const flags = node.flags;
427
+ if (flags & (DISPOSED | RUNNING | CHECKING) || !(flags & (DIRTY | PENDING))) {
428
+ return;
429
+ }
430
+ if (flags & DIRTY ||
431
+ (node.deps !== undefined && checkDirty(node.deps, node))) {
432
+ if (updateComputed(node))
433
+ shallowPropagate(node.subs);
434
+ }
435
+ else {
436
+ node.flags &= ~PENDING;
437
+ }
438
+ }
439
+ function readComputed(node) {
440
+ refresh(node);
441
+ const sub = activeSub;
442
+ if (sub !== null &&
443
+ sub !== node &&
444
+ isComputed(sub) &&
445
+ (node.flags & (DIRTY | RUNNING | CHECKING | DISPOSED)) === DIRTY) {
446
+ sub.flags |= DIRTY;
447
+ }
448
+ if (sub !== null &&
449
+ sub !== node &&
450
+ !(node.flags & DISPOSED) &&
451
+ !(sub.flags & DISPOSED)) {
452
+ link(node, sub);
453
+ }
454
+ return node.value;
455
+ }
456
+ let notifyDepth = 0;
457
+ let batchFlushes = 0;
458
+ let reentries = 0;
459
+ function flushFrom(start, depth, reentered) {
460
+ let failure;
461
+ const outermost = flushLevel++ === 0;
462
+ let index = start;
463
+ try {
464
+ while (index < queuedLength) {
465
+ const node = queue[index];
466
+ queue[index++] = undefined;
467
+ if (node === undefined)
468
+ continue;
469
+ const stale = node.slot !== index - 1;
470
+ if (stale && node.flags & DISPOSED)
471
+ continue;
472
+ if (!stale &&
473
+ (node.flags & (RUNNING | DEFERRED)) === RUNNING &&
474
+ index < queuedLength) {
475
+ node.flags |= DEFERRED;
476
+ node.slot = queuedLength;
477
+ queue[queuedLength++] = node;
478
+ continue;
479
+ }
480
+ node.flags &= ~DEFERRED;
481
+ const prior = reentries;
482
+ try {
483
+ if (ownersQueued && node.parent !== null)
484
+ runQueuedAncestors(node);
485
+ if (stale && node.flags & FAILED)
486
+ runEffect(node);
487
+ else
488
+ run(node);
489
+ }
490
+ catch (error) {
491
+ failure ?? (failure = [error]);
492
+ if (depth && (reentered || reentries !== prior))
493
+ throw failure[0];
494
+ }
495
+ }
496
+ }
497
+ finally {
498
+ flushLevel--;
499
+ while (index < queuedLength) {
500
+ const node = queue[index];
501
+ queue[index++] = undefined;
502
+ if (node !== undefined)
503
+ node.flags &= ~QUEUED;
504
+ }
505
+ queuedLength = outermost ? 0 : start;
506
+ if (outermost) {
507
+ ownersQueued = false;
508
+ if (queue.length > 1024)
509
+ queue = [];
510
+ }
511
+ }
512
+ return failure;
513
+ }
514
+ function run(node) {
515
+ const flags = node.flags;
516
+ node.flags = flags & ~QUEUED;
517
+ if (flags & DISPOSED)
518
+ return;
519
+ if (flags & DIRTY ||
520
+ (flags & PENDING && node.deps !== undefined && checkDirty(node.deps, node))) {
521
+ runEffect(node);
522
+ }
523
+ else {
524
+ node.flags &= ~PENDING;
525
+ }
526
+ }
23
527
  /**
24
- * Set of effect wrappers scheduled to run after a batch ends.
528
+ * How many `batch()` bodies are running; only the outermost one flushes.
25
529
  */
26
- const pendingEffects = new Set();
530
+ let batchDepth = 0;
27
531
  /**
28
532
  * Indicates whether we are currently inside a tracking context (effect or computed).
29
533
  * Used to suppress signal read warnings in appropriate scopes.
@@ -34,11 +538,11 @@ let isTrackingContext = false;
34
538
  */
35
539
  let suppressionDepth = 0;
36
540
  /**
37
- * Checks if a value is a Signal at runtime.
541
+ * Checks if a value is a signal or a computed at runtime.
38
542
  * @param value - The value to check.
39
- * @returns True if the value is a Signal.
543
+ * @returns True if the value is a signal or a computed.
40
544
  */
41
- /** @codexApi {"parent":"pithy.feature.signals","name":"isSignal","stability":"stable","signature":"(value: unknown) => value is Signal<any>"} */
545
+ /** @codexApi {"parent":"pithy.feature.signals","name":"isSignal","stability":"stable","signature":"(value: unknown) => value is ReadableSignal<unknown>"} */
42
546
  export function isSignal(value) {
43
547
  return (typeof value === 'function' &&
44
548
  value[SIGNAL_SYMBOL] === true);
@@ -59,18 +563,21 @@ function suppressWarnings(fn) {
59
563
  }
60
564
  }
61
565
  /**
62
- * Registers a cleanup callback that will be executed
63
- * before the current effect is re-run.
566
+ * Registers a cleanup callback, executed once — before the current effect's
567
+ * next re-run, or when it is disposed, whichever comes first. Effects and
568
+ * computeds created during a run are
569
+ * owned by that run and torn down on the same schedule; use `createRoot` for
570
+ * work that must outlive the run that built it.
64
571
  *
65
572
  * @param fn - The cleanup function.
66
573
  */
67
574
  /** @codexApi {"parent":"pithy.feature.signals","name":"onCleanup","stability":"stable","signature":"(fn: () => void) => void"} */
68
575
  export function onCleanup(fn) {
69
- if (currentEffect) {
70
- const stack = cleanupMap.get(currentEffect);
71
- if (stack)
72
- stack.push(fn);
73
- }
576
+ if (!currentOwner)
577
+ return;
578
+ if (selfRegistered.get(fn) === currentOwner)
579
+ return;
580
+ registerCleanup(currentOwner, fn);
74
581
  }
75
582
  /**
76
583
  * Creates a reactive effect that automatically tracks
@@ -81,36 +588,297 @@ export function onCleanup(fn) {
81
588
  */
82
589
  /** @codexApi {"parent":"pithy.feature.signals","name":"effect","stability":"stable","signature":"(fn: () => void) => () => void"} */
83
590
  export function effect(fn) {
84
- const wrapped = () => runEffect(wrapped, fn);
85
- // Run immediately to establish dependencies
86
- wrapped();
87
- // Return disposer to manually stop the effect
591
+ if (DEV && computing)
592
+ warnImpure('created an effect');
593
+ const owner = currentOwner;
594
+ const node = {
595
+ fn,
596
+ deps: undefined,
597
+ depsTail: undefined,
598
+ flags: DIRTY,
599
+ version: 0,
600
+ parent: owner !== null && effectNodes.has(owner) ? owner : null,
601
+ slot: 0,
602
+ };
603
+ if (node.parent !== null) {
604
+ node.parent.flags |= OWNS;
605
+ if (node.parent.flags & QUEUED)
606
+ ownersQueued = true;
607
+ }
608
+ effectNodes.add(node);
609
+ cleanupMap.set(node, null);
88
610
  const disposer = () => {
89
- cleanupMap.get(wrapped)?.forEach(fn => fn());
90
- cleanupMap.delete(wrapped);
611
+ const flags = node.flags;
612
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__)
613
+ disposerCalled.add(node);
614
+ if (flags & DISPOSED)
615
+ return;
616
+ node.flags |= DISPOSED;
617
+ node.parent = null;
618
+ try {
619
+ const stack = cleanupMap.get(node);
620
+ if (stack)
621
+ drain(stack);
622
+ }
623
+ finally {
624
+ cleanupMap.delete(node);
625
+ selfRegistered.delete(disposer);
626
+ node.fn = noop;
627
+ if (!(node.flags & RUNNING))
628
+ unlinkAll(node);
629
+ }
91
630
  };
631
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__)
632
+ effectWrappers.set(disposer, node);
633
+ if (owner) {
634
+ selfRegistered.set(disposer, owner);
635
+ registerCleanup(owner, disposer);
636
+ }
637
+ if (batchDepth > 0) {
638
+ enqueue(node);
639
+ return disposer;
640
+ }
641
+ const owns = errorScopes === 0;
642
+ const outer = recordedError;
643
+ let recorded;
644
+ if (owns) {
645
+ recordedError = undefined;
646
+ errorScopes++;
647
+ }
648
+ let thrown;
649
+ try {
650
+ runEffect(node);
651
+ }
652
+ catch (error) {
653
+ thrown = [error];
654
+ }
655
+ finally {
656
+ if (owns) {
657
+ errorScopes--;
658
+ recorded = recordedError;
659
+ recordedError = outer;
660
+ }
661
+ }
662
+ const failure = recorded ?? thrown;
663
+ if (failure) {
664
+ if (!owner) {
665
+ try {
666
+ disposer();
667
+ }
668
+ catch {
669
+ // The run's error is the one worth reporting.
670
+ }
671
+ }
672
+ throw failure[0];
673
+ }
92
674
  return disposer;
93
675
  }
676
+ function runEffect(node) {
677
+ if (node.flags & (DISPOSED | DRAINING))
678
+ return;
679
+ const stale = cleanupMap.get(node);
680
+ cleanupMap.set(node, null);
681
+ if (stale && stale.length > 0) {
682
+ node.flags |= DRAINING;
683
+ try {
684
+ drain(stale);
685
+ }
686
+ finally {
687
+ node.flags &= ~DRAINING;
688
+ }
689
+ }
690
+ if (node.flags & DISPOSED)
691
+ return;
692
+ const running = node.flags & RUNNING;
693
+ node.flags = (node.flags & ~(DIRTY | PENDING | FAILED)) | RUNNING;
694
+ node.depsTail = undefined;
695
+ node.version = ++version;
696
+ const parentSub = activeSub;
697
+ const parentOwner = currentOwner;
698
+ const parentTracking = isTrackingContext;
699
+ const parentComputing = computing;
700
+ activeSub = node;
701
+ currentOwner = node;
702
+ isTrackingContext = true;
703
+ computing = null;
704
+ let ok = false;
705
+ try {
706
+ node.fn();
707
+ ok = true;
708
+ }
709
+ finally {
710
+ activeSub = parentSub;
711
+ currentOwner = parentOwner;
712
+ isTrackingContext = parentTracking;
713
+ computing = parentComputing;
714
+ if (!running)
715
+ node.flags &= ~RUNNING;
716
+ if (!ok)
717
+ node.flags |= FAILED;
718
+ settleDeps(node, !ok);
719
+ }
720
+ }
94
721
  /**
95
722
  * Batches multiple signal updates together.
96
- * Effects triggered during the batch are deferred and executed once after the batch.
723
+ * Batches nest: effects triggered inside are deferred and run once, after the outermost batch.
97
724
  *
98
725
  * @param fn - The function performing multiple signal updates.
99
726
  */
100
727
  /** @codexApi {"parent":"pithy.feature.signals","name":"batch","stability":"stable","signature":"(fn: () => void) => void"} */
101
728
  export function batch(fn) {
102
- isBatching = true;
729
+ let failure;
730
+ const outermost = batchDepth === 0;
731
+ const start = queuedLength;
732
+ const outerEpoch = batchEpoch;
733
+ const outerSegment = segment;
734
+ const outer = recordedError;
735
+ const outerBody = inBatchBody;
736
+ if (outermost) {
737
+ batchEpoch = markEpoch;
738
+ segment = start;
739
+ recordedError = undefined;
740
+ inBatchBody = true;
741
+ errorScopes++;
742
+ }
743
+ batchDepth++;
103
744
  try {
104
745
  fn();
105
746
  }
106
747
  finally {
107
- isBatching = false;
108
- // Execute all pending effects once
109
- const effects = Array.from(pendingEffects);
110
- pendingEffects.clear();
111
- effects.forEach(run => run());
748
+ if (--batchDepth === 0) {
749
+ batchEpoch = outerEpoch;
750
+ segment = outerSegment;
751
+ inBatchBody = outerBody;
752
+ failure = recordedError;
753
+ recordedError = undefined;
754
+ const depth = notifyDepth++;
755
+ const nested = batchFlushes++ > 0;
756
+ if (nested)
757
+ reentries++;
758
+ try {
759
+ const flushed = flushFrom(start, depth, nested);
760
+ failure ?? (failure = flushed);
761
+ }
762
+ catch (error) {
763
+ failure ?? (failure = [error]);
764
+ while (queuedLength > start) {
765
+ const node = queue[--queuedLength];
766
+ queue[queuedLength] = undefined;
767
+ if (node !== undefined)
768
+ node.flags &= ~QUEUED;
769
+ }
770
+ }
771
+ finally {
772
+ notifyDepth = depth;
773
+ batchFlushes--;
774
+ inBatchBody = outerBody;
775
+ errorScopes--;
776
+ failure ?? (failure = recordedError);
777
+ recordedError = outer;
778
+ }
779
+ }
780
+ }
781
+ if (failure)
782
+ throw failure[0];
783
+ }
784
+ /**
785
+ * Reads signals without subscribing the enclosing effect to them.
786
+ *
787
+ * @param fn - The function to run untracked.
788
+ * @returns Whatever `fn` returns.
789
+ */
790
+ /** @codexApi {"parent":"pithy.feature.signals","name":"untrack","stability":"stable","signature":"<T>(fn: () => T) => T"} */
791
+ export function untrack(fn) {
792
+ const previous = activeSub;
793
+ activeSub = null;
794
+ try {
795
+ return suppressWarnings(fn);
796
+ }
797
+ finally {
798
+ activeSub = previous;
799
+ }
800
+ }
801
+ /**
802
+ * Creates an ownership root: effects and computeds created inside `fn` are owned
803
+ * by the root rather than by any enclosing effect, so an enclosing re-run does
804
+ * not dispose them. Disposing the root disposes everything it owns — but a root
805
+ * created inside another root is independent: hold its disposer, because the
806
+ * outer root will not reach it.
807
+ *
808
+ * Tracking is untouched — a signal read inside `fn` still subscribes whatever
809
+ * effect is current.
810
+ *
811
+ * @param fn - Receives the root's disposer.
812
+ * @returns Whatever `fn` returns.
813
+ */
814
+ /** @codexApi {"parent":"pithy.feature.signals","name":"createRoot","stability":"stable","signature":"<T>(fn: (dispose: () => void) => T) => T"} */
815
+ export function createRoot(fn) {
816
+ const owner = {};
817
+ cleanupMap.set(owner, null);
818
+ const dispose = () => {
819
+ const stack = cleanupMap.get(owner);
820
+ cleanupMap.delete(owner);
821
+ if (stack)
822
+ drain(stack);
823
+ };
824
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__)
825
+ rootOwners.set(dispose, owner);
826
+ const parentOwner = currentOwner;
827
+ currentOwner = owner;
828
+ try {
829
+ return fn(dispose);
830
+ }
831
+ catch (error) {
832
+ try {
833
+ dispose();
834
+ }
835
+ catch {
836
+ // `fn`'s error is the one worth reporting.
837
+ }
838
+ throw error;
839
+ }
840
+ finally {
841
+ currentOwner = parentOwner;
112
842
  }
113
843
  }
844
+ function callListeners(listeners, value, depth, reentered, failure) {
845
+ const previousSub = activeSub;
846
+ const previousComputing = computing;
847
+ activeSub = null;
848
+ computing = null;
849
+ try {
850
+ for (const fn of listeners) {
851
+ const prior = reentries;
852
+ try {
853
+ fn(value);
854
+ }
855
+ catch (error) {
856
+ failure ?? (failure = [error]);
857
+ if (depth && (reentered || reentries !== prior))
858
+ throw failure[0];
859
+ }
860
+ }
861
+ }
862
+ finally {
863
+ activeSub = previousSub;
864
+ computing = previousComputing;
865
+ }
866
+ return failure;
867
+ }
868
+ function subscribeWith(listeners, fn, current) {
869
+ const added = !listeners.has(fn);
870
+ listeners.add(fn);
871
+ let called = false;
872
+ try {
873
+ fn(current());
874
+ called = true;
875
+ }
876
+ finally {
877
+ if (!called && added)
878
+ listeners.delete(fn);
879
+ }
880
+ return called;
881
+ }
114
882
  /**
115
883
  * Creates a signal: a reactive function holding a value and supporting subscriptions.
116
884
  *
@@ -119,112 +887,254 @@ export function batch(fn) {
119
887
  */
120
888
  /** @codexApi {"parent":"pithy.feature.signals","name":"signal","stability":"stable","signature":"<T>(initial: T) => Signal<T>"} */
121
889
  export function signal(initial) {
122
- let value = initial;
123
- const subscribers = new Set();
890
+ const node = {
891
+ value: initial,
892
+ subs: undefined,
893
+ subsTail: undefined,
894
+ flags: 0,
895
+ };
896
+ let active = 0;
124
897
  const listeners = new Set();
125
- const read = () => suppressWarnings(() => {
126
- if (currentEffect) {
127
- // Register currentEffect as a subscriber
128
- subscribers.add(currentEffect);
129
- // IMPORTANT: Capture currentEffect NOW, not when cleanup runs!
130
- // Otherwise, if cleanup runs while a different effect is active,
131
- // we'd delete the wrong effect from subscribers.
132
- const effectToRemove = currentEffect;
133
- onCleanup(() => {
134
- subscribers.delete(effectToRemove);
135
- });
136
- }
137
- else {
138
- // Warn if a signal is read outside of reactive context (in dev mode)
139
- if (!isTrackingContext &&
140
- suppressionDepth === 0 &&
141
- import.meta.env?.DEV &&
142
- !import.meta.env?.TEST) {
143
- const error = new Error();
144
- const stack = error.stack ?? '';
145
- console.warn(`[PithyJS] ❗ Signal (value: ${value}) was read outside of a reactive context.\n` +
146
- ` • This might lead to missed updates in the UI.\n\n` +
147
- `🔎 Where it happened:\n${stack.split('\n').slice(2).join('\n')}`);
148
- }
149
- }
150
- return value;
151
- });
898
+ const read = () => {
899
+ const sub = activeSub;
900
+ if (sub !== null && !(sub.flags & DISPOSED))
901
+ link(node, sub);
902
+ return node.value;
903
+ };
152
904
  read.set = (next) => {
153
- const newValue = suppressWarnings(() => typeof next === 'function' ? next(value) : next);
154
- if (value !== newValue) {
155
- value = newValue;
156
- // Notify all reactive subscribers
157
- subscribers.forEach(fn => {
158
- if (isBatching) {
159
- pendingEffects.add(fn);
160
- }
161
- else {
162
- fn();
163
- }
164
- });
165
- // Notify all external listeners (subscriptions)
166
- listeners.forEach(fn => fn(value));
905
+ const newValue = suppressWarnings(() => typeof next === 'function' ? next(node.value) : next);
906
+ if (DEV && computing)
907
+ warnImpure('wrote a signal');
908
+ if (node.value === newValue)
909
+ return;
910
+ node.value = newValue;
911
+ let failure;
912
+ const start = queuedLength;
913
+ const outerSegment = segment;
914
+ const outer = recordedError;
915
+ const outerBody = inBatchBody;
916
+ const depth = notifyDepth++;
917
+ const entered = active++ > 0;
918
+ if (entered)
919
+ reentries++;
920
+ if (batchDepth === 0)
921
+ segment = start;
922
+ recordedError = undefined;
923
+ inBatchBody = false;
924
+ errorScopes++;
925
+ try {
926
+ if (node.subs !== undefined)
927
+ propagate(node.subs);
928
+ if (batchDepth === 0)
929
+ failure = flushFrom(start, depth, entered);
930
+ if (listeners.size > 0) {
931
+ failure = callListeners(listeners, node.value, depth, entered, failure);
932
+ }
933
+ failure ?? (failure = recordedError);
934
+ }
935
+ finally {
936
+ notifyDepth = depth;
937
+ active--;
938
+ segment = outerSegment;
939
+ errorScopes--;
940
+ recordedError = outer;
941
+ inBatchBody = outerBody;
167
942
  }
943
+ if (failure)
944
+ throw failure[0];
168
945
  };
169
946
  read.subscribe = (fn) => {
170
- listeners.add(fn);
171
- fn(value); // Immediately call with current value
947
+ subscribeWith(listeners, fn, () => node.value);
172
948
  return () => listeners.delete(fn);
173
949
  };
174
950
  read[SIGNAL_SYMBOL] =
175
951
  true;
952
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__)
953
+ nodesOf.set(read, node);
176
954
  return read;
177
955
  }
178
956
  /**
179
- * Creates a derived signal (computed value) that automatically
180
- * recalculates when its dependencies change.
957
+ * Creates a derived signal (computed value). It computes once when created,
958
+ * then recomputes when it is read after one of its sources changed.
181
959
  *
182
- * @param compute - A function returning the computed value.
183
- * @returns A readonly function returning the latest computed value.
960
+ * @param compute - A function returning the computed value. It must not write signals or create effects.
961
+ * @returns A read-only signal returning the latest computed value.
184
962
  */
185
- /** @codexApi {"parent":"pithy.feature.signals","name":"computed","stability":"stable","signature":"<T>(compute: () => T) => () => T"} */
963
+ /** @codexApi {"parent":"pithy.feature.signals","name":"computed","stability":"stable","signature":"<T>(compute: () => T) => ReadableSignal<T>"} */
186
964
  export function computed(compute) {
187
- isTrackingContext = true; // Allow first compute() call to access signals without warnings
188
- let initial;
965
+ const node = {
966
+ value: undefined,
967
+ getter: compute,
968
+ subs: undefined,
969
+ subsTail: undefined,
970
+ deps: undefined,
971
+ depsTail: undefined,
972
+ flags: MUTABLE,
973
+ version: 0,
974
+ marked: 0,
975
+ watcher: null,
976
+ };
977
+ const listeners = new Set();
978
+ cleanupMap.set(node, null);
979
+ const dispose = () => {
980
+ if (node.flags & DISPOSED)
981
+ return;
982
+ node.flags |= DISPOSED;
983
+ const watcher = node.watcher;
984
+ node.watcher = null;
985
+ try {
986
+ watcher?.();
987
+ const stack = cleanupMap.get(node);
988
+ if (stack)
989
+ drain(stack);
990
+ }
991
+ finally {
992
+ cleanupMap.delete(node);
993
+ if (!(node.flags & RUNNING))
994
+ unlinkAll(node);
995
+ node.getter = null;
996
+ }
997
+ };
998
+ const owner = currentOwner;
999
+ if (owner)
1000
+ registerCleanup(owner, dispose);
1001
+ const previousSub = activeSub;
1002
+ const previousOwner = currentOwner;
1003
+ const previousTracking = isTrackingContext;
1004
+ const previousComputing = computing;
1005
+ node.flags |= RUNNING;
1006
+ node.version = ++version;
1007
+ activeSub = node;
1008
+ currentOwner = node;
1009
+ isTrackingContext = true;
1010
+ computing = node;
1011
+ let recorded;
1012
+ let failed;
1013
+ const owns = errorScopes === 0;
1014
+ const outer = recordedError;
1015
+ if (owns) {
1016
+ recordedError = undefined;
1017
+ errorScopes++;
1018
+ }
189
1019
  try {
190
- initial = compute();
1020
+ node.value = compute();
1021
+ }
1022
+ catch (error) {
1023
+ failed = [error];
191
1024
  }
192
1025
  finally {
193
- isTrackingContext = false;
1026
+ if (owns) {
1027
+ errorScopes--;
1028
+ recorded = recordedError;
1029
+ recordedError = outer;
1030
+ }
1031
+ activeSub = previousSub;
1032
+ currentOwner = previousOwner;
1033
+ isTrackingContext = previousTracking;
1034
+ computing = previousComputing;
1035
+ node.flags &= ~RUNNING;
194
1036
  }
195
- const result = signal(initial);
196
- const owner = currentEffect;
197
- const dispose = effect(() => {
198
- result.set(compute());
199
- });
200
- if (owner) {
201
- cleanupMap.get(owner)?.push(dispose);
1037
+ failed ?? (failed = recorded);
1038
+ if (failed) {
1039
+ dispose();
1040
+ throw failed[0];
202
1041
  }
203
- return result;
1042
+ if (node.flags & DISPOSED)
1043
+ unlinkAll(node);
1044
+ const read = () => readComputed(node);
1045
+ read.subscribe = (fn) => {
1046
+ let delivered = untrack(read);
1047
+ if (!subscribeWith(listeners, fn, () => delivered))
1048
+ return () => { };
1049
+ if (!node.watcher && !(node.flags & DISPOSED)) {
1050
+ const outerOwner = currentOwner;
1051
+ const outerComputing = computing;
1052
+ currentOwner = null;
1053
+ computing = null;
1054
+ try {
1055
+ node.watcher = effect(() => {
1056
+ const value = read();
1057
+ if (value === delivered)
1058
+ return;
1059
+ delivered = value;
1060
+ const failure = callListeners(listeners, value, 0, false, undefined);
1061
+ if (failure)
1062
+ throw failure[0];
1063
+ });
1064
+ }
1065
+ finally {
1066
+ currentOwner = outerOwner;
1067
+ computing = outerComputing;
1068
+ }
1069
+ }
1070
+ return () => {
1071
+ listeners.delete(fn);
1072
+ if (listeners.size === 0 && node.watcher) {
1073
+ const stop = node.watcher;
1074
+ node.watcher = null;
1075
+ stop();
1076
+ }
1077
+ };
1078
+ };
1079
+ read[SIGNAL_SYMBOL] =
1080
+ true;
1081
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__)
1082
+ nodesOf.set(read, node);
1083
+ return read;
204
1084
  }
205
- /**
206
- * Runs the provided effect function, setting up the current tracking context,
207
- * managing cleanups, and allowing signal dependencies to be re-collected.
208
- *
209
- * @param wrapped - The wrapper function associated with the effect.
210
- * @param fn - The effect body function.
211
- */
212
- function runEffect(wrapped, fn) {
213
- isTrackingContext = true;
214
- if (isBatching) {
215
- // Defer running effect until after batch completes
216
- pendingEffects.add(() => runEffect(wrapped, fn));
217
- return;
1085
+ function subscribersOfNode(node) {
1086
+ const result = [];
1087
+ let current = node.subs;
1088
+ while (current !== undefined) {
1089
+ if (!result.includes(current.sub))
1090
+ result.push(current.sub);
1091
+ current = current.nextSub;
218
1092
  }
219
- // Prepare new cleanup stack
220
- cleanupMap.set(wrapped, []);
221
- currentEffect = wrapped;
222
- try {
223
- fn(); // Run the effect
224
- }
225
- finally {
226
- currentEffect = null;
227
- isTrackingContext = false;
1093
+ return result;
1094
+ }
1095
+ function dependenciesOfNode(node) {
1096
+ const result = [];
1097
+ let current = node.deps;
1098
+ while (current !== undefined) {
1099
+ if (!result.includes(current.dep))
1100
+ result.push(current.dep);
1101
+ current = current.nextDep;
228
1102
  }
1103
+ return result;
1104
+ }
1105
+ if (typeof __PITHY_TEST__ !== 'undefined' && __PITHY_TEST__) {
1106
+ installIntrospection({
1107
+ context: () => {
1108
+ let pending = 0;
1109
+ for (let index = 0; index < queuedLength; index++) {
1110
+ const node = queue[index];
1111
+ if (node !== undefined && !(node.flags & DISPOSED))
1112
+ pending++;
1113
+ }
1114
+ return {
1115
+ effect: activeSub,
1116
+ owner: currentOwner,
1117
+ tracking: isTrackingContext,
1118
+ suppression: suppressionDepth,
1119
+ batching: batchDepth > 0,
1120
+ pending,
1121
+ notifying: notifyDepth,
1122
+ flushing: flushLevel,
1123
+ };
1124
+ },
1125
+ cleanupStack: owner => cleanupMap.get(owner),
1126
+ subscribers: subscribersOfNode,
1127
+ links: node => {
1128
+ let count = 0;
1129
+ let current = node.subs;
1130
+ while (current !== undefined) {
1131
+ count++;
1132
+ current = current.nextSub;
1133
+ }
1134
+ return count;
1135
+ },
1136
+ dependencies: dependenciesOfNode,
1137
+ disposed: node => (node.flags & DISPOSED) !== 0,
1138
+ });
229
1139
  }
230
1140
  //# sourceMappingURL=signal.js.map